album: rename by moving entries

This commit is contained in:
Thibault Deckers 2020-09-28 11:46:25 +09:00
parent f32c3f1154
commit 44fe56efdb
6 changed files with 74 additions and 242 deletions

View file

@ -9,7 +9,6 @@ import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import java.io.File;
import java.util.List;
import java.util.Map;
@ -59,9 +58,6 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "rotate":
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
break;
case "renameDirectory":
new Thread(() -> renameDirectory(call, new MethodResultWrapper(result))).start();
break;
default:
result.notImplemented();
break;
@ -183,29 +179,4 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
});
}
private void renameDirectory(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String dirPath = call.argument("path");
String newName = call.argument("newName");
if (dirPath == null || newName == null) {
result.error("renameDirectory-args", "failed because of missing arguments", null);
return;
}
if (!dirPath.endsWith(File.separator)) {
dirPath += File.separator;
}
ImageProvider provider = new MediaStoreImageProvider();
provider.renameDirectory(activity, dirPath, newName, new ImageProvider.AlbumRenameOpCallback() {
@Override
public void onSuccess(List<Map<String, Object>> fieldsByEntry) {
result.success(fieldsByEntry);
}
@Override
public void onFailure(Throwable throwable) {
result.error("renameDirectory-failure", "failed to rename directory", throwable.getMessage());
}
});
}
}

View file

@ -17,20 +17,14 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.MetadataHelper;
@ -92,122 +86,6 @@ public abstract class ImageProvider {
scanNewPath(context, newFile.getPath(), mimeType, callback);
}
@SuppressWarnings("UnstableApiUsage")
public void renameDirectory(Context context, final String oldDirPath, String newDirName, final AlbumRenameOpCallback callback) {
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, oldDirPath);
if (destinationDirDocFile == null) {
callback.onFailure(new Exception("failed to find directory at path=" + oldDirPath));
return;
}
// list entries with their content IDs before renaming
Uri[] baseContentUris = new Uri[]{MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Video.Media.EXTERNAL_CONTENT_URI};
Map<Uri, List<Map<String, Object>>> entriesByBaseContentUri = Arrays.stream(baseContentUris).collect(Collectors.toMap(
uri -> uri,
uri -> listContentEntries(context, uri, oldDirPath)
));
// rename directory
boolean renamed;
try {
renamed = destinationDirDocFile.renameTo(newDirName);
} catch (FileNotFoundException e) {
callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath, e));
return;
}
if (!renamed) {
callback.onFailure(new Exception("failed to rename to name=" + newDirName + " directory at path=" + oldDirPath));
return;
}
String newDirPath = new File(oldDirPath).getParent() + File.separator + newDirName + File.separator;
// scan old paths for cleanup, and new paths to fetch content IDs
Collection<SettableFuture<Map<String, Object>>> scanFutures = new ArrayList<>();
entriesByBaseContentUri.forEach((baseContentUri, entries) -> {
int count = entries.size();
if (count > 0) {
String[] mimeTypes = new String[count];
String[] oldPaths = new String[count];
String[] newPaths = new String[count];
Map<String, Integer> oldContentIdByPath = new HashMap<>();
Map<String, SettableFuture<Map<String, Object>>> scanFutureByPath = new HashMap<>();
for (int i = 0; i < count; i++) {
Map<String, Object> entry = entries.get(i);
String displayName = (String) entry.get("displayName");
String newPath = newDirPath + displayName;
mimeTypes[i] = (String) entry.get("mimeType");
oldPaths[i] = oldDirPath + displayName;
newPaths[i] = newPath;
oldContentIdByPath.put(newPath, (Integer) entry.get("oldContentId"));
scanFutureByPath.put(newPath, SettableFuture.create());
}
MediaScannerConnection.scanFile(context, oldPaths, mimeTypes, null);
MediaScannerConnection.scanFile(context, newPaths, mimeTypes, (path, rawUri) -> {
SettableFuture<Map<String, Object>> future = scanFutureByPath.get(path);
if (future == null) {
Log.e(LOG_TAG, "no future for path=" + path);
return;
}
if (rawUri == null) {
future.setException(new Exception("failed to get URI of item at path=" + path));
return;
}
// newURI is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
long contentId = ContentUris.parseId(rawUri);
Uri contentUri = ContentUris.withAppendedId(baseContentUri, contentId);
Map<String, Object> newFields = new HashMap<String, Object>() {{
put("path", path);
put("uri", contentUri.toString());
put("contentId", contentId);
put("oldContentId", oldContentIdByPath.get(path));
}};
future.set(newFields);
});
scanFutures.addAll(scanFutureByPath.values());
}
});
try {
callback.onSuccess(Futures.allAsList(scanFutures).get());
} catch (ExecutionException | InterruptedException e) {
callback.onFailure(e);
}
}
private List<Map<String, Object>> listContentEntries(Context context, Uri contentUri, String dirPath) {
List<Map<String, Object>> entries = new ArrayList<>();
String[] projection = {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.MIME_TYPE,
};
String selection = MediaStore.MediaColumns.DATA + " like ?";
try {
Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, new String[]{dirPath + "%"}, null);
if (cursor != null) {
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
int displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE);
while (cursor.moveToNext()) {
entries.add(new HashMap<String, Object>() {{
put("oldContentId", cursor.getInt(idColumn));
put("displayName", cursor.getString(displayNameColumn));
put("mimeType", cursor.getString(mimeTypeColumn));
}});
}
cursor.close();
}
} catch (Exception e) {
Log.e(LOG_TAG, "failed to list entries in contentUri=" + contentUri, e);
}
return entries;
}
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
switch (mimeType) {
case MimeTypes.JPEG:
@ -416,10 +294,4 @@ public abstract class ImageProvider {
void onFailure(Throwable throwable);
}
public interface AlbumRenameOpCallback {
void onSuccess(List<Map<String, Object>> fieldsByEntry);
void onFailure(Throwable throwable);
}
}

