diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index dce85d755..dd775eea7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -92,19 +92,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments } private suspend fun delete() { - if (entryMapList.isEmpty()) { - endOfStream() - return - } - - // assume same provider for all entries - val firstEntry = entryMapList.first() - val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) } - if (provider == null) { - error("delete-provider", "failed to find provider for entry=$firstEntry", null) - return - } - val entries = entryMapList.map(::AvesEntry) for (entry in entries) { val mimeType = entry.mimeType @@ -119,12 +106,14 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments if (isCancelledOp()) { result["skipped"] = true } else { - try { - provider.delete(activity, uri, path, mimeType) - result["success"] = true - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to delete entry with path=$path", e) - result["success"] = false + result["success"] = false + getProvider(uri)?.let { provider -> + try { + provider.delete(activity, uri, path, mimeType) + result["success"] = true + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to delete entry with path=$path", e) + } } } success(result) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt index 7ef565d8b..13eaff424 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.ContextWrapper import android.net.Uri import android.util.Log +import android.webkit.MimeTypeMap import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils @@ -12,12 +13,19 @@ import java.io.File internal class FileImageProvider : ImageProvider() { override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { - if (sourceMimeType == null) { - callback.onFailure(Exception("MIME type is null for uri=$uri")) - return + val mimeType = if (sourceMimeType != null) { + sourceMimeType + } else { + val fromExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + if (fromExtension != null) { + fromExtension + } else { + callback.onFailure(Exception("MIME type was not provided and cannot be guessed from extension of uri=$uri")) + return + } } - val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, sourceMimeType) + val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, mimeType) val path = uri.path if (path != null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 8e973af43..7d171625e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -10,6 +10,7 @@ import android.net.Uri import android.os.Binder import android.os.Build import android.util.Log +import androidx.core.net.toUri import androidx.exifinterface.media.ExifInterface import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat @@ -31,10 +32,7 @@ import deckers.thibault.aves.metadata.Mp4ParserHelper.updateRotation import deckers.thibault.aves.metadata.Mp4ParserHelper.updateXmp import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString -import deckers.thibault.aves.model.AvesEntry -import deckers.thibault.aves.model.ExifOrientationOp -import deckers.thibault.aves.model.FieldMap -import deckers.thibault.aves.model.NameConflictStrategy +import deckers.thibault.aves.model.* import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.FileUtils.transferFrom import deckers.thibault.aves.utils.FileUtils.transferTo @@ -53,6 +51,19 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider")) } + suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap { + return if (StorageUtils.isInVault(context, path)) { + hashMapOf( + "origin" to SourceEntry.ORIGIN_VAULT, + "uri" to File(path).toUri().toString(), + "contentId" to null, + "path" to path, + ) + } else { + MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType) + } + } + open suspend fun delete(contextWrapper: ContextWrapper, uri: Uri, path: String?, mimeType: String) { throw UnsupportedOperationException("`delete` is not supported by this image provider") } @@ -294,8 +305,7 @@ abstract class ImageProvider { } } - val mediaStoreImageProvider = MediaStoreImageProvider() - val targetPath = mediaStoreImageProvider.createSingle( + val targetPath = MediaStoreImageProvider().createSingle( activity = activity, mimeType = targetMimeType, targetDir = targetDir, @@ -303,7 +313,7 @@ abstract class ImageProvider { targetNameWithoutExtension = targetNameWithoutExtension, write = write, ) - return mediaStoreImageProvider.scanNewPath(activity, targetPath, exportMimeType) + return scanNewPath(activity, targetPath, exportMimeType) } finally { // clearing Glide target should happen after effectively writing the bitmap Glide.with(activity).clear(target) @@ -422,7 +432,7 @@ abstract class ImageProvider { val fileName = targetDocFile.name val targetFullPath = targetDir + fileName - val newFields = MediaStoreImageProvider().scanNewPath(contextWrapper, targetFullPath, captureMimeType) + val newFields = scanNewPath(contextWrapper, targetFullPath, captureMimeType) callback.onSuccess(newFields) } catch (e: Exception) { callback.onFailure(e) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index c57872841..3983ff52e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -9,7 +9,7 @@ import android.net.Uri import android.os.Build import android.provider.MediaStore import android.util.Log -import androidx.core.net.toUri +import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST @@ -30,6 +30,7 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.io.File +import java.io.FileOutputStream import java.io.OutputStream import java.io.SyncFailedException import java.util.* @@ -474,7 +475,6 @@ class MediaStoreImageProvider : ImageProvider() { mimeType = mimeType, copy = copy, toBin = toBin, - toVault = toVault, ) } } @@ -501,7 +501,6 @@ class MediaStoreImageProvider : ImageProvider() { mimeType: String, copy: Boolean, toBin: Boolean, - toVault: Boolean, ): FieldMap { val sourcePath = sourceFile.path val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) } @@ -550,21 +549,11 @@ class MediaStoreImageProvider : ImageProvider() { "trashed" to true, "trashPath" to targetPath, ) - } else if (toVault) { - hashMapOf( - "origin" to SourceEntry.ORIGIN_VAULT, - "uri" to File(targetPath).toUri().toString(), - "contentId" to null, - "path" to targetPath, - ) } else { scanNewPath(activity, targetPath, mimeType) } } - // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry - // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" - // when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri` fun createSingle( activity: Activity, mimeType: String, @@ -573,33 +562,86 @@ class MediaStoreImageProvider : ImageProvider() { targetNameWithoutExtension: String, write: (OutputStream) -> Unit, ): String { + if (StorageUtils.isInVault(activity, targetDir)) { + return insertByFile( + targetDir = targetDir, + targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}", + write = write, + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val downloadDirPath = StorageUtils.getDownloadDirPath(activity, targetDir) val isDownloadSubdir = downloadDirPath != null && targetDir.startsWith(downloadDirPath) if (isDownloadSubdir) { - val volumePath = StorageUtils.getVolumePath(activity, targetDir) - val relativePath = targetDir.substring(volumePath?.length ?: 0) - - val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}" - val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - put(MediaStore.MediaColumns.IS_PENDING, 1) - } - val resolver = activity.contentResolver - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) - - uri?.let { - resolver.openOutputStream(uri)?.use(write) - values.clear() - values.put(MediaStore.MediaColumns.IS_PENDING, 0) - resolver.update(uri, values, null, null) - } ?: throw Exception("MediaStore failed for some reason") - - return File(targetDir, targetFileName).path + return insertByMediaStore( + activity = activity, + targetDir = targetDir, + targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}", + write = write, + ) } } + return insertByTreeDoc( + activity = activity, + mimeType = mimeType, + targetDir = targetDir, + targetDirDocFile = targetDirDocFile, + targetNameWithoutExtension = targetNameWithoutExtension, + write = write, + ) + } + + private fun insertByFile( + targetDir: String, + targetFileName: String, + write: (OutputStream) -> Unit, + ): String { + val file = File(targetDir, targetFileName) + FileOutputStream(file).use(write) + return file.path + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun insertByMediaStore( + activity: Activity, + targetDir: String, + targetFileName: String, + write: (OutputStream) -> Unit, + ): String { + val volumePath = StorageUtils.getVolumePath(activity, targetDir) + val relativePath = targetDir.substring(volumePath?.length ?: 0) + + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val resolver = activity.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + + uri?.let { + resolver.openOutputStream(uri)?.use(write) + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } ?: throw Exception("MediaStore failed for some reason") + + return File(targetDir, targetFileName).path + } + + // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry + // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" + // when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri` + private fun insertByTreeDoc( + activity: Activity, + mimeType: String, + targetDir: String, + targetDirDocFile: DocumentFileCompat?, + targetNameWithoutExtension: String, + write: (OutputStream) -> Unit, + ): String { targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir") // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` @@ -670,7 +712,7 @@ class MediaStoreImageProvider : ImageProvider() { } // URI should not change - return scanNewPath(activity, newFile.path, mimeType) + return scanNewPathByMediaStore(activity, newFile.path, mimeType) } private suspend fun renameSingleByTreeDoc( @@ -690,7 +732,7 @@ class MediaStoreImageProvider : ImageProvider() { throw Exception("failed to rename document at path=$oldPath") } scanObsoletePath(activity, oldMediaUri, oldPath, mimeType) - return scanNewPath(activity, newFile.path, mimeType) + return scanNewPathByMediaStore(activity, newFile.path, mimeType) } private suspend fun renameSingleByFile( @@ -706,7 +748,7 @@ class MediaStoreImageProvider : ImageProvider() { throw Exception("failed to rename file at path=$oldPath") } scanObsoletePath(activity, oldMediaUri, oldPath, mimeType) - return scanNewPath(activity, newFile.path, mimeType) + return scanNewPathByMediaStore(activity, newFile.path, mimeType) } override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) { @@ -757,10 +799,23 @@ class MediaStoreImageProvider : ImageProvider() { } } - suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap = - suspendCoroutine { cont -> tryScanNewPath(context, path = path, mimeType = mimeType, cont) } + suspend fun scanNewPathByMediaStore(context: Context, path: String, mimeType: String): FieldMap = + suspendCoroutine { cont -> + tryScanNewPathByMediaStore( + context = context, + path = path, + mimeType = mimeType, + cont = cont, + ) + } - private fun tryScanNewPath(context: Context, path: String, mimeType: String, cont: Continuation, iteration: Int = 0) { + private fun tryScanNewPathByMediaStore( + context: Context, + path: String, + mimeType: String, + cont: Continuation, + iteration: Int = 0, + ) { // `scanFile` may (e.g. when copying to SD card on Android 10 (API 29)): // 1) yield no URI, // 2) yield a temporary URI that fails when queried, @@ -832,7 +887,7 @@ class MediaStoreImageProvider : ImageProvider() { } } - tryScanNewPath(context, path = path, mimeType = mimeType, cont, iteration + 1) + tryScanNewPathByMediaStore(context, path = path, mimeType = mimeType, cont, iteration + 1) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 1215d7ab7..31d0e9986 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -70,7 +70,7 @@ object MimeTypes { fun isRaw(mimeType: String): Boolean { return when (mimeType) { - ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW -> true + ARW, CR2, CRW, DCR, DNG, ERF, K25, KDC, MRW, NEF, NRW, ORF, PEF, RAF, RAW, RW2, SR2, SRF, SRW, X3F -> true else -> false } } diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 67bfaa69d..7744ca664 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -105,7 +105,7 @@ class MediaStoreSource extends CollectionSource { // with items that may be hidden right away because of their metadata addEntries(knownEntries, notify: false); - await _addVaultEntries(directory); + await _loadVaultEntries(directory); debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata'); if (directory != null) { @@ -266,6 +266,13 @@ class MediaStoreSource extends CollectionSource { } } + await _refreshVaultEntries( + changedUris: changedUris.where(vaults.isVaultEntryUri).toSet(), + newEntries: newEntries, + entriesToRefresh: entriesToRefresh, + existingDirectories: existingDirectories, + ); + invalidateAlbumFilterSummary(directories: existingDirectories); if (newEntries.isNotEmpty) { @@ -278,21 +285,21 @@ class MediaStoreSource extends CollectionSource { await refreshEntries(entriesToRefresh, EntryDataType.values.toSet()); } - await _refreshVaultEntries(changedUris.where(vaults.isVaultEntryUri).toSet()); - return tempUris; } // vault - Future _addVaultEntries(String? directory) async { + Future _loadVaultEntries(String? directory) async { addEntries(await metadataDb.loadEntries(origin: EntryOrigins.vault, directory: directory)); } - Future _refreshVaultEntries(Set changedUris) async { - final entriesToRefresh = {}; - final existingDirectories = {}; - + Future _refreshVaultEntries({ + required Set changedUris, + required Set newEntries, + required Set entriesToRefresh, + required Set existingDirectories, + }) async { for (final uri in changedUris) { final existingEntry = allEntries.firstWhereOrNull((entry) => entry.uri == uri); if (existingEntry != null) { @@ -301,13 +308,15 @@ class MediaStoreSource extends CollectionSource { if (existingDirectory != null) { existingDirectories.add(existingDirectory); } + } else { + final sourceEntry = await mediaFetchService.getEntry(uri, null); + if (sourceEntry != null) { + newEntries.add(sourceEntry.copyWith( + id: metadataDb.nextId, + origin: EntryOrigins.vault, + )); + } } } - - invalidateAlbumFilterSummary(directories: existingDirectories); - - if (entriesToRefresh.isNotEmpty) { - await refreshEntries(entriesToRefresh, EntryDataType.values.toSet()); - } } }