#107 renaming: check and delete Media Store obsolete entry
This commit is contained in:
parent
d87cf4395f
commit
a3bd158ca6
10 changed files with 213 additions and 104 deletions
|
@ -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<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) {
|
||||
Glide.get(activity).clearDiskCache()
|
||||
result.success(null)
|
||||
|
|
|
@ -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<String, Any?>(
|
||||
val fields: FieldMap = hashMapOf(
|
||||
"projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
|
||||
)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
|
|
|
@ -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<String, Any?>(
|
||||
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<ImageOpStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/media_op_stream"
|
||||
|
|
|
@ -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<String, Any?>(
|
||||
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<String, Any?>(
|
||||
val track: FieldMap = hashMapOf(
|
||||
KEY_PAGE to trackCount++,
|
||||
KEY_MIME_TYPE to MimeTypes.MP4,
|
||||
KEY_IS_DEFAULT to false,
|
||||
|
|
|
@ -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<String, Any?>(
|
||||
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 {
|
||||
|
|
|
@ -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<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) {
|
||||
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<String, Any?>(
|
||||
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,
|
||||
|
|
|
@ -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<String, Any?>(
|
||||
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<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) {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
||||
val projection = arrayOf(
|
||||
|
|
|
@ -162,13 +162,34 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<bool> 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;
|
||||
|
||||
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);
|
||||
entry.metadataChangeNotifier.notifyListeners();
|
||||
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 {
|
||||
|
|
|
@ -84,6 +84,11 @@ abstract class MediaFileService {
|
|||
required NameConflictStrategy nameConflictStrategy,
|
||||
});
|
||||
|
||||
Stream<MoveOpEvent> rename(
|
||||
Iterable<AvesEntry> entries, {
|
||||
required String newName,
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> captureFrame(
|
||||
AvesEntry entry, {
|
||||
required String desiredName,
|
||||
|
@ -92,8 +97,6 @@ abstract class MediaFileService {
|
|||
required String destinationAlbum,
|
||||
required NameConflictStrategy nameConflictStrategy,
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
|
||||
}
|
||||
|
||||
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
|
||||
Future<Map<String, dynamic>> captureFrame(
|
||||
AvesEntry entry, {
|
||||
|
@ -370,19 +390,4 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
}
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Map<String, dynamic>> rename(AvesEntry entry, String newName) {
|
||||
Stream<MoveOpEvent> rename(
|
||||
Iterable<AvesEntry> entries, {
|
||||
required String newName,
|
||||
}) {
|
||||
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',
|
||||
'contentId': contentId,
|
||||
'path': '${entry.directory}/$newName',
|
||||
'displayName': newName,
|
||||
'title': newName.substring(0, newName.length - entry.extension!.length),
|
||||
'dateModifiedSecs': FakeMediaStoreService.dateSecs,
|
||||
});
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue