diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt index b69c715de..b3fe9d930 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt @@ -35,7 +35,6 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) } "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) } - "rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } else -> result.notImplemented() } @@ -164,34 +163,6 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { }) } - private suspend fun rename(call: MethodCall, result: MethodChannel.Result) { - val entryMap = call.argument("entry") - val newName = call.argument("newName") - if (entryMap == null || newName == null) { - result.error("rename-args", "failed because of missing arguments", null) - return - } - - val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } - val path = entryMap["path"] as String? - val mimeType = entryMap["mimeType"] as String? - if (uri == null || path == null || mimeType == null) { - result.error("rename-args", "failed because entry fields are missing", null) - return - } - - val provider = getProvider(uri) - if (provider == null) { - result.error("rename-provider", "failed to find provider for uri=$uri", null) - return - } - - provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback { - override fun onSuccess(fields: FieldMap) = result.success(fields) - override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message) - }) - } - private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { Glide.get(activity).clearDiskCache() result.success(null) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 6586b9745..3fbbb707f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -709,7 +709,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - val fields = hashMapOf( + val fields: FieldMap = hashMapOf( "projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT, ) for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index a7cc8fe53..a5358a839 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -45,6 +45,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments "delete" -> GlobalScope.launch(Dispatchers.IO) { delete() } "export" -> GlobalScope.launch(Dispatchers.IO) { export() } "move" -> GlobalScope.launch(Dispatchers.IO) { move() } + "rename" -> GlobalScope.launch(Dispatchers.IO) { rename() } else -> endOfStream() } } @@ -100,7 +101,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val path = entryMap["path"] as String? if (uri != null) { - val result = hashMapOf( + val result: FieldMap = hashMapOf( "uri" to uri.toString(), ) try { @@ -178,6 +179,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments endOfStream() } + private suspend fun rename() { + if (arguments !is Map<*, *> || entryMapList.isEmpty()) { + endOfStream() + return + } + + val newName = arguments["newName"] as String? + if (newName == null) { + error("rename-args", "failed because of missing arguments", null) + return + } + + // assume same provider for all entries + val firstEntry = entryMapList.first() + val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) } + if (provider == null) { + error("rename-provider", "failed to find provider for entry=$firstEntry", null) + return + } + + val entries = entryMapList.map(::AvesEntry) + provider.renameMultiple(activity, newName, entries, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = success(fields) + override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message) + }) + endOfStream() + } + companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/media_op_stream" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index 96bcbbbaa..588927a9a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -46,7 +46,7 @@ object MultiPage { val format = extractor.getTrackFormat(i) format.getString(MediaFormat.KEY_MIME)?.let { mime -> val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime - val track = hashMapOf( + val track: FieldMap = hashMapOf( KEY_PAGE to i, KEY_MIME_TYPE to trackMime, ) @@ -106,7 +106,7 @@ object MultiPage { val format = extractor.getTrackFormat(i) format.getString(MediaFormat.KEY_MIME)?.let { mime -> if (MimeTypes.isVideo(mime)) { - val track = hashMapOf( + val track: FieldMap = hashMapOf( KEY_PAGE to trackCount++, KEY_MIME_TYPE to MimeTypes.MP4, KEY_IS_DEFAULT to false, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt index 1f1829db5..8c935b741 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt @@ -9,6 +9,7 @@ import com.drew.imaging.ImageMetadataReader import com.drew.metadata.file.FileTypeDirectory import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes @@ -47,16 +48,16 @@ internal class ContentImageProvider : ImageProvider() { return } - val map = hashMapOf( + val fields: FieldMap = hashMapOf( "uri" to uri.toString(), "sourceMimeType" to mimeType, ) try { val cursor = context.contentResolver.query(uri, projection, null, null, null) if (cursor != null && cursor.moveToFirst()) { - cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) } - cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) } - cursor.getColumnIndex(PATH).let { if (it != -1) map["path"] = cursor.getString(it) } + cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) } + cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) } + cursor.getColumnIndex(PATH).let { if (it != -1) fields["path"] = cursor.getString(it) } cursor.close() } } catch (e: Exception) { @@ -64,7 +65,7 @@ internal class ContentImageProvider : ImageProvider() { return } - val entry = SourceEntry(map).fillPreCatalogMetadata(context) + val entry = SourceEntry(fields).fillPreCatalogMetadata(context) if (entry.isSized || entry.isSvg || entry.isVideo) { callback.onSuccess(entry.toMap()) } else { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 15bbc4d8c..200e3f117 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -32,7 +32,6 @@ import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import java.io.ByteArrayInputStream import java.io.File -import java.io.FileNotFoundException import java.io.IOException import java.util.* import kotlin.collections.HashMap @@ -50,6 +49,10 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) } + open suspend fun renameMultiple(activity: Activity, newFileName: String, entries: List, callback: ImageOpCallback) { + callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider")) + } + open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider") } @@ -81,7 +84,7 @@ abstract class ImageProvider { val sourcePath = entry.path val pageId = entry.pageId - val result = hashMapOf( + val result: FieldMap = hashMapOf( "uri" to sourceUri.toString(), "pageId" to pageId, "success" to false, @@ -371,36 +374,6 @@ abstract class ImageProvider { } } - suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { - val oldFile = File(oldPath) - val newFile = File(oldFile.parent, newFilename) - if (oldFile == newFile) { - Log.w(LOG_TAG, "new name and old name are the same, path=$oldPath") - callback.onSuccess(HashMap()) - return - } - - val df = getDocumentFile(context, oldPath, oldMediaUri) - try { - @Suppress("BlockingMethodInNonBlockingContext") - val renamed = df != null && df.renameTo(newFilename) - if (!renamed) { - callback.onFailure(Exception("failed to rename entry at path=$oldPath")) - return - } - } catch (e: FileNotFoundException) { - callback.onFailure(e) - return - } - - scanObsoletePath(context, oldPath, mimeType) - try { - callback.onSuccess(MediaStoreImageProvider().scanNewPath(context, newFile.path, mimeType)) - } catch (e: Exception) { - callback.onFailure(e) - } - } - private fun editExif( context: Context, path: String, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 2f30626a7..deb657bb1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -223,6 +223,23 @@ class MediaStoreImageProvider : ImageProvider() { return found } + private fun hasEntry(context: Context, contentUri: Uri): Boolean { + var found = false + val projection = arrayOf(MediaStore.MediaColumns._ID) + try { + val cursor = context.contentResolver.query(contentUri, projection, null, null, null) + if (cursor != null) { + while (cursor.moveToNext()) { + found = true + } + cursor.close() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to get entry at contentUri=$contentUri", e) + } + return found + } + private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType // `uri` is a media URI, not a document URI @@ -286,7 +303,7 @@ class MediaStoreImageProvider : ImageProvider() { val sourcePath = entry.path val mimeType = entry.mimeType - val result = hashMapOf( + val result: FieldMap = hashMapOf( "uri" to sourceUri.toString(), "success" to false, ) @@ -391,6 +408,90 @@ class MediaStoreImageProvider : ImageProvider() { } } + override suspend fun renameMultiple( + activity: Activity, + newFileName: String, + entries: List, + callback: ImageOpCallback, + ) { + for (entry in entries) { + val sourceUri = entry.uri + val sourcePath = entry.path + val mimeType = entry.mimeType + + val result: FieldMap = hashMapOf( + "uri" to sourceUri.toString(), + "success" to false, + ) + + if (sourcePath != null) { + try { + val newFields = renameSingle( + activity = activity, + oldPath = sourcePath, + oldMediaUri = sourceUri, + newFileName = newFileName, + mimeType = mimeType, + ) + result["newFields"] = newFields + result["success"] = true + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to rename to newFileName=$newFileName entry with sourcePath=$sourcePath", e) + } + } + callback.onSuccess(result) + } + } + + private suspend fun renameSingle( + activity: Activity, + oldPath: String, + oldMediaUri: Uri, + newFileName: String, + mimeType: String, + ): FieldMap { + val oldFile = File(oldPath) + val newFile = File(oldFile.parent, newFileName) + if (oldFile == newFile) { + // nothing to do + return skippedFieldMap + } + + @Suppress("BlockingMethodInNonBlockingContext") + val renamed = getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFileName) ?: false + if (!renamed) { + throw Exception("failed to rename entry at path=$oldPath") + } + + // renaming may be successful and the file at the old path no longer exists + // but, in some situations, scanning the old path does not clear the Media Store entry + // e.g. for media owned by another package in the Download folder on API 29 + + // for higher chance of accurate obsolete item check, keep this order: + // 1) scan obsolete item, + // 2) scan current item, + // 3) check obsolete item in Media Store + + scanObsoletePath(activity, oldPath, mimeType) + val newFields = scanNewPath(activity, newFile.path, mimeType) + + var deletedSource = !hasEntry(activity, oldMediaUri) + if (!deletedSource) { + Log.w(LOG_TAG, "renaming item at uri=$oldMediaUri to newFileName=$newFileName did not clear the MediaStore entry for obsolete path=$oldPath") + + // delete obsolete entry + try { + delete(activity, oldMediaUri, oldPath) + deletedSource = true + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to delete entry with path=$oldPath", e) + } + } + newFields["deletedSource"] = deletedSource + + return newFields + } + override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> val projection = arrayOf( diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 3475dab32..5e026c490 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -162,13 +162,34 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future renameEntry(AvesEntry entry, String newName, {required bool persist}) async { if (newName == entry.filenameWithoutExtension) return true; - final newFields = await mediaFileService.rename(entry, '$newName${entry.extension}'); - if (newFields.isEmpty) return false; - await _moveEntry(entry, newFields, persist: persist); - entry.metadataChangeNotifier.notifyListeners(); - eventBus.fire(EntryMovedEvent({entry})); - return true; + pauseMonitoring(); + final completer = Completer(); + final processed = {}; + mediaFileService.rename({entry}, newName: '$newName${entry.extension}').listen( + processed.add, + onError: (error) => reportService.recordError('renameEntry failed with error=$error', null), + onDone: () async { + final successOps = processed.where((e) => e.success).toSet(); + if (successOps.isEmpty) { + completer.complete(false); + return; + } + final newFields = successOps.first.newFields; + if (newFields.isEmpty) { + completer.complete(false); + return; + } + await _moveEntry(entry, newFields, persist: persist); + entry.metadataChangeNotifier.notifyListeners(); + eventBus.fire(EntryMovedEvent({entry})); + completer.complete(true); + }, + ); + + final success = await completer.future; + resumeMonitoring(); + return success; } Future renameAlbum(String sourceAlbum, String destinationAlbum, Set todoEntries, Set movedOps) async { diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index fbf6ddc1b..c2dc44a4d 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -84,6 +84,11 @@ abstract class MediaFileService { required NameConflictStrategy nameConflictStrategy, }); + Stream rename( + Iterable entries, { + required String newName, + }); + Future> captureFrame( AvesEntry entry, { required String desiredName, @@ -92,8 +97,6 @@ abstract class MediaFileService { required String destinationAlbum, required NameConflictStrategy nameConflictStrategy, }); - - Future> rename(AvesEntry entry, String newName); } class PlatformMediaFileService implements MediaFileService { @@ -346,6 +349,23 @@ class PlatformMediaFileService implements MediaFileService { } } + @override + Stream rename( + Iterable entries, { + required String newName, + }) { + try { + return _opStreamChannel.receiveBroadcastStream({ + 'op': 'rename', + 'entries': entries.map(_toPlatformEntryMap).toList(), + 'newName': newName, + }).map((event) => MoveOpEvent.fromMap(event)); + } on PlatformException catch (e, stack) { + reportService.recordError(e, stack); + return Stream.error(e); + } + } + @override Future> captureFrame( AvesEntry entry, { @@ -370,19 +390,4 @@ class PlatformMediaFileService implements MediaFileService { } return {}; } - - @override - Future> rename(AvesEntry entry, String newName) async { - try { - // returns map with: 'contentId' 'path' 'title' 'uri' (all optional) - final result = await platform.invokeMethod('rename', { - 'entry': _toPlatformEntryMap(entry), - 'newName': newName, - }); - if (result != null) return (result as Map).cast(); - } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); - } - return {}; - } } diff --git a/test/fake/media_file_service.dart b/test/fake/media_file_service.dart index 5d2933b66..8af17306d 100644 --- a/test/fake/media_file_service.dart +++ b/test/fake/media_file_service.dart @@ -1,21 +1,29 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/media/media_file_service.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'media_store_service.dart'; class FakeMediaFileService extends Fake implements MediaFileService { @override - Future> rename(AvesEntry entry, String newName) { + Stream rename( + Iterable entries, { + required String newName, + }) { final contentId = FakeMediaStoreService.nextContentId; - return SynchronousFuture({ - 'uri': 'content://media/external/images/media/$contentId', - 'contentId': contentId, - 'path': '${entry.directory}/$newName', - 'displayName': newName, - 'title': newName.substring(0, newName.length - entry.extension!.length), - 'dateModifiedSecs': FakeMediaStoreService.dateSecs, - }); + final entry = entries.first; + return Stream.value(MoveOpEvent( + success: true, + uri: entry.uri, + newFields: { + 'uri': 'content://media/external/images/media/$contentId', + 'contentId': contentId, + 'path': '${entry.directory}/$newName', + 'displayName': newName, + 'title': newName.substring(0, newName.length - entry.extension!.length), + 'dateModifiedSecs': FakeMediaStoreService.dateSecs, + }, + )); } }