#75 ask to rename/replace/skip on move/copy with name conflict
This commit is contained in:
parent
ff92100dcf
commit
34cd727c52
18 changed files with 375 additions and 94 deletions
|
@ -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.ThumbnailFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
||||||
import deckers.thibault.aves.model.FieldMap
|
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.ImageProvider.ImageOpCallback
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
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 exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
||||||
val bytes = call.argument<ByteArray>("bytes")
|
val bytes = call.argument<ByteArray>("bytes")
|
||||||
var destinationDir = call.argument<String>("destinationPath")
|
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)
|
result.error("captureFrame-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -156,7 +158,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationDir = ensureTrailingSeparator(destinationDir)
|
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 onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
|
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.FieldMap
|
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.ImageProvider.ImageOpCallback
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
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?
|
var destinationDir = arguments["destinationPath"] as String?
|
||||||
val mimeType = arguments["mimeType"] 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)
|
error("export-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -138,7 +140,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||||
val entries = entryMapList.map(::AvesEntry)
|
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 onSuccess(fields: FieldMap) = success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
|
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?
|
val copy = arguments["copy"] as Boolean?
|
||||||
var destinationDir = arguments["destinationPath"] as String?
|
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)
|
error("move-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -168,7 +171,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||||
val entries = entryMapList.map(::AvesEntry)
|
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 onSuccess(fields: FieldMap) = success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
import deckers.thibault.aves.model.NameConflictStrategy
|
||||||
import deckers.thibault.aves.utils.*
|
import deckers.thibault.aves.utils.*
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
||||||
|
@ -34,6 +35,7 @@ import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import kotlin.collections.HashMap
|
||||||
|
|
||||||
abstract class ImageProvider {
|
abstract class ImageProvider {
|
||||||
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
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")
|
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"))
|
callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,17 +59,18 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun exportMultiple(
|
suspend fun exportMultiple(
|
||||||
context: Context,
|
activity: Activity,
|
||||||
imageExportMimeType: String,
|
imageExportMimeType: String,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
entries: List<AvesEntry>,
|
entries: List<AvesEntry>,
|
||||||
|
nameConflictStrategy: NameConflictStrategy,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
||||||
throw Exception("unsupported export MIME type=$imageExportMimeType")
|
throw Exception("unsupported export MIME type=$imageExportMimeType")
|
||||||
}
|
}
|
||||||
|
|
||||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
|
||||||
if (destinationDirDocFile == null) {
|
if (destinationDirDocFile == null) {
|
||||||
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
||||||
return
|
return
|
||||||
|
@ -88,10 +91,11 @@ abstract class ImageProvider {
|
||||||
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
|
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
|
||||||
try {
|
try {
|
||||||
val newFields = exportSingleByTreeDocAndScan(
|
val newFields = exportSingleByTreeDocAndScan(
|
||||||
context = context,
|
activity = activity,
|
||||||
sourceEntry = entry,
|
sourceEntry = entry,
|
||||||
destinationDir = destinationDir,
|
destinationDir = destinationDir,
|
||||||
destinationDirDocFile = destinationDirDocFile,
|
destinationDirDocFile = destinationDirDocFile,
|
||||||
|
nameConflictStrategy = nameConflictStrategy,
|
||||||
exportMimeType = exportMimeType,
|
exportMimeType = exportMimeType,
|
||||||
)
|
)
|
||||||
result["newFields"] = newFields
|
result["newFields"] = newFields
|
||||||
|
@ -105,10 +109,11 @@ abstract class ImageProvider {
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun exportSingleByTreeDocAndScan(
|
private suspend fun exportSingleByTreeDocAndScan(
|
||||||
context: Context,
|
activity: Activity,
|
||||||
sourceEntry: AvesEntry,
|
sourceEntry: AvesEntry,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
destinationDirDocFile: DocumentFileCompat,
|
destinationDirDocFile: DocumentFileCompat,
|
||||||
|
nameConflictStrategy: NameConflictStrategy,
|
||||||
exportMimeType: String,
|
exportMimeType: String,
|
||||||
): FieldMap {
|
): FieldMap {
|
||||||
val sourceMimeType = sourceEntry.mimeType
|
val sourceMimeType = sourceEntry.mimeType
|
||||||
|
@ -125,23 +130,29 @@ abstract class ImageProvider {
|
||||||
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
||||||
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
|
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`
|
// 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`
|
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||||
// through a document URI, not a tree URI
|
// through a document URI, not a tree URI
|
||||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||||
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, availableNameWithoutExtension)
|
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, targetNameWithoutExtension)
|
||||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri)
|
||||||
|
|
||||||
if (isVideo(sourceMimeType)) {
|
if (isVideo(sourceMimeType)) {
|
||||||
val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri)
|
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
|
||||||
sourceDocFile.copyTo(destinationDocFile)
|
sourceDocFile.copyTo(destinationDocFile)
|
||||||
} else {
|
} else {
|
||||||
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
||||||
MultiTrackImage(context, sourceUri, pageId)
|
MultiTrackImage(activity, sourceUri, pageId)
|
||||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||||
TiffImage(context, sourceUri, pageId)
|
TiffImage(activity, sourceUri, pageId)
|
||||||
} else {
|
} else {
|
||||||
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
|
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
|
||||||
}
|
}
|
||||||
|
@ -152,7 +163,7 @@ abstract class ImageProvider {
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
.skipMemoryCache(true)
|
.skipMemoryCache(true)
|
||||||
|
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(activity)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(glideOptions)
|
||||||
.load(model)
|
.load(model)
|
||||||
|
@ -160,7 +171,7 @@ abstract class ImageProvider {
|
||||||
try {
|
try {
|
||||||
var bitmap = target.get()
|
var bitmap = target.get()
|
||||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
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")
|
bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId")
|
||||||
|
|
||||||
|
@ -188,40 +199,58 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
Glide.with(context).clear(target)
|
Glide.with(activity).clear(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val fileName = destinationDocFile.name
|
val fileName = destinationDocFile.name
|
||||||
val destinationFullPath = destinationDir + fileName
|
val destinationFullPath = destinationDir + fileName
|
||||||
|
|
||||||
return MediaStoreImageProvider().scanNewPath(context, destinationFullPath, exportMimeType)
|
return MediaStoreImageProvider().scanNewPath(activity, destinationFullPath, exportMimeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
suspend fun captureFrame(
|
suspend fun captureFrame(
|
||||||
context: Context,
|
activity: Activity,
|
||||||
desiredNameWithoutExtension: String,
|
desiredNameWithoutExtension: String,
|
||||||
exifFields: FieldMap,
|
exifFields: FieldMap,
|
||||||
bytes: ByteArray,
|
bytes: ByteArray,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
|
nameConflictStrategy: NameConflictStrategy,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
|
||||||
if (destinationDirDocFile == null) {
|
if (destinationDirDocFile == null) {
|
||||||
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val captureMimeType = MimeTypes.JPEG
|
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`
|
// 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`
|
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||||
// through a document URI, not a tree URI
|
// through a document URI, not a tree URI
|
||||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||||
val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, availableNameWithoutExtension)
|
val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, targetNameWithoutExtension)
|
||||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (exifFields.isEmpty()) {
|
if (exifFields.isEmpty()) {
|
||||||
|
@ -289,21 +318,51 @@ abstract class ImageProvider {
|
||||||
|
|
||||||
val fileName = destinationDocFile.name
|
val fileName = destinationDocFile.name
|
||||||
val destinationFullPath = destinationDir + fileName
|
val destinationFullPath = destinationDir + fileName
|
||||||
val newFields = MediaStoreImageProvider().scanNewPath(context, destinationFullPath, captureMimeType)
|
val newFields = MediaStoreImageProvider().scanNewPath(activity, destinationFullPath, captureMimeType)
|
||||||
callback.onSuccess(newFields)
|
callback.onSuccess(newFields)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback.onFailure(e)
|
callback.onFailure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findAvailableFileNameWithoutExtension(dir: String, desiredNameWithoutExtension: String, extension: String?): String {
|
// returns available name to use, or `null` to skip it
|
||||||
var nameWithoutExtension = desiredNameWithoutExtension
|
suspend fun resolveTargetFileNameWithoutExtension(
|
||||||
var i = 0
|
activity: Activity,
|
||||||
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
dir: String,
|
||||||
i++
|
desiredNameWithoutExtension: String,
|
||||||
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
|
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) {
|
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.
|
// 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.
|
// 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(
|
private fun checkTrailerOffset(
|
||||||
context: Context,
|
context: Context,
|
||||||
path: String,
|
path: String,
|
||||||
|
@ -635,7 +694,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
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>()
|
private val LOG_TAG = LogUtils.createTag<ImageProvider>()
|
||||||
|
|
||||||
val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST
|
import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
import deckers.thibault.aves.model.NameConflictStrategy
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
@ -270,6 +271,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
copy: Boolean,
|
copy: Boolean,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
|
nameConflictStrategy: NameConflictStrategy,
|
||||||
entries: List<AvesEntry>,
|
entries: List<AvesEntry>,
|
||||||
callback: ImageOpCallback,
|
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
|
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
||||||
try {
|
try {
|
||||||
val newFields = moveSingleByTreeDocAndScan(
|
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["newFields"] = newFields
|
||||||
result["success"] = true
|
result["success"] = true
|
||||||
|
@ -324,6 +333,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
sourceUri: Uri,
|
sourceUri: Uri,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
destinationDirDocFile: DocumentFileCompat,
|
destinationDirDocFile: DocumentFileCompat,
|
||||||
|
nameConflictStrategy: NameConflictStrategy,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
copy: Boolean,
|
copy: Boolean,
|
||||||
): FieldMap {
|
): FieldMap {
|
||||||
|
@ -336,17 +346,20 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
|
|
||||||
val sourceFileName = sourceFile.name
|
val sourceFileName = sourceFile.name
|
||||||
val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
|
val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
|
||||||
|
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
|
||||||
if (File(destinationDir, sourceFileName).exists()) {
|
activity = activity,
|
||||||
throw Exception("file with name=$sourceFileName already exists in destination directory")
|
dir = destinationDir,
|
||||||
}
|
desiredNameWithoutExtension = desiredNameWithoutExtension,
|
||||||
|
extension = MimeTypes.extensionFor(mimeType),
|
||||||
|
conflictStrategy = nameConflictStrategy,
|
||||||
|
) ?: return skippedFieldMap
|
||||||
|
|
||||||
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
|
// 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`
|
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||||
// through a document URI, not a tree URI
|
// through a document URI, not a tree URI
|
||||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension)
|
val destinationTreeFile = destinationDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
||||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri)
|
val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri)
|
||||||
|
|
||||||
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
|
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
|
||||||
|
@ -445,9 +458,9 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val contentId = newUri.tryParseId()
|
val contentId = newUri.tryParseId()
|
||||||
if (contentId != null) {
|
if (contentId != null) {
|
||||||
if (isImage(mimeType)) {
|
if (isImage(mimeType)) {
|
||||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
|
contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId)
|
||||||
} else if (isVideo(mimeType)) {
|
} 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 {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
|
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
|
||||||
|
|
||||||
|
|
|
@ -23,21 +23,34 @@ object MimeTypes {
|
||||||
// raw raster
|
// raw raster
|
||||||
private const val ARW = "image/x-sony-arw"
|
private const val ARW = "image/x-sony-arw"
|
||||||
private const val CR2 = "image/x-canon-cr2"
|
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 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 NEF = "image/x-nikon-nef"
|
||||||
private const val NRW = "image/x-nikon-nrw"
|
private const val NRW = "image/x-nikon-nrw"
|
||||||
private const val ORF = "image/x-olympus-orf"
|
private const val ORF = "image/x-olympus-orf"
|
||||||
private const val PEF = "image/x-pentax-pef"
|
private const val PEF = "image/x-pentax-pef"
|
||||||
private const val RAF = "image/x-fuji-raf"
|
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 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 SRW = "image/x-samsung-srw"
|
||||||
|
private const val X3F = "image/x-sigma-x3f"
|
||||||
|
|
||||||
// vector
|
// vector
|
||||||
const val SVG = "image/svg+xml"
|
const val SVG = "image/svg+xml"
|
||||||
|
|
||||||
private const val VIDEO = "video"
|
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 MKV = "video/x-matroska"
|
||||||
|
private const val MOV = "video/quicktime"
|
||||||
private const val MP2T = "video/mp2t"
|
private const val MP2T = "video/mp2t"
|
||||||
private const val MP2TS = "video/mp2ts"
|
private const val MP2TS = "video/mp2ts"
|
||||||
const val MP4 = "video/mp4"
|
const val MP4 = "video/mp4"
|
||||||
|
@ -125,14 +138,45 @@ object MimeTypes {
|
||||||
// extensions
|
// extensions
|
||||||
|
|
||||||
fun extensionFor(mimeType: String): String? = when (mimeType) {
|
fun extensionFor(mimeType: String): String? = when (mimeType) {
|
||||||
|
ARW -> ".arw"
|
||||||
|
AVI, AVI_VND -> ".avi"
|
||||||
BMP -> ".bmp"
|
BMP -> ".bmp"
|
||||||
|
CR2 -> ".cr2"
|
||||||
|
CRW -> ".crw"
|
||||||
|
DCR -> ".dcr"
|
||||||
|
DJVU -> ".djvu"
|
||||||
|
DNG -> ".dng"
|
||||||
|
ERF -> ".erf"
|
||||||
GIF -> ".gif"
|
GIF -> ".gif"
|
||||||
HEIC, HEIF -> ".heif"
|
HEIC, HEIF -> ".heif"
|
||||||
|
ICO -> ".ico"
|
||||||
JPEG -> ".jpg"
|
JPEG -> ".jpg"
|
||||||
|
K25 -> ".k25"
|
||||||
|
KDC -> ".kdc"
|
||||||
|
MKV -> ".mkv"
|
||||||
|
MOV -> ".mov"
|
||||||
|
MP2T, MP2TS -> ".m2ts"
|
||||||
MP4 -> ".mp4"
|
MP4 -> ".mp4"
|
||||||
|
MRW -> ".mrw"
|
||||||
|
NEF -> ".nef"
|
||||||
|
NRW -> ".nrw"
|
||||||
|
OGV -> ".ogv"
|
||||||
|
ORF -> ".orf"
|
||||||
|
PEF -> ".pef"
|
||||||
PNG -> ".png"
|
PNG -> ".png"
|
||||||
|
PSD_VND, PSD_X -> ".psd"
|
||||||
|
RAF -> ".raf"
|
||||||
|
RAW -> ".raw"
|
||||||
|
RW2 -> ".rw2"
|
||||||
|
SR2 -> ".sr2"
|
||||||
|
SRF -> ".srf"
|
||||||
|
SRW -> ".srw"
|
||||||
|
SVG -> ".svg"
|
||||||
TIFF -> ".tiff"
|
TIFF -> ".tiff"
|
||||||
|
WBMP -> ".wbmp"
|
||||||
|
WEBM -> ".webm"
|
||||||
WEBP -> ".webp"
|
WEBP -> ".webp"
|
||||||
|
X3F -> ".x3f"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -193,6 +193,13 @@
|
||||||
"mapStyleStamenWatercolor": "Stamen Watercolor",
|
"mapStyleStamenWatercolor": "Stamen Watercolor",
|
||||||
"@mapStyleStamenWatercolor": {},
|
"@mapStyleStamenWatercolor": {},
|
||||||
|
|
||||||
|
"nameConflictStrategyRename": "Rename",
|
||||||
|
"@nameConflictStrategyRename": {},
|
||||||
|
"nameConflictStrategyReplace": "Replace",
|
||||||
|
"@nameConflictStrategyReplace": {},
|
||||||
|
"nameConflictStrategySkip": "Skip",
|
||||||
|
"@nameConflictStrategySkip": {},
|
||||||
|
|
||||||
"keepScreenOnNever": "Never",
|
"keepScreenOnNever": "Never",
|
||||||
"@keepScreenOnNever": {},
|
"@keepScreenOnNever": {},
|
||||||
"keepScreenOnViewerOnly": "Viewer page only",
|
"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": "Shortcut label",
|
||||||
"@addShortcutDialogLabel": {},
|
"@addShortcutDialogLabel": {},
|
||||||
"addShortcutButtonLabel": "ADD",
|
"addShortcutButtonLabel": "ADD",
|
||||||
|
|
|
@ -97,6 +97,10 @@
|
||||||
"mapStyleStamenToner": "Stamen 토너",
|
"mapStyleStamenToner": "Stamen 토너",
|
||||||
"mapStyleStamenWatercolor": "Stamen 수채화",
|
"mapStyleStamenWatercolor": "Stamen 수채화",
|
||||||
|
|
||||||
|
"nameConflictStrategyRename": "이름 변경",
|
||||||
|
"nameConflictStrategyReplace": "대체",
|
||||||
|
"nameConflictStrategySkip": "건너뛰기",
|
||||||
|
|
||||||
"keepScreenOnNever": "자동 꺼짐",
|
"keepScreenOnNever": "자동 꺼짐",
|
||||||
"keepScreenOnViewerOnly": "뷰어 이용 시 작동",
|
"keepScreenOnViewerOnly": "뷰어 이용 시 작동",
|
||||||
"keepScreenOnAlways": "항상 켜짐",
|
"keepScreenOnAlways": "항상 켜짐",
|
||||||
|
@ -121,6 +125,9 @@
|
||||||
"notEnoughSpaceDialogTitle": "저장공간 부족",
|
"notEnoughSpaceDialogTitle": "저장공간 부족",
|
||||||
"notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.",
|
"notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.",
|
||||||
|
|
||||||
|
"nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.",
|
||||||
|
"nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.",
|
||||||
|
|
||||||
"addShortcutDialogLabel": "바로가기 라벨",
|
"addShortcutDialogLabel": "바로가기 라벨",
|
||||||
"addShortcutButtonLabel": "추가",
|
"addShortcutButtonLabel": "추가",
|
||||||
|
|
||||||
|
|
|
@ -76,6 +76,7 @@ class AvesEntry {
|
||||||
String? uri,
|
String? uri,
|
||||||
String? path,
|
String? path,
|
||||||
int? contentId,
|
int? contentId,
|
||||||
|
String? title,
|
||||||
int? dateModifiedSecs,
|
int? dateModifiedSecs,
|
||||||
List<AvesEntry>? burstEntries,
|
List<AvesEntry>? burstEntries,
|
||||||
}) {
|
}) {
|
||||||
|
@ -90,7 +91,7 @@ class AvesEntry {
|
||||||
height: height,
|
height: height,
|
||||||
sourceRotationDegrees: sourceRotationDegrees,
|
sourceRotationDegrees: sourceRotationDegrees,
|
||||||
sizeBytes: sizeBytes,
|
sizeBytes: sizeBytes,
|
||||||
sourceTitle: sourceTitle,
|
sourceTitle: title ?? sourceTitle,
|
||||||
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
|
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
|
||||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||||
durationMillis: durationMillis,
|
durationMillis: durationMillis,
|
||||||
|
|
|
@ -215,6 +215,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
uri: newFields['uri'] as String?,
|
uri: newFields['uri'] as String?,
|
||||||
path: newFields['path'] as String?,
|
path: newFields['path'] as String?,
|
||||||
contentId: newFields['contentId'] as int?,
|
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?,
|
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ class MimeTypes {
|
||||||
static const mov = 'video/quicktime';
|
static const mov = 'video/quicktime';
|
||||||
static const mp2t = 'video/mp2t'; // .m2ts
|
static const mp2t = 'video/mp2t'; // .m2ts
|
||||||
static const mp4 = 'video/mp4';
|
static const mp4 = 'video/mp4';
|
||||||
static const ogg = 'video/ogg';
|
static const ogv = 'video/ogg';
|
||||||
static const webm = 'video/webm';
|
static const webm = 'video/webm';
|
||||||
|
|
||||||
static const json = 'application/json';
|
static const json = 'application/json';
|
||||||
|
@ -67,7 +67,7 @@ class MimeTypes {
|
||||||
|
|
||||||
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
|
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};
|
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};
|
||||||
|
|
||||||
|
|
20
lib/services/media/enums.dart
Normal file
20
lib/services/media/enums.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/output_buffer.dart';
|
||||||
import 'package:aves/services/common/service_policy.dart';
|
import 'package:aves/services/common/service_policy.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
|
import 'package:aves/services/media/enums.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:streams_channel/streams_channel.dart';
|
import 'package:streams_channel/streams_channel.dart';
|
||||||
|
@ -73,12 +74,14 @@ abstract class MediaFileService {
|
||||||
Iterable<AvesEntry> entries, {
|
Iterable<AvesEntry> entries, {
|
||||||
required bool copy,
|
required bool copy,
|
||||||
required String destinationAlbum,
|
required String destinationAlbum,
|
||||||
|
required NameConflictStrategy nameConflictStrategy,
|
||||||
});
|
});
|
||||||
|
|
||||||
Stream<ExportOpEvent> export(
|
Stream<ExportOpEvent> export(
|
||||||
Iterable<AvesEntry> entries, {
|
Iterable<AvesEntry> entries, {
|
||||||
required String mimeType,
|
required String mimeType,
|
||||||
required String destinationAlbum,
|
required String destinationAlbum,
|
||||||
|
required NameConflictStrategy nameConflictStrategy,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<Map<String, dynamic>> captureFrame(
|
Future<Map<String, dynamic>> captureFrame(
|
||||||
|
@ -87,6 +90,7 @@ abstract class MediaFileService {
|
||||||
required Map<String, dynamic> exif,
|
required Map<String, dynamic> exif,
|
||||||
required Uint8List bytes,
|
required Uint8List bytes,
|
||||||
required String destinationAlbum,
|
required String destinationAlbum,
|
||||||
|
required NameConflictStrategy nameConflictStrategy,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
|
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
|
||||||
|
@ -305,6 +309,7 @@ class PlatformMediaFileService implements MediaFileService {
|
||||||
Iterable<AvesEntry> entries, {
|
Iterable<AvesEntry> entries, {
|
||||||
required bool copy,
|
required bool copy,
|
||||||
required String destinationAlbum,
|
required String destinationAlbum,
|
||||||
|
required NameConflictStrategy nameConflictStrategy,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
|
@ -312,6 +317,7 @@ class PlatformMediaFileService implements MediaFileService {
|
||||||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||||
'copy': copy,
|
'copy': copy,
|
||||||
'destinationPath': destinationAlbum,
|
'destinationPath': destinationAlbum,
|
||||||
|
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
|
||||||
}).map((event) => MoveOpEvent.fromMap(event));
|
}).map((event) => MoveOpEvent.fromMap(event));
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
reportService.recordError(e, stack);
|
reportService.recordError(e, stack);
|
||||||
|
@ -324,6 +330,7 @@ class PlatformMediaFileService implements MediaFileService {
|
||||||
Iterable<AvesEntry> entries, {
|
Iterable<AvesEntry> entries, {
|
||||||
required String mimeType,
|
required String mimeType,
|
||||||
required String destinationAlbum,
|
required String destinationAlbum,
|
||||||
|
required NameConflictStrategy nameConflictStrategy,
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
|
@ -331,6 +338,7 @@ class PlatformMediaFileService implements MediaFileService {
|
||||||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||||
'mimeType': mimeType,
|
'mimeType': mimeType,
|
||||||
'destinationPath': destinationAlbum,
|
'destinationPath': destinationAlbum,
|
||||||
|
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
|
||||||
}).map((event) => ExportOpEvent.fromMap(event));
|
}).map((event) => ExportOpEvent.fromMap(event));
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
reportService.recordError(e, stack);
|
reportService.recordError(e, stack);
|
||||||
|
@ -345,6 +353,7 @@ class PlatformMediaFileService implements MediaFileService {
|
||||||
required Map<String, dynamic> exif,
|
required Map<String, dynamic> exif,
|
||||||
required Uint8List bytes,
|
required Uint8List bytes,
|
||||||
required String destinationAlbum,
|
required String destinationAlbum,
|
||||||
|
required NameConflictStrategy nameConflictStrategy,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('captureFrame', <String, dynamic>{
|
final result = await platform.invokeMethod('captureFrame', <String, dynamic>{
|
||||||
|
@ -353,6 +362,7 @@ class PlatformMediaFileService implements MediaFileService {
|
||||||
'exif': exif,
|
'exif': exif,
|
||||||
'bytes': bytes,
|
'bytes': bytes,
|
||||||
'destinationPath': destinationAlbum,
|
'destinationPath': destinationAlbum,
|
||||||
|
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
|
||||||
});
|
});
|
||||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||||
import 'package:aves/model/actions/move_type.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/android_app_service.dart';
|
||||||
import 'package:aves/services/common/image_op_events.dart';
|
import 'package:aves/services/common/image_op_events.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
|
import 'package:aves/services/media/enums.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.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/action_mixins/size_aware.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.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/filter_grids/album_pick.dart';
|
||||||
import 'package:aves/widgets/map/map_page.dart';
|
import 'package:aves/widgets/map/map_page.dart';
|
||||||
import 'package:aves/widgets/stats/stats_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 {
|
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
|
||||||
|
final l10n = context.l10n;
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final selection = context.read<Selection<AvesEntry>>();
|
final selection = context.read<Selection<AvesEntry>>();
|
||||||
final selectedItems = _getExpandedSelectedItems(selection);
|
final selectedItems = _getExpandedSelectedItems(selection);
|
||||||
|
@ -119,13 +123,44 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
final todoCount = todoEntries.length;
|
final todoCount = todoEntries.length;
|
||||||
assert(todoCount > 0);
|
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();
|
source.pauseMonitoring();
|
||||||
showOpReport<MoveOpEvent>(
|
showOpReport<MoveOpEvent>(
|
||||||
context: context,
|
context: context,
|
||||||
opStream: mediaFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum),
|
opStream: mediaFileService.move(
|
||||||
|
todoEntries,
|
||||||
|
copy: copy,
|
||||||
|
destinationAlbum: destinationAlbum,
|
||||||
|
nameConflictStrategy: nameConflictStrategy,
|
||||||
|
),
|
||||||
itemCount: todoCount,
|
itemCount: todoCount,
|
||||||
onDone: (processed) async {
|
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(
|
await source.updateAfterMove(
|
||||||
todoEntries: todoEntries,
|
todoEntries: todoEntries,
|
||||||
copy: copy,
|
copy: copy,
|
||||||
|
@ -140,50 +175,51 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
await storageService.deleteEmptyDirectories(selectionDirs);
|
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||||
}
|
}
|
||||||
|
|
||||||
final l10n = context.l10n;
|
final successCount = successOps.length;
|
||||||
final movedCount = movedOps.length;
|
if (successCount < todoCount) {
|
||||||
if (movedCount < todoCount) {
|
final count = todoCount - successCount;
|
||||||
final count = todoCount - movedCount;
|
|
||||||
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
|
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
|
||||||
} else {
|
} else {
|
||||||
final count = movedCount;
|
final count = movedOps.length;
|
||||||
showFeedback(
|
showFeedback(
|
||||||
context,
|
context,
|
||||||
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
|
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
|
||||||
SnackBarAction(
|
count > 0
|
||||||
label: context.l10n.showButtonLabel,
|
? SnackBarAction(
|
||||||
onPressed: () async {
|
label: l10n.showButtonLabel,
|
||||||
final highlightInfo = context.read<HighlightInfo>();
|
onPressed: () async {
|
||||||
final collection = context.read<CollectionLens>();
|
final highlightInfo = context.read<HighlightInfo>();
|
||||||
var targetCollection = collection;
|
final collection = context.read<CollectionLens>();
|
||||||
if (collection.filters.any((f) => f is AlbumFilter)) {
|
var targetCollection = collection;
|
||||||
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
|
if (collection.filters.any((f) => f is AlbumFilter)) {
|
||||||
// we could simply add the filter to the current collection
|
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
|
||||||
// but navigating makes the change less jarring
|
// we could simply add the filter to the current collection
|
||||||
targetCollection = CollectionLens(
|
// but navigating makes the change less jarring
|
||||||
source: collection.source,
|
targetCollection = CollectionLens(
|
||||||
filters: collection.filters,
|
source: collection.source,
|
||||||
)..addFilter(filter);
|
filters: collection.filters,
|
||||||
unawaited(Navigator.pushReplacement(
|
)..addFilter(filter);
|
||||||
context,
|
unawaited(Navigator.pushReplacement(
|
||||||
MaterialPageRoute(
|
context,
|
||||||
settings: const RouteSettings(name: CollectionPage.routeName),
|
MaterialPageRoute(
|
||||||
builder: (context) => CollectionPage(
|
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||||
collection: targetCollection,
|
builder: (context) => CollectionPage(
|
||||||
),
|
collection: targetCollection,
|
||||||
),
|
),
|
||||||
));
|
),
|
||||||
final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget;
|
));
|
||||||
await Future.delayed(delayDuration);
|
final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget;
|
||||||
}
|
await Future.delayed(delayDuration);
|
||||||
await Future.delayed(Durations.highlightScrollInitDelay);
|
}
|
||||||
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
|
await Future.delayed(Durations.highlightScrollInitDelay);
|
||||||
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
|
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
|
||||||
if (targetEntry != null) {
|
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
|
||||||
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
|
if (targetEntry != null) {
|
||||||
}
|
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
|
||||||
},
|
}
|
||||||
),
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -10,14 +10,16 @@ class AvesSelectionDialog<T> extends StatefulWidget {
|
||||||
final T initialValue;
|
final T initialValue;
|
||||||
final Map<T, String> options;
|
final Map<T, String> options;
|
||||||
final TextBuilder<T>? optionSubtitleBuilder;
|
final TextBuilder<T>? optionSubtitleBuilder;
|
||||||
final String title;
|
final String? title, message, confirmationButtonLabel;
|
||||||
|
|
||||||
const AvesSelectionDialog({
|
const AvesSelectionDialog({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.initialValue,
|
required this.initialValue,
|
||||||
required this.options,
|
required this.options,
|
||||||
this.optionSubtitleBuilder,
|
this.optionSubtitleBuilder,
|
||||||
required this.title,
|
this.title,
|
||||||
|
this.message,
|
||||||
|
this.confirmationButtonLabel,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -35,27 +37,48 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog<T>> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final message = widget.message;
|
||||||
|
final confirmationButtonLabel = widget.confirmationButtonLabel;
|
||||||
|
final needConfirmation = confirmationButtonLabel != null;
|
||||||
return AvesDialog(
|
return AvesDialog(
|
||||||
context: context,
|
context: context,
|
||||||
title: widget.title,
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
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);
|
final subtitle = widget.optionSubtitleBuilder?.call(value);
|
||||||
return ReselectableRadioListTile<T>(
|
return ReselectableRadioListTile<T>(
|
||||||
// key is expected by test driver
|
// key is expected by test driver
|
||||||
key: Key(value.toString()),
|
key: Key(value.toString()),
|
||||||
value: value,
|
value: value,
|
||||||
groupValue: _selectedValue,
|
groupValue: _selectedValue,
|
||||||
onChanged: (v) => Navigator.pop(context, v),
|
onChanged: (v) {
|
||||||
|
if (needConfirmation) {
|
||||||
|
setState(() => _selectedValue = v!);
|
||||||
|
} else {
|
||||||
|
Navigator.pop(context, v);
|
||||||
|
}
|
||||||
|
},
|
||||||
reselectable: true,
|
reselectable: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/services/common/image_op_events.dart';
|
import 'package:aves/services/common/image_op_events.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
|
import 'package:aves/services/media/enums.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
@ -226,7 +227,13 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
|
||||||
source.pauseMonitoring();
|
source.pauseMonitoring();
|
||||||
showOpReport<MoveOpEvent>(
|
showOpReport<MoveOpEvent>(
|
||||||
context: context,
|
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,
|
itemCount: todoCount,
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
final movedOps = processed.where((e) => e.success).toSet();
|
final movedOps = processed.where((e) => e.success).toSet();
|
||||||
|
|
|
@ -13,6 +13,7 @@ import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/common/image_op_events.dart';
|
import 'package:aves/services/common/image_op_events.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
|
import 'package:aves/services/media/enums.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.dart';
|
import 'package:aves/widgets/collection/collection_page.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
|
@ -208,6 +209,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
selection,
|
selection,
|
||||||
mimeType: MimeTypes.jpeg,
|
mimeType: MimeTypes.jpeg,
|
||||||
destinationAlbum: destinationAlbum,
|
destinationAlbum: destinationAlbum,
|
||||||
|
nameConflictStrategy: NameConflictStrategy.rename,
|
||||||
),
|
),
|
||||||
itemCount: selectionCount,
|
itemCount: selectionCount,
|
||||||
onDone: (processed) {
|
onDone: (processed) {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
|
import 'package:aves/services/media/enums.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.dart';
|
import 'package:aves/widgets/collection/collection_page.dart';
|
||||||
|
@ -91,6 +92,7 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
exif: exif,
|
exif: exif,
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
destinationAlbum: destinationAlbum,
|
destinationAlbum: destinationAlbum,
|
||||||
|
nameConflictStrategy: NameConflictStrategy.rename,
|
||||||
);
|
);
|
||||||
final success = newFields.isNotEmpty;
|
final success = newFields.isNotEmpty;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue