#107 renaming: check and delete Media Store obsolete entry

This commit is contained in:
Thibault Deckers 2021-10-18 17:24:17 +09:00
parent d87cf4395f
commit a3bd158ca6
10 changed files with 213 additions and 104 deletions

View file

@ -35,7 +35,6 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) } "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) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented() 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<FieldMap>("entry")
val newName = call.argument<String>("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) { private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
Glide.get(activity).clearDiskCache() Glide.get(activity).clearDiskCache()
result.success(null) result.success(null)

View file

@ -709,7 +709,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
val fields = hashMapOf<String, Any?>( val fields: FieldMap = hashMapOf(
"projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT, "projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
) )
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {

View file

@ -45,6 +45,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() } "delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
"export" -> GlobalScope.launch(Dispatchers.IO) { export() } "export" -> GlobalScope.launch(Dispatchers.IO) { export() }
"move" -> GlobalScope.launch(Dispatchers.IO) { move() } "move" -> GlobalScope.launch(Dispatchers.IO) { move() }
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename() }
else -> endOfStream() 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 uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
if (uri != null) { if (uri != null) {
val result = hashMapOf<String, Any?>( val result: FieldMap = hashMapOf(
"uri" to uri.toString(), "uri" to uri.toString(),
) )
try { try {
@ -178,6 +179,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
endOfStream() 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 { companion object {
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>() private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_op_stream" const val CHANNEL = "deckers.thibault/aves/media_op_stream"

View file

@ -46,7 +46,7 @@ object MultiPage {
val format = extractor.getTrackFormat(i) val format = extractor.getTrackFormat(i)
format.getString(MediaFormat.KEY_MIME)?.let { mime -> format.getString(MediaFormat.KEY_MIME)?.let { mime ->
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
val track = hashMapOf<String, Any?>( val track: FieldMap = hashMapOf(
KEY_PAGE to i, KEY_PAGE to i,
KEY_MIME_TYPE to trackMime, KEY_MIME_TYPE to trackMime,
) )
@ -106,7 +106,7 @@ object MultiPage {
val format = extractor.getTrackFormat(i) val format = extractor.getTrackFormat(i)
format.getString(MediaFormat.KEY_MIME)?.let { mime -> format.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) { if (MimeTypes.isVideo(mime)) {
val track = hashMapOf<String, Any?>( val track: FieldMap = hashMapOf(
KEY_PAGE to trackCount++, KEY_PAGE to trackCount++,
KEY_MIME_TYPE to MimeTypes.MP4, KEY_MIME_TYPE to MimeTypes.MP4,
KEY_IS_DEFAULT to false, KEY_IS_DEFAULT to false,

View file

@ -9,6 +9,7 @@ import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.model.FieldMap
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
@ -47,16 +48,16 @@ internal class ContentImageProvider : ImageProvider() {
return return
} }
val map = hashMapOf<String, Any?>( val fields: FieldMap = hashMapOf(
"uri" to uri.toString(), "uri" to uri.toString(),
"sourceMimeType" to mimeType, "sourceMimeType" to mimeType,
) )
try { try {
val cursor = context.contentResolver.query(uri, projection, null, null, null) val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = 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) map["sizeBytes"] = cursor.getLong(it) } cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
cursor.getColumnIndex(PATH).let { if (it != -1) map["path"] = cursor.getString(it) } cursor.getColumnIndex(PATH).let { if (it != -1) fields["path"] = cursor.getString(it) }
cursor.close() cursor.close()
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -64,7 +65,7 @@ internal class ContentImageProvider : ImageProvider() {
return return
} }
val entry = SourceEntry(map).fillPreCatalogMetadata(context) val entry = SourceEntry(fields).fillPreCatalogMetadata(context)
if (entry.isSized || entry.isSvg || entry.isVideo) { if (entry.isSized || entry.isSvg || entry.isVideo) {
callback.onSuccess(entry.toMap()) callback.onSuccess(entry.toMap())
} else { } else {

View file

@ -32,7 +32,6 @@ import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import kotlin.collections.HashMap import kotlin.collections.HashMap
@ -50,6 +49,10 @@ abstract class ImageProvider {
callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider"))
} }
open suspend fun renameMultiple(activity: Activity, newFileName: String, entries: List<AvesEntry>, 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<String, Any?>, callback: ImageOpCallback) { open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider") throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
} }
@ -81,7 +84,7 @@ abstract class ImageProvider {
val sourcePath = entry.path val sourcePath = entry.path
val pageId = entry.pageId val pageId = entry.pageId
val result = hashMapOf<String, Any?>( val result: FieldMap = hashMapOf(
"uri" to sourceUri.toString(), "uri" to sourceUri.toString(),
"pageId" to pageId, "pageId" to pageId,
"success" to false, "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( private fun editExif(
context: Context, context: Context,
path: String, path: String,

View file

@ -223,6 +223,23 @@ class MediaStoreImageProvider : ImageProvider() {
return found 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 private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
// `uri` is a media URI, not a document URI // `uri` is a media URI, not a document URI
@ -286,7 +303,7 @@ class MediaStoreImageProvider : ImageProvider() {
val sourcePath = entry.path val sourcePath = entry.path
val mimeType = entry.mimeType val mimeType = entry.mimeType
val result = hashMapOf<String, Any?>( val result: FieldMap = hashMapOf(
"uri" to sourceUri.toString(), "uri" to sourceUri.toString(),
"success" to false, "success" to false,
) )
@ -391,6 +408,90 @@ class MediaStoreImageProvider : ImageProvider() {
} }
} }
override suspend fun renameMultiple(
activity: Activity,
newFileName: String,
entries: List<AvesEntry>,
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<String, Any?>, callback: ImageOpCallback) { override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
val projection = arrayOf( val projection = arrayOf(

View file

@ -162,13 +162,34 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async { Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
if (newName == entry.filenameWithoutExtension) return true; if (newName == entry.filenameWithoutExtension) return true;
final newFields = await mediaFileService.rename(entry, '$newName${entry.extension}');
if (newFields.isEmpty) return false;
pauseMonitoring();
final completer = Completer<bool>();
final processed = <MoveOpEvent>{};
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); await _moveEntry(entry, newFields, persist: persist);
entry.metadataChangeNotifier.notifyListeners(); entry.metadataChangeNotifier.notifyListeners();
eventBus.fire(EntryMovedEvent({entry})); eventBus.fire(EntryMovedEvent({entry}));
return true; completer.complete(true);
},
);
final success = await completer.future;
resumeMonitoring();
return success;
} }
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async { Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {

View file

@ -84,6 +84,11 @@ abstract class MediaFileService {
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
}); });
Stream<MoveOpEvent> rename(
Iterable<AvesEntry> entries, {
required String newName,
});
Future<Map<String, dynamic>> captureFrame( Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, { AvesEntry entry, {
required String desiredName, required String desiredName,
@ -92,8 +97,6 @@ abstract class MediaFileService {
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
}); });
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
} }
class PlatformMediaFileService implements MediaFileService { class PlatformMediaFileService implements MediaFileService {
@ -346,6 +349,23 @@ class PlatformMediaFileService implements MediaFileService {
} }
} }
@override
Stream<MoveOpEvent> rename(
Iterable<AvesEntry> entries, {
required String newName,
}) {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'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 @override
Future<Map<String, dynamic>> captureFrame( Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, { AvesEntry entry, {
@ -370,19 +390,4 @@ class PlatformMediaFileService implements MediaFileService {
} }
return {}; return {};
} }
@override
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName) async {
try {
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'newName': newName,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
} }

View file

@ -1,21 +1,29 @@
import 'package:aves/model/entry.dart'; 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:aves/services/media/media_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'media_store_service.dart'; import 'media_store_service.dart';
class FakeMediaFileService extends Fake implements MediaFileService { class FakeMediaFileService extends Fake implements MediaFileService {
@override @override
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName) { Stream<MoveOpEvent> rename(
Iterable<AvesEntry> entries, {
required String newName,
}) {
final contentId = FakeMediaStoreService.nextContentId; final contentId = FakeMediaStoreService.nextContentId;
return SynchronousFuture({ final entry = entries.first;
return Stream.value(MoveOpEvent(
success: true,
uri: entry.uri,
newFields: {
'uri': 'content://media/external/images/media/$contentId', 'uri': 'content://media/external/images/media/$contentId',
'contentId': contentId, 'contentId': contentId,
'path': '${entry.directory}/$newName', 'path': '${entry.directory}/$newName',
'displayName': newName, 'displayName': newName,
'title': newName.substring(0, newName.length - entry.extension!.length), 'title': newName.substring(0, newName.length - entry.extension!.length),
'dateModifiedSecs': FakeMediaStoreService.dateSecs, 'dateModifiedSecs': FakeMediaStoreService.dateSecs,
}); },
));
} }
} }