View file

@ -9,6 +9,7 @@ import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
@ -108,20 +109,53 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
}
void updateAfterMove({
@required Iterable<ImageEntry> entries,
@required Set<String> fromAlbums,
@required String toAlbum,
@required List<ImageEntry> selection,
@required bool copy,
}) {
@required String destinationAlbum,
@required Iterable<MoveOpEvent> movedOps,
}) async {
if (movedOps.isEmpty) return;
final fromAlbums = <String>{};
final movedEntries = <ImageEntry>[];
if (copy) {
addAll(entries);
movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final sourceEntry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry?.copyWith(
uri: newFields['uri'] as String,
path: newFields['path'] as String,
contentId: newFields['contentId'] as int,
dateModifiedSecs: newFields['dateModifiedSecs'] as int,
));
});
await metadataDb.saveEntries(movedEntries);
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata));
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
} else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
if (entry != null) {
fromAlbums.add(entry.directory);
movedEntries.add(entry);
await moveEntry(entry, newFields);
}
});
}
if (copy) {
addAll(movedEntries);
} else {
cleanEmptyAlbums(fromAlbums);
addFolderPath({toAlbum});
addFolderPath({destinationAlbum});
}
updateAlbums();
invalidateFilterEntryCounts();
eventBus.fire(EntryMovedEvent(entries));
eventBus.fire(EntryMovedEvent(movedEntries));
}
int count(CollectionFilter filter) {

View file

@ -194,19 +194,6 @@ class ImageFileService {
}
return {};
}
static Future<List<Map>> renameDirectory(String path, String newName) async {
try {
final result = await platform.invokeMethod('renameDirectory', <String, dynamic>{
'path': path,
'newName': newName,
});
return (result as List).cast<Map>();
} on PlatformException catch (e) {
debugPrint('renameDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
}
@immutable

View file

@ -1,8 +1,6 @@
import 'dart:async';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
@ -108,7 +106,6 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
selection: selection,
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
onDone: (processed) async {
debugPrint('$runtimeType _moveSelection onDone');
final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length;
final selectionCount = selection.length;
@ -119,44 +116,12 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final count = movedCount;
showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
}
if (movedCount > 0) {
final fromAlbums = <String>{};
final movedEntries = <ImageEntry>[];
if (copy) {
movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final sourceEntry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry?.copyWith(
uri: newFields['uri'] as String,
path: newFields['path'] as String,
contentId: newFields['contentId'] as int,
dateModifiedSecs: newFields['dateModifiedSecs'] as int,
));
});
await metadataDb.saveEntries(movedEntries);
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata));
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
} else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields;
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
if (entry != null) {
fromAlbums.add(entry.directory);
movedEntries.add(entry);
await source.moveEntry(entry, newFields);
}
});
}
source.updateAfterMove(
entries: movedEntries,
fromAlbums: fromAlbums,
toAlbum: destinationAlbum,
copy: copy,
);
}
await source.updateAfterMove(
selection: selection,
copy: copy,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
collection.clearSelection();
collection.browse();
},

View file

@ -1,6 +1,5 @@
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
@ -113,30 +112,34 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
if (!await checkStoragePermissionForAlbums(context, {album})) return;
final result = await ImageFileService.renameDirectory(album, newName);
final selection = source.rawEntries.where(filter.filter).toList();
final destinationAlbum = path.join(path.dirname(album), newName);
final albumEntries = source.rawEntries.where(filter.filter);
final movedEntries = <ImageEntry>[];
await Future.forEach<Map>(result, (newFields) async {
final oldContentId = newFields['oldContentId'];
final entry = albumEntries.firstWhere((entry) => entry.contentId == oldContentId, orElse: () => null);
if (entry != null) {
movedEntries.add(entry);
await source.moveEntry(entry, newFields);
}
});
final newAlbum = path.join(path.dirname(album), newName);
source.updateAfterMove(
entries: movedEntries,
fromAlbums: {album},
toAlbum: newAlbum,
copy: false,
showOpReport<MoveOpEvent>(
context: context,
selection: selection,
opStream: ImageFileService.move(selection, copy: false, destinationAlbum: destinationAlbum),
onDone: (processed) async {
final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length;
final selectionCount = selection.length;
if (movedCount < selectionCount) {
final count = selectionCount - movedCount;
showFeedback(context, 'Failed to move ${Intl.plural(count, one: '$count item', other: '$count items')}');
} else {
showFeedback(context, 'Done!');
}
await source.updateAfterMove(
selection: selection,
copy: false,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
final newFilter = AlbumFilter(destinationAlbum, source.getUniqueAlbumName(destinationAlbum));
settings.pinnedFilters = settings.pinnedFilters
..remove(filter)
..add(newFilter);
},
);
final newFilter = AlbumFilter(newAlbum, source.getUniqueAlbumName(newAlbum));
settings.pinnedFilters = settings.pinnedFilters
..remove(filter)
..add(newFilter);
showFeedback(context, 'Done!');
}
}