#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.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)
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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.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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>()
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "추가",
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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?,
|
||||
));
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
|
||||
|
|
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/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) {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in a new issue