diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt index 6ee746c10..b69c715de 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt @@ -11,6 +11,7 @@ import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.utils.MimeTypes @@ -144,7 +145,8 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { val exifFields = call.argument("exif") ?: HashMap() val bytes = call.argument("bytes") var destinationDir = call.argument("destinationPath") - if (uri == null || desiredName == null || bytes == null || destinationDir == null) { + val nameConflictStrategy = NameConflictStrategy.get(call.argument("nameConflictStrategy")) + if (uri == null || desiredName == null || bytes == null || destinationDir == null || nameConflictStrategy == null) { result.error("captureFrame-args", "failed because of missing arguments", null) return } @@ -156,7 +158,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { } destinationDir = ensureTrailingSeparator(destinationDir) - provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { + provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message) }) 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 9786cd59e..a7cc8fe53 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 @@ -7,6 +7,7 @@ import android.os.Looper import android.util.Log import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.utils.LogUtils @@ -123,7 +124,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments var destinationDir = arguments["destinationPath"] as String? val mimeType = arguments["mimeType"] as String? - if (destinationDir == null || mimeType == null) { + val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) + if (destinationDir == null || mimeType == null || nameConflictStrategy == null) { error("export-args", "failed because of missing arguments", null) return } @@ -138,7 +140,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback { + provider.exportMultiple(activity, mimeType, destinationDir, entries, nameConflictStrategy, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) }) @@ -153,7 +155,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments val copy = arguments["copy"] as Boolean? var destinationDir = arguments["destinationPath"] as String? - if (copy == null || destinationDir == null) { + val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) + if (copy == null || destinationDir == null || nameConflictStrategy == null) { error("move-args", "failed because of missing arguments", null) return } @@ -168,7 +171,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) - provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback { + provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) }) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt new file mode 100644 index 000000000..f9341bcb7 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt @@ -0,0 +1,12 @@ +package deckers.thibault.aves.model + +enum class NameConflictStrategy { + SKIP, REPLACE, RENAME; + + companion object { + fun get(name: String?): NameConflictStrategy? { + name ?: return null + return valueOf(name.uppercase()) + } + } +} \ No newline at end of file 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 ed1a66634..e2e324866 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 @@ -21,6 +21,7 @@ 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.utils.* import deckers.thibault.aves.utils.MimeTypes.canEditExif import deckers.thibault.aves.utils.MimeTypes.canEditXmp @@ -34,6 +35,7 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.util.* +import kotlin.collections.HashMap abstract class ImageProvider { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { @@ -44,7 +46,7 @@ abstract class ImageProvider { throw UnsupportedOperationException("`delete` is not supported by this image provider") } - open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { + open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, nameConflictStrategy: NameConflictStrategy, entries: List, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) } @@ -57,17 +59,18 @@ abstract class ImageProvider { } suspend fun exportMultiple( - context: Context, + activity: Activity, imageExportMimeType: String, destinationDir: String, entries: List, + nameConflictStrategy: NameConflictStrategy, callback: ImageOpCallback, ) { if (!supportedExportMimeTypes.contains(imageExportMimeType)) { throw Exception("unsupported export MIME type=$imageExportMimeType") } - val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) + val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) if (destinationDirDocFile == null) { callback.onFailure(Exception("failed to create directory at path=$destinationDir")) return @@ -88,10 +91,11 @@ abstract class ImageProvider { val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType try { val newFields = exportSingleByTreeDocAndScan( - context = context, + activity = activity, sourceEntry = entry, destinationDir = destinationDir, destinationDirDocFile = destinationDirDocFile, + nameConflictStrategy = nameConflictStrategy, exportMimeType = exportMimeType, ) result["newFields"] = newFields @@ -105,10 +109,11 @@ abstract class ImageProvider { @Suppress("BlockingMethodInNonBlockingContext") private suspend fun exportSingleByTreeDocAndScan( - context: Context, + activity: Activity, sourceEntry: AvesEntry, destinationDir: String, destinationDirDocFile: DocumentFileCompat, + nameConflictStrategy: NameConflictStrategy, exportMimeType: String, ): FieldMap { val sourceMimeType = sourceEntry.mimeType @@ -125,23 +130,29 @@ abstract class ImageProvider { val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" } - val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(exportMimeType)) + val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( + activity = activity, + dir = destinationDir, + desiredNameWithoutExtension = desiredNameWithoutExtension, + extension = extensionFor(exportMimeType), + conflictStrategy = nameConflictStrategy, + ) ?: return skippedFieldMap // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, availableNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) + val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, targetNameWithoutExtension) + val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri) if (isVideo(sourceMimeType)) { - val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri) + val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri) sourceDocFile.copyTo(destinationDocFile) } else { val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { - MultiTrackImage(context, sourceUri, pageId) + MultiTrackImage(activity, sourceUri, pageId) } else if (sourceMimeType == MimeTypes.TIFF) { - TiffImage(context, sourceUri, pageId) + TiffImage(activity, sourceUri, pageId) } else { StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType) } @@ -152,7 +163,7 @@ abstract class ImageProvider { .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(true) - val target = Glide.with(context) + val target = Glide.with(activity) .asBitmap() .apply(glideOptions) .load(model) @@ -160,7 +171,7 @@ abstract class ImageProvider { try { var bitmap = target.get() if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { - bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) + bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) } bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId") @@ -188,40 +199,58 @@ abstract class ImageProvider { } } } finally { - Glide.with(context).clear(target) + Glide.with(activity).clear(target) } } val fileName = destinationDocFile.name val destinationFullPath = destinationDir + fileName - return MediaStoreImageProvider().scanNewPath(context, destinationFullPath, exportMimeType) + return MediaStoreImageProvider().scanNewPath(activity, destinationFullPath, exportMimeType) } @Suppress("BlockingMethodInNonBlockingContext") suspend fun captureFrame( - context: Context, + activity: Activity, desiredNameWithoutExtension: String, exifFields: FieldMap, bytes: ByteArray, destinationDir: String, + nameConflictStrategy: NameConflictStrategy, callback: ImageOpCallback, ) { - val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) + val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) if (destinationDirDocFile == null) { callback.onFailure(Exception("failed to create directory at path=$destinationDir")) return } val captureMimeType = MimeTypes.JPEG - val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(captureMimeType)) + val targetNameWithoutExtension = try { + resolveTargetFileNameWithoutExtension( + activity = activity, + dir = destinationDir, + desiredNameWithoutExtension = desiredNameWithoutExtension, + extension = extensionFor(captureMimeType), + conflictStrategy = nameConflictStrategy, + ) + } catch (e: Exception) { + callback.onFailure(e) + return + } + + if (targetNameWithoutExtension == null) { + // skip it + callback.onSuccess(skippedFieldMap) + return + } // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, availableNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) + val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, targetNameWithoutExtension) + val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri) try { if (exifFields.isEmpty()) { @@ -289,21 +318,51 @@ abstract class ImageProvider { val fileName = destinationDocFile.name val destinationFullPath = destinationDir + fileName - val newFields = MediaStoreImageProvider().scanNewPath(context, destinationFullPath, captureMimeType) + val newFields = MediaStoreImageProvider().scanNewPath(activity, destinationFullPath, captureMimeType) callback.onSuccess(newFields) } catch (e: Exception) { callback.onFailure(e) } } - private fun findAvailableFileNameWithoutExtension(dir: String, desiredNameWithoutExtension: String, extension: String?): String { - var nameWithoutExtension = desiredNameWithoutExtension - var i = 0 - while (File(dir, "$nameWithoutExtension$extension").exists()) { - i++ - nameWithoutExtension = "$desiredNameWithoutExtension ($i)" + // returns available name to use, or `null` to skip it + suspend fun resolveTargetFileNameWithoutExtension( + activity: Activity, + dir: String, + desiredNameWithoutExtension: String, + extension: String?, + conflictStrategy: NameConflictStrategy, + ): String? { + val targetFile = File(dir, "$desiredNameWithoutExtension$extension") + return when (conflictStrategy) { + NameConflictStrategy.RENAME -> { + var nameWithoutExtension = desiredNameWithoutExtension + var i = 0 + while (File(dir, "$nameWithoutExtension$extension").exists()) { + i++ + nameWithoutExtension = "$desiredNameWithoutExtension ($i)" + } + nameWithoutExtension + } + NameConflictStrategy.REPLACE -> { + if (targetFile.exists()) { + val path = targetFile.path + MediaStoreImageProvider().apply { + val uri = getContentUriForPath(activity, path) + uri ?: throw Exception("failed to find content URI for path=$path") + delete(activity, uri, path) + } + } + desiredNameWithoutExtension + } + NameConflictStrategy.SKIP -> { + if (targetFile.exists()) { + null + } else { + desiredNameWithoutExtension + } + } } - return nameWithoutExtension } suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { @@ -476,7 +535,7 @@ abstract class ImageProvider { // A few bytes are sometimes appended when writing to a document output stream. // In that case, we need to adjust the trailer video offset accordingly and rewrite the file. - // return whether the file at `path` is fine + // returns whether the file at `path` is fine private fun checkTrailerOffset( context: Context, path: String, @@ -635,7 +694,7 @@ abstract class ImageProvider { } if (success) { - scanPostMetadataEdit(context, path, uri, mimeType, HashMap(), callback) + scanPostMetadataEdit(context, path, uri, mimeType, HashMap(), callback) } } @@ -701,5 +760,8 @@ abstract class ImageProvider { private val LOG_TAG = LogUtils.createTag() val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP) + + // used when skipping a move/creation op because the target file already exists + val skippedFieldMap: HashMap = hashMapOf("skipped" to true) } } 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 918a6990b..9fd3dc9c6 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 @@ -14,6 +14,7 @@ import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes @@ -270,6 +271,7 @@ class MediaStoreImageProvider : ImageProvider() { activity: Activity, copy: Boolean, destinationDir: String, + nameConflictStrategy: NameConflictStrategy, entries: List, callback: ImageOpCallback, ) { @@ -306,7 +308,14 @@ class MediaStoreImageProvider : ImageProvider() { // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage try { val newFields = moveSingleByTreeDocAndScan( - activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, + activity = activity, + sourcePath = sourcePath, + sourceUri = sourceUri, + destinationDir = destinationDir, + destinationDirDocFile = destinationDirDocFile, + nameConflictStrategy = nameConflictStrategy, + mimeType = mimeType, + copy = copy, ) result["newFields"] = newFields result["success"] = true @@ -324,6 +333,7 @@ class MediaStoreImageProvider : ImageProvider() { sourceUri: Uri, destinationDir: String, destinationDirDocFile: DocumentFileCompat, + nameConflictStrategy: NameConflictStrategy, mimeType: String, copy: Boolean, ): FieldMap { @@ -336,17 +346,20 @@ class MediaStoreImageProvider : ImageProvider() { val sourceFileName = sourceFile.name val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") - - if (File(destinationDir, sourceFileName).exists()) { - throw Exception("file with name=$sourceFileName already exists in destination directory") - } + val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( + activity = activity, + dir = destinationDir, + desiredNameWithoutExtension = desiredNameWithoutExtension, + extension = MimeTypes.extensionFor(mimeType), + conflictStrategy = nameConflictStrategy, + ) ?: return skippedFieldMap // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first @Suppress("BlockingMethodInNonBlockingContext") - val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension) + val destinationTreeFile = destinationDirDocFile.createFile(mimeType, targetNameWithoutExtension) val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri) // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry @@ -445,9 +458,9 @@ class MediaStoreImageProvider : ImageProvider() { val contentId = newUri.tryParseId() if (contentId != null) { if (isImage(mimeType)) { - contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) + contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId) } else if (isVideo(mimeType)) { - contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId) + contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId) } } @@ -462,6 +475,29 @@ class MediaStoreImageProvider : ImageProvider() { } } + fun getContentUriForPath(context: Context, path: String): Uri? { + val projection = arrayOf(MediaStore.MediaColumns._ID) + val selection = "${MediaColumns.PATH} = ?" + val selectionArgs = arrayOf(path) + + fun check(context: Context, contentUri: Uri): Uri? { + var mediaContentUri: Uri? = null + try { + val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null) + if (cursor != null && cursor.moveToFirst()) { + cursor.getColumnIndex(MediaStore.MediaColumns._ID).let { + if (it != -1) mediaContentUri = ContentUris.withAppendedId(contentUri, cursor.getLong(it)) + } + cursor.close() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to get URI for contentUri=$contentUri path=$path", e) + } + return mediaContentUri + } + return check(context, IMAGE_CONTENT_URI) ?: check(context, VIDEO_CONTENT_URI) + } + companion object { private val LOG_TAG = LogUtils.createTag() 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 a5cece985..412f3a20a 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 @@ -23,21 +23,34 @@ object MimeTypes { // raw raster private const val ARW = "image/x-sony-arw" private const val CR2 = "image/x-canon-cr2" + private const val CRW = "image/x-canon-crw" + private const val DCR = "image/x-kodak-dcr" private const val DNG = "image/x-adobe-dng" + private const val ERF = "image/x-epson-erf" + private const val K25 = "image/x-kodak-k25" + private const val KDC = "image/x-kodak-kdc" + private const val MRW = "image/x-minolta-mrw" private const val NEF = "image/x-nikon-nef" private const val NRW = "image/x-nikon-nrw" private const val ORF = "image/x-olympus-orf" private const val PEF = "image/x-pentax-pef" private const val RAF = "image/x-fuji-raf" + private const val RAW = "image/x-panasonic-raw" private const val RW2 = "image/x-panasonic-rw2" + private const val SR2 = "image/x-sony-sr2" + private const val SRF = "image/x-sony-srf" private const val SRW = "image/x-samsung-srw" + private const val X3F = "image/x-sigma-x3f" // vector const val SVG = "image/svg+xml" private const val VIDEO = "video" + private const val AVI = "video/avi" + private const val AVI_VND = "video/vnd.avi" private const val MKV = "video/x-matroska" + private const val MOV = "video/quicktime" private const val MP2T = "video/mp2t" private const val MP2TS = "video/mp2ts" const val MP4 = "video/mp4" @@ -125,14 +138,45 @@ object MimeTypes { // extensions fun extensionFor(mimeType: String): String? = when (mimeType) { + ARW -> ".arw" + AVI, AVI_VND -> ".avi" BMP -> ".bmp" + CR2 -> ".cr2" + CRW -> ".crw" + DCR -> ".dcr" + DJVU -> ".djvu" + DNG -> ".dng" + ERF -> ".erf" GIF -> ".gif" HEIC, HEIF -> ".heif" + ICO -> ".ico" JPEG -> ".jpg" + K25 -> ".k25" + KDC -> ".kdc" + MKV -> ".mkv" + MOV -> ".mov" + MP2T, MP2TS -> ".m2ts" MP4 -> ".mp4" + MRW -> ".mrw" + NEF -> ".nef" + NRW -> ".nrw" + OGV -> ".ogv" + ORF -> ".orf" + PEF -> ".pef" PNG -> ".png" + PSD_VND, PSD_X -> ".psd" + RAF -> ".raf" + RAW -> ".raw" + RW2 -> ".rw2" + SR2 -> ".sr2" + SRF -> ".srf" + SRW -> ".srw" + SVG -> ".svg" TIFF -> ".tiff" + WBMP -> ".wbmp" + WEBM -> ".webm" WEBP -> ".webp" + X3F -> ".x3f" else -> null } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6e688ef96..b57fb4ae0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -193,6 +193,13 @@ "mapStyleStamenWatercolor": "Stamen Watercolor", "@mapStyleStamenWatercolor": {}, + "nameConflictStrategyRename": "Rename", + "@nameConflictStrategyRename": {}, + "nameConflictStrategyReplace": "Replace", + "@nameConflictStrategyReplace": {}, + "nameConflictStrategySkip": "Skip", + "@nameConflictStrategySkip": {}, + "keepScreenOnNever": "Never", "@keepScreenOnNever": {}, "keepScreenOnViewerOnly": "Viewer page only", @@ -273,6 +280,11 @@ } }, + "nameConflictDialogSingleSourceMessage": "Some files in the destination folder have the same name.", + "@nameConflictDialogSingleSourceMessage": {}, + "nameConflictDialogMultipleSourceMessage": "Some files have the same name.", + "@nameConflictDialogMultipleSourceMessage": {}, + "addShortcutDialogLabel": "Shortcut label", "@addShortcutDialogLabel": {}, "addShortcutButtonLabel": "ADD", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 445f383be..cff16555c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -97,6 +97,10 @@ "mapStyleStamenToner": "Stamen 토너", "mapStyleStamenWatercolor": "Stamen 수채화", + "nameConflictStrategyRename": "이름 변경", + "nameConflictStrategyReplace": "대체", + "nameConflictStrategySkip": "건너뛰기", + "keepScreenOnNever": "자동 꺼짐", "keepScreenOnViewerOnly": "뷰어 이용 시 작동", "keepScreenOnAlways": "항상 켜짐", @@ -121,6 +125,9 @@ "notEnoughSpaceDialogTitle": "저장공간 부족", "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", + "nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.", + "nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.", + "addShortcutDialogLabel": "바로가기 라벨", "addShortcutButtonLabel": "추가", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 5da5eb27a..8d97731e2 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -76,6 +76,7 @@ class AvesEntry { String? uri, String? path, int? contentId, + String? title, int? dateModifiedSecs, List? burstEntries, }) { @@ -90,7 +91,7 @@ class AvesEntry { height: height, sourceRotationDegrees: sourceRotationDegrees, sizeBytes: sizeBytes, - sourceTitle: sourceTitle, + sourceTitle: title ?? sourceTitle, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index a9d34a885..a08d56cad 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -215,6 +215,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM uri: newFields['uri'] as String?, path: newFields['path'] as String?, contentId: newFields['contentId'] as int?, + // title can change when moved files are automatically renamed to avoid conflict + title: newFields['title'] as String?, dateModifiedSecs: newFields['dateModifiedSecs'] as int?, )); } diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index b19e333b3..b81428f18 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -46,7 +46,7 @@ class MimeTypes { static const mov = 'video/quicktime'; static const mp2t = 'video/mp2t'; // .m2ts static const mp4 = 'video/mp4'; - static const ogg = 'video/ogg'; + static const ogv = 'video/ogg'; static const webm = 'video/webm'; static const json = 'application/json'; @@ -67,7 +67,7 @@ class MimeTypes { static const Set _knownOpaqueImages = {heic, heif, jpeg}; - static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogg, webm}; + static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogv, webm}; static final Set knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; diff --git a/lib/services/media/enums.dart b/lib/services/media/enums.dart new file mode 100644 index 000000000..7ffed6cf4 --- /dev/null +++ b/lib/services/media/enums.dart @@ -0,0 +1,20 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +// names should match possible values on platform +enum NameConflictStrategy { rename, replace, skip } + +extension ExtraNameConflictStrategy on NameConflictStrategy { + String toPlatform() => toString().substring('NameConflictStrategy.'.length); + + String getName(BuildContext context) { + switch (this) { + case NameConflictStrategy.rename: + return context.l10n.nameConflictStrategyRename; + case NameConflictStrategy.replace: + return context.l10n.nameConflictStrategyReplace; + case NameConflictStrategy.skip: + return context.l10n.nameConflictStrategySkip; + } + } +} diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index 7301795f9..fbf6ddc1b 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -9,6 +9,7 @@ import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/output_buffer.dart'; import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; @@ -73,12 +74,14 @@ abstract class MediaFileService { Iterable entries, { required bool copy, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }); Stream export( Iterable entries, { required String mimeType, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }); Future> captureFrame( @@ -87,6 +90,7 @@ abstract class MediaFileService { required Map exif, required Uint8List bytes, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }); Future> rename(AvesEntry entry, String newName); @@ -305,6 +309,7 @@ class PlatformMediaFileService implements MediaFileService { Iterable entries, { required bool copy, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }) { try { return _opStreamChannel.receiveBroadcastStream({ @@ -312,6 +317,7 @@ class PlatformMediaFileService implements MediaFileService { 'entries': entries.map(_toPlatformEntryMap).toList(), 'copy': copy, 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), }).map((event) => MoveOpEvent.fromMap(event)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); @@ -324,6 +330,7 @@ class PlatformMediaFileService implements MediaFileService { Iterable entries, { required String mimeType, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }) { try { return _opStreamChannel.receiveBroadcastStream({ @@ -331,6 +338,7 @@ class PlatformMediaFileService implements MediaFileService { 'entries': entries.map(_toPlatformEntryMap).toList(), 'mimeType': mimeType, 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), }).map((event) => ExportOpEvent.fromMap(event)); } on PlatformException catch (e, stack) { reportService.recordError(e, stack); @@ -345,6 +353,7 @@ class PlatformMediaFileService implements MediaFileService { required Map exif, required Uint8List bytes, required String destinationAlbum, + required NameConflictStrategy nameConflictStrategy, }) async { try { final result = await platform.invokeMethod('captureFrame', { @@ -353,6 +362,7 @@ class PlatformMediaFileService implements MediaFileService { 'exif': exif, 'bytes': bytes, 'destinationPath': destinationAlbum, + 'nameConflictStrategy': nameConflictStrategy.toPlatform(), }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index bb9ede8ab..bd1a284ae 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; @@ -11,6 +12,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; @@ -19,6 +21,7 @@ import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/stats/stats_page.dart'; @@ -78,6 +81,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } Future _moveSelection(BuildContext context, {required MoveType moveType}) async { + final l10n = context.l10n; final source = context.read(); final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); @@ -119,13 +123,44 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final todoCount = todoEntries.length; assert(todoCount > 0); + final destinationDirectory = Directory(destinationAlbum); + final names = [ + ...todoEntries.map((v) => '${v.filenameWithoutExtension}${v.extension}'), + // do not guard up front based on directory existence, + // as conflicts could be within moved entries scattered across multiple albums + if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), + ]; + final uniqueNames = names.toSet(); + var nameConflictStrategy = NameConflictStrategy.rename; + if (uniqueNames.length < names.length) { + final value = await showDialog( + context: context, + builder: (context) { + return AvesSelectionDialog( + initialValue: nameConflictStrategy, + options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))), + message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage, + confirmationButtonLabel: l10n.continueButtonLabel, + ); + }, + ); + if (value == null) return; + nameConflictStrategy = value; + } + source.pauseMonitoring(); showOpReport( context: context, - opStream: mediaFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum), + opStream: mediaFileService.move( + todoEntries, + copy: copy, + destinationAlbum: destinationAlbum, + nameConflictStrategy: nameConflictStrategy, + ), itemCount: todoCount, onDone: (processed) async { - final movedOps = processed.where((e) => e.success).toSet(); + final successOps = processed.where((e) => e.success).toSet(); + final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet(); await source.updateAfterMove( todoEntries: todoEntries, copy: copy, @@ -140,50 +175,51 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware await storageService.deleteEmptyDirectories(selectionDirs); } - final l10n = context.l10n; - final movedCount = movedOps.length; - if (movedCount < todoCount) { - final count = todoCount - movedCount; + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); } else { - final count = movedCount; + final count = movedOps.length; showFeedback( context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count), - SnackBarAction( - label: context.l10n.showButtonLabel, - onPressed: () async { - final highlightInfo = context.read(); - final collection = context.read(); - var targetCollection = collection; - if (collection.filters.any((f) => f is AlbumFilter)) { - final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); - // we could simply add the filter to the current collection - // but navigating makes the change less jarring - targetCollection = CollectionLens( - source: collection.source, - filters: collection.filters, - )..addFilter(filter); - unawaited(Navigator.pushReplacement( - context, - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - collection: targetCollection, - ), - ), - )); - final delayDuration = context.read().staggeredAnimationPageTarget; - await Future.delayed(delayDuration); - } - await Future.delayed(Durations.highlightScrollInitDelay); - final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); - final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); - if (targetEntry != null) { - highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); - } - }, - ), + count > 0 + ? SnackBarAction( + label: l10n.showButtonLabel, + onPressed: () async { + final highlightInfo = context.read(); + final collection = context.read(); + var targetCollection = collection; + if (collection.filters.any((f) => f is AlbumFilter)) { + final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); + // we could simply add the filter to the current collection + // but navigating makes the change less jarring + targetCollection = CollectionLens( + source: collection.source, + filters: collection.filters, + )..addFilter(filter); + unawaited(Navigator.pushReplacement( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + collection: targetCollection, + ), + ), + )); + final delayDuration = context.read().staggeredAnimationPageTarget; + await Future.delayed(delayDuration); + } + await Future.delayed(Durations.highlightScrollInitDelay); + final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); + final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); + if (targetEntry != null) { + highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); + } + }, + ) + : null, ); } }, diff --git a/lib/widgets/dialogs/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart index d339470a4..65ef50f58 100644 --- a/lib/widgets/dialogs/aves_selection_dialog.dart +++ b/lib/widgets/dialogs/aves_selection_dialog.dart @@ -10,14 +10,16 @@ class AvesSelectionDialog extends StatefulWidget { final T initialValue; final Map options; final TextBuilder? optionSubtitleBuilder; - final String title; + final String? title, message, confirmationButtonLabel; const AvesSelectionDialog({ Key? key, required this.initialValue, required this.options, this.optionSubtitleBuilder, - required this.title, + this.title, + this.message, + this.confirmationButtonLabel, }) : super(key: key); @override @@ -35,27 +37,48 @@ class _AvesSelectionDialogState extends State> { @override Widget build(BuildContext context) { + final message = widget.message; + final confirmationButtonLabel = widget.confirmationButtonLabel; + final needConfirmation = confirmationButtonLabel != null; return AvesDialog( context: context, title: widget.title, - scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(), + scrollableContent: [ + if (message != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text(message), + ), + ...widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value, needConfirmation)), + ], actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), + if (needConfirmation) + TextButton( + onPressed: () => Navigator.pop(context, _selectedValue), + child: Text(confirmationButtonLabel!), + ), ], ); } - Widget _buildRadioListTile(T value, String title) { + Widget _buildRadioListTile(T value, String title, bool needConfirmation) { final subtitle = widget.optionSubtitleBuilder?.call(value); return ReselectableRadioListTile( // key is expected by test driver key: Key(value.toString()), value: value, groupValue: _selectedValue, - onChanged: (v) => Navigator.pop(context, v), + onChanged: (v) { + if (needConfirmation) { + setState(() => _selectedValue = v!); + } else { + Navigator.pop(context, v); + } + }, reselectable: true, title: Text( title, diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index a2f0bb183..334f455b7 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -10,6 +10,7 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -226,7 +227,13 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { source.pauseMonitoring(); showOpReport( context: context, - opStream: mediaFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum), + opStream: mediaFileService.move( + todoEntries, + copy: false, + destinationAlbum: destinationAlbum, + // there should be no file conflict, as the target directory itself does not exist + nameConflictStrategy: NameConflictStrategy.rename, + ), itemCount: todoCount, onDone: (processed) async { final movedOps = processed.where((e) => e.success).toSet(); diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index aba76e6f6..23b7ffe8e 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -13,6 +13,7 @@ import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -208,6 +209,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix selection, mimeType: MimeTypes.jpeg, destinationAlbum: destinationAlbum, + nameConflictStrategy: NameConflictStrategy.rename, ), itemCount: selectionCount, onDone: (processed) { diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index 96ac37e7f..6f87bef12 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; @@ -91,6 +92,7 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix exif: exif, bytes: bytes, destinationAlbum: destinationAlbum, + nameConflictStrategy: NameConflictStrategy.rename, ); final success = newFields.isNotEmpty;