#75 ask to rename/replace/skip on move/copy with name conflict

This commit is contained in:
Thibault Deckers 2021-10-04 14:39:07 +09:00
parent ff92100dcf
commit 34cd727c52
18 changed files with 375 additions and 94 deletions

View file

@ -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<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes")
var destinationDir = call.argument<String>("destinationPath")
if (uri == null || desiredName == null || bytes == null || destinationDir == null) {
val nameConflictStrategy = NameConflictStrategy.get(call.argument<String>("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)
})

View file

@ -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)
})

View file

@ -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())
}
}
}

View file

@ -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<AvesEntry>, callback: ImageOpCallback) {
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, nameConflictStrategy: NameConflictStrategy, entries: List<AvesEntry>, 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<AvesEntry>,
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 {
// 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)"
}
return nameWithoutExtension
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
}
}
}
}
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<String, Any?>(), callback)
scanPostMetadataEdit(context, path, uri, mimeType, HashMap(), callback)
}
}
@ -701,5 +760,8 @@ abstract class ImageProvider {
private val LOG_TAG = LogUtils.createTag<ImageProvider>()
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<String, Any?> = hashMapOf("skipped" to true)
}
}

View file

@ -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<AvesEntry>,
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<MediaStoreImageProvider>()

View file

@ -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
}

View file

@ -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",

View file

@ -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": "추가",

View file

@ -76,6 +76,7 @@ class AvesEntry {
String? uri,
String? path,
int? contentId,
String? title,
int? dateModifiedSecs,
List<AvesEntry>? 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,

View file

@ -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?,
));
}

View file

@ -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<String> _knownOpaqueImages = {heic, heif, jpeg};
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogg, webm};
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogv, webm};
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};

View file

@ -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;
}
}
}

View file

@ -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<AvesEntry> entries, {
required bool copy,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required String mimeType,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
Future<Map<String, dynamic>> captureFrame(
@ -87,6 +90,7 @@ abstract class MediaFileService {
required Map<String, dynamic> exif,
required Uint8List bytes,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
@ -305,6 +309,7 @@ class PlatformMediaFileService implements MediaFileService {
Iterable<AvesEntry> entries, {
required bool copy,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
@ -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<AvesEntry> entries, {
required String mimeType,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
@ -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<String, dynamic> exif,
required Uint8List bytes,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) async {
try {
final result = await platform.invokeMethod('captureFrame', <String, dynamic>{
@ -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<String, dynamic>();
} on PlatformException catch (e, stack) {

View file

@ -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<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
final l10n = context.l10n;
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
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<NameConflictStrategy>(
context: context,
builder: (context) {
return AvesSelectionDialog<NameConflictStrategy>(
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<MoveOpEvent>(
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,18 +175,18 @@ 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,
count > 0
? SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () async {
final highlightInfo = context.read<HighlightInfo>();
final collection = context.read<CollectionLens>();
@ -183,7 +218,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
}
},
),
)
: null,
);
}
},

View file

@ -10,14 +10,16 @@ class AvesSelectionDialog<T> extends StatefulWidget {
final T initialValue;
final Map<T, String> options;
final TextBuilder<T>? 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<T> extends State<AvesSelectionDialog<T>> {
@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<T>(
// 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,

View file

@ -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<AlbumFilter> {
source.pauseMonitoring();
showOpReport<MoveOpEvent>(
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();

View file

@ -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) {

View file

@ -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;