diff --git a/android/app/build.gradle b/android/app/build.gradle index 822ed2f59..710034859 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -64,10 +64,18 @@ flutter { source '../..' } +repositories { + maven { + url "https://s3.amazonaws.com/repo.commonsware.com" + } +} + dependencies { // enable support for Java 8 language APIs (stream, optional, etc.) coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5' + implementation "androidx.exifinterface:exifinterface:1.2.0" + implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.drewnoakes:metadata-extractor:2.14.0' implementation 'com.github.bumptech.glide:glide:4.11.0' implementation 'com.google.guava:guava:29.0-android' diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java index d92c04ddc..c46df0b14 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java @@ -4,36 +4,40 @@ import android.app.Activity; import android.net.Uri; import android.os.Handler; import android.os.Looper; +import android.util.Log; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutionException; import deckers.thibault.aves.model.provider.ImageProvider; import deckers.thibault.aves.model.provider.ImageProviderFactory; +import deckers.thibault.aves.utils.Utils; import io.flutter.plugin.common.EventChannel; public class ImageOpStreamHandler implements EventChannel.StreamHandler { + private static final String LOG_TAG = Utils.createLogTag(ImageOpStreamHandler.class); + public static final String CHANNEL = "deckers.thibault/aves/imageopstream"; private Activity activity; private EventChannel.EventSink eventSink; private Handler handler; - private List entryMapList; + private Map argMap; + private List> entryMapList; private String op; public ImageOpStreamHandler(Activity activity, Object arguments) { this.activity = activity; if (arguments instanceof Map) { - Map argMap = (Map) arguments; + argMap = (Map) arguments; this.op = (String) argMap.get("op"); this.entryMapList = new ArrayList<>(); - List rawEntries = (List) argMap.get("entries"); + List> rawEntries = (List>) argMap.get("entries"); if (rawEntries != null) { - for (Object entry : rawEntries) { - entryMapList.add((Map) entry); - } + entryMapList.addAll(rawEntries); } } } @@ -44,6 +48,8 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { this.handler = new Handler(Looper.getMainLooper()); if ("delete".equals(op)) { new Thread(this::delete).start(); + } else if ("move".equals(op)) { + new Thread(this::move).start(); } else { endOfStream(); } @@ -53,7 +59,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { public void onCancel(Object o) { } - // {String uri, bool success} + // {String uri, bool success, [Map newFields]} private void success(final Map result) { handler.post(() -> eventSink.success(result)); } @@ -66,6 +72,47 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { handler.post(() -> eventSink.endOfStream()); } + private void move() { + if (entryMapList.size() == 0) { + endOfStream(); + return; + } + + // assume same provider for all entries + Map firstEntry = entryMapList.get(0); + Uri firstUri = Uri.parse((String) firstEntry.get("uri")); + ImageProvider provider = ImageProviderFactory.getProvider(firstUri); + if (provider == null) { + error("move-provider", "failed to find provider for uri=" + firstUri, null); + return; + } + + Boolean copy = (Boolean) argMap.get("copy"); + String destinationDir = (String) argMap.get("destinationPath"); + if (copy == null || destinationDir == null) return; + + for (Map entryMap : entryMapList) { + String uriString = (String) entryMap.get("uri"); + Uri sourceUri = Uri.parse(uriString); + String sourcePath = (String) entryMap.get("path"); + String mimeType = (String) entryMap.get("mimeType"); + + Map result = new HashMap() {{ + put("uri", uriString); + }}; + try { + Map newFields = provider.move(activity, sourcePath, sourceUri, destinationDir, mimeType, copy).get(); + result.put("success", true); + result.put("newFields", newFields); + } catch (ExecutionException | InterruptedException e) { + Log.w(LOG_TAG, "failed to move to destinationDir=" + destinationDir + " entry with sourcePath=" + sourcePath, e); + result.put("success", false); + } + success(result); + } + endOfStream(); + } + private void delete() { if (entryMapList.size() == 0) { endOfStream(); @@ -73,7 +120,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { } // assume same provider for all entries - Map firstEntry = entryMapList.get(0); + Map firstEntry = entryMapList.get(0); Uri firstUri = Uri.parse((String) firstEntry.get("uri")); ImageProvider provider = ImageProviderFactory.getProvider(firstUri); if (provider == null) { @@ -81,29 +128,23 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { return; } - for (Map entryMap : entryMapList) { + for (Map entryMap : entryMapList) { String uriString = (String) entryMap.get("uri"); Uri uri = Uri.parse(uriString); String path = (String) entryMap.get("path"); - provider.delete(activity, path, uri, new ImageProvider.ImageOpCallback() { - @Override - public void onSuccess(Map newFields) { - Map result = new HashMap() {{ - put("uri", uriString); - put("success", true); - }}; - success(result); - } - @Override - public void onFailure() { - Map result = new HashMap() {{ - put("uri", uriString); - put("success", false); - }}; - success(result); - } - }); + Map result = new HashMap() {{ + put("uri", uriString); + }}; + try { + provider.delete(activity, path, uri).get(); + result.put("success", true); + } catch (ExecutionException | InterruptedException e) { + Log.w(LOG_TAG, "failed to delete entry with path=" + path, e); + result.put("success", false); + } + success(result); + } endOfStream(); } diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index 0874f733b..dfd950e67 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -1,8 +1,11 @@ package deckers.thibault.aves.channelhandlers; +import android.content.ContentUris; import android.content.Context; +import android.database.Cursor; import android.media.MediaMetadataRetriever; import android.net.Uri; +import android.provider.MediaStore; import android.text.format.Formatter; import androidx.annotation.NonNull; @@ -87,6 +90,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { case "getOverlayMetadata": new Thread(() -> getOverlayMetadata(call, new MethodResultWrapper(result))).start(); break; + case "getContentResolverMetadata": + new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start(); + break; default: result.notImplemented(); break; @@ -329,6 +335,56 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } } + private void getContentResolverMetadata(MethodCall call, MethodChannel.Result result) { + String mimeType = call.argument("mimeType"); + String uriString = call.argument("uri"); + if (mimeType == null || uriString == null) { + result.error("getContentResolverMetadata-args", "failed because of missing arguments", null); + return; + } + + Uri uri = Uri.parse(uriString); + long id = ContentUris.parseId(uri); + Uri contentUri = uri; + if (mimeType.startsWith(MimeTypes.IMAGE)) { + contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); + } else if (mimeType.startsWith(MimeTypes.VIDEO)) { + contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); + } + + Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + Map metadataMap = new HashMap<>(); + int columnCount = cursor.getColumnCount(); + String[] columnNames = cursor.getColumnNames(); + for (int i = 0; i < columnCount; i++) { + String key = columnNames[i]; + switch (cursor.getType(i)) { + case Cursor.FIELD_TYPE_NULL: + default: + metadataMap.put(key, null); + break; + case Cursor.FIELD_TYPE_INTEGER: + metadataMap.put(key, cursor.getInt(i)); + break; + case Cursor.FIELD_TYPE_FLOAT: + metadataMap.put(key, cursor.getFloat(i)); + break; + case Cursor.FIELD_TYPE_STRING: + metadataMap.put(key, cursor.getString(i)); + break; + case Cursor.FIELD_TYPE_BLOB: + metadataMap.put(key, cursor.getBlob(i)); + break; + } + } + cursor.close(); + result.success(metadataMap); + } else { + result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=" + contentUri, null); + } + } + // convenience methods private static void putDateFromDirectoryTag(Map metadataMap, String key, Metadata metadata, Class dirClass, int tag) { diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index eb6a335e8..3b4716959 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -8,7 +8,6 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Matrix; -import android.media.ExifInterface; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; @@ -20,11 +19,14 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.exifinterface.media.ExifInterface; import com.drew.imaging.ImageMetadataReader; import com.drew.imaging.ImageProcessingException; import com.drew.metadata.Metadata; import com.drew.metadata.file.FileTypeDirectory; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import java.io.File; import java.io.FileDescriptor; @@ -49,8 +51,12 @@ public abstract class ImageProvider { callback.onFailure(); } - public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) { - callback.onFailure(); + public ListenableFuture delete(final Activity activity, final String path, final Uri uri) { + return Futures.immediateFailedFuture(new UnsupportedOperationException()); + } + + public ListenableFuture> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) { + return Futures.immediateFailedFuture(new UnsupportedOperationException()); } public void rename(final Activity activity, final String oldPath, final Uri oldUri, final String mimeType, final String newFilename, final ImageOpCallback callback) { diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 082e9c9ba..87db96235 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider; import android.annotation.SuppressLint; import android.app.Activity; import android.content.ContentUris; +import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; @@ -14,6 +15,11 @@ import android.util.Log; import androidx.annotation.NonNull; +import com.commonsware.cwac.document.DocumentFileCompat; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; + +import java.io.File; import java.util.HashMap; import java.util.Map; import java.util.stream.Stream; @@ -21,6 +27,7 @@ import java.util.stream.Stream; import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.MimeTypes; +import deckers.thibault.aves.utils.PathComponents; import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.Utils; @@ -171,38 +178,112 @@ public class MediaStoreImageProvider extends ImageProvider { return !MimeTypes.SVG.equals(mimeType); } + // check write access permission to SD card + // Before KitKat, we do whatever we want on the SD card. + // From KitKat, we need access permission from the Document Provider, at the file level. + // From Lollipop, we can request the permission at the SD card root level. + @Override - public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) { - // check write access permission to SD card - // Before KitKat, we do whatever we want on the SD card. - // From KitKat, we need access permission from the Document Provider, at the file level. - // From Lollipop, we can request the permission at the SD card root level. + public ListenableFuture delete(final Activity activity, final String path, final Uri uri) { + SettableFuture future = SettableFuture.create(); + if (Env.isOnSdCard(activity, path)) { Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); if (sdCardTreeUri == null) { - Runnable runnable = () -> delete(activity, path, uri, callback); + Runnable runnable = () -> { + try { + future.set(delete(activity, path, uri).get()); + } catch (Exception e) { + future.setException(e); + } + }; new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable)); - return; + return future; } // if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store // but it doesn't delete the file, even if the app has the permission StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path); Log.d(LOG_TAG, "deleted from SD card at path=" + uri); - callback.onSuccess(null); - return; + future.set(null); + return future; } try { if (activity.getContentResolver().delete(uri, null, null) > 0) { Log.d(LOG_TAG, "deleted from content resolver uri=" + uri); - callback.onSuccess(null); - return; + future.set(null); + } else { + future.setException(new Exception("failed to delete row from content provider")); } } catch (Exception e) { Log.e(LOG_TAG, "failed to delete entry", e); + future.setException(e); } - callback.onFailure(); + + return future; + } + + @Override + public ListenableFuture> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) { + SettableFuture> future = SettableFuture.create(); + +// if (Env.isOnSdCard(activity, path)) { +// Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); +// if (sdCardTreeUri == null) { +// Runnable runnable = () -> move(activity, path, uri, copy, destinationPath, callback); +// new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showSdCardAccessDialog(activity, runnable)); +// return; +// } +// +// // if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store +// // but it doesn't delete the file, even if the app has the permission +// StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path); +// Log.d(LOG_TAG, "deleted from SD card at path=" + uri); +// callback.onSuccess(null); +// return; +// } + + try { + // from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device) + // from API 26, retrieve document URI from mediastore URI with `MediaStore.getDocumentUri(...)` + // DocumentFile.getUri() is same as original uri: "content://media/external/images/media/58457" + // DocumentFile.getParentFile() is null without picking a tree first + // DocumentsContract.copyDocument() and moveDocument() need parent doc uri + + // TODO TLAD copy/move + // TODO TLAD cannot copy to SD card, even with the permission to the volume root, by inserting to MediaStore + + PathComponents sourcePathComponents = new PathComponents(sourcePath, Env.getStorageVolumes(activity)); + String destinationPath = destinationDir + File.separator + sourcePathComponents.getFilename(); + + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.MediaColumns.DATA, destinationPath); + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); +// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, ""); +// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, ""); + Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.EXTERNAL_CONTENT_URI : MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues); +// Log.d("TLAD", "move copy from=" + sourcePath + " to=" + destinationPath + " (destinationUri=" + destinationUri + ")"); + if (destinationUri == null) { + future.setException(new Exception("failed to insert row to content resolver")); + } else { + DocumentFileCompat source = DocumentFileCompat.fromFile(new File(sourcePath)); + DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri); + source.copyTo(destination); + + Map newFields = new HashMap<>(); + newFields.put("uri", destinationUri.toString()); + newFields.put("contentId", ContentUris.parseId(destinationUri)); + newFields.put("path", destinationPath); + future.set(newFields); + } + } catch (Exception e) { + Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e); + future.setException(e); + } + + return future; } public interface NewEntryHandler { diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java b/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java index df74fe726..5bc895ff7 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/MetadataHelper.java @@ -1,8 +1,7 @@ package deckers.thibault.aves.utils; -import android.media.ExifInterface; - import androidx.annotation.Nullable; +import androidx.exifinterface.media.ExifInterface; import java.text.DateFormat; import java.text.ParseException; diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PathComponents.java b/android/app/src/main/java/deckers/thibault/aves/utils/PathComponents.java index f1daa7e1f..b1fca3ce8 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PathComponents.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PathComponents.java @@ -27,11 +27,11 @@ public class PathComponents { return storage; } - String getFolder() { + public String getFolder() { return folder; } - String getFilename() { + public String getFilename() { return filename; } } diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index 5e4345fe9..6ad3a8be7 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -65,4 +65,9 @@ class AlbumFilter extends CollectionFilter { @override int get hashCode => hashValues('AlbumFilter', album); + + @override + String toString() { + return 'AlbumFilter{album=$album}'; + } } diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index fd5df313d..8e905a9f8 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -35,11 +35,16 @@ class LocationFilter extends CollectionFilter { @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is LocationFilter && other._location == _location; + return other is LocationFilter && other.level == level && other._location == _location; } @override - int get hashCode => hashValues('LocationFilter', _location); + int get hashCode => hashValues('LocationFilter', level, _location); + + @override + String toString() { + return 'LocationFilter{level=$level, location=$_location}'; + } // U+0041 Latin Capital letter A // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index fd9383875..6d66c6a45 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -33,4 +33,9 @@ class TagFilter extends CollectionFilter { @override int get hashCode => hashValues('TagFilter', tag); + + @override + String toString() { + return 'TagFilter{tag=$tag}'; + } } diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 8cf8715ec..e6c3cb5b3 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; @@ -95,6 +96,21 @@ class ImageFileService { } } + static Stream move(List entries, {@required bool copy, @required String destinationPath}) { + debugPrint('move ${entries.length} entries'); + try { + return opChannel.receiveBroadcastStream({ + 'op': 'move', + 'entries': entries.map((e) => e.toMap()).toList(), + 'copy': copy, + 'destinationPath': destinationPath, + }).map((event) => MoveOpEvent.fromMap(event)); + } on PlatformException catch (e) { + debugPrint('move failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + return Stream.error(e); + } + } + static Future rename(ImageEntry entry, String newName) async { try { // return map with: 'contentId' 'path' 'title' 'uri' (all optional) @@ -125,15 +141,55 @@ class ImageFileService { } class ImageOpEvent { - final String uri; final bool success; + final String uri; - ImageOpEvent({this.uri, this.success}); + ImageOpEvent({ + this.success, + this.uri, + }); factory ImageOpEvent.fromMap(Map map) { return ImageOpEvent( - uri: map['uri'], success: map['success'] ?? false, + uri: map['uri'], ); } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is ImageOpEvent && other.success == success && other.uri == uri; + } + + @override + int get hashCode => hashValues('ImageOpEvent', success, uri); + + @override + String toString() { + return 'ImageOpEvent{success=$success, uri=$uri}'; + } +} + +class MoveOpEvent extends ImageOpEvent { + final Map newFields; + + MoveOpEvent({bool success, String uri, this.newFields}) + : super( + success: success, + uri: uri, + ); + + factory MoveOpEvent.fromMap(Map map) { + return MoveOpEvent( + success: map['success'] ?? false, + uri: map['uri'], + newFields: map['newFields'], + ); + } + + @override + String toString() { + return 'MoveOpEvent{success=$success, uri=$uri, newFields=$newFields}'; + } } diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 4086a6591..b6d9c9802 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -70,4 +70,18 @@ class MetadataService { } return null; } + + static Future getContentResolverMetadata(ImageEntry entry) async { + try { + // return map with all data available from the content resolver + final result = await platform.invokeMethod('getContentResolverMetadata', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } } diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index 2473e8fd4..f442c008e 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -160,7 +160,7 @@ class _CollectionAppBarState extends State with SingleTickerPr builder: (context, child) { return IconButton( icon: Icon(action.getIcon()), - onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onActionSelected(context, action), + onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action), tooltip: action.getText(), ); }, @@ -182,6 +182,14 @@ class _CollectionAppBarState extends State with SingleTickerPr ), ], if (collection.isSelecting) ...[ + const PopupMenuItem( + value: CollectionAction.copy, + child: MenuRow(text: 'Copy to album'), + ), + const PopupMenuItem( + value: CollectionAction.move, + child: MenuRow(text: 'Move to album'), + ), const PopupMenuItem( value: CollectionAction.selectAll, child: MenuRow(text: 'Select all'), @@ -253,6 +261,10 @@ class _CollectionAppBarState extends State with SingleTickerPr // wait for the popup menu to hide before proceeding with the action await Future.delayed(Constants.popupMenuTransitionDuration); switch (action) { + case CollectionAction.copy: + case CollectionAction.move: + _actionDelegate.onCollectionActionSelected(context, action); + break; case CollectionAction.select: collection.select(); break; @@ -312,4 +324,4 @@ class _CollectionAppBarState extends State with SingleTickerPr } } -enum CollectionAction { select, selectAll, selectNone, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName } +enum CollectionAction { copy, move, select, selectAll, selectNone, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName } diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 2c9776bb1..7990e15a9 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -105,9 +105,11 @@ class _AppDrawerState extends State { videoEntry, favouriteEntry, _buildSpecialAlbumSection(), + const Divider(), _buildRegularAlbumSection(), _buildCountrySection(), _buildTagSection(), + const Divider(), aboutEntry, if (kDebugMode) ...[ const Divider(), @@ -252,7 +254,7 @@ class _AppDrawerState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => FilterGridPage( + builder: (context) => FilterNavigationPage( source: source, title: 'Albums', filterEntries: source.getAlbumEntries(), @@ -267,7 +269,7 @@ class _AppDrawerState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => FilterGridPage( + builder: (context) => FilterNavigationPage( source: source, title: 'Countries', filterEntries: source.getCountryEntries(), @@ -282,7 +284,7 @@ class _AppDrawerState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => FilterGridPage( + builder: (context) => FilterNavigationPage( source: source, title: 'Tags', filterEntries: source.getTagEntries(), diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index 775481584..94df3d5f9 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -1,11 +1,15 @@ import 'dart:async'; import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/filters/album.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; +import 'package:aves/widgets/album/app_bar.dart'; import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; import 'package:aves/widgets/common/entry_actions.dart'; +import 'package:aves/widgets/common/icons.dart'; +import 'package:aves/widgets/filter_grid_page.dart'; import 'package:collection/collection.dart'; import 'package:flushbar/flushbar.dart'; import 'package:flutter/foundation.dart'; @@ -20,7 +24,7 @@ class SelectionActionDelegate with PermissionAwareMixin { @required this.collection, }); - void onActionSelected(BuildContext context, EntryAction action) { + void onEntryActionSelected(BuildContext context, EntryAction action) { switch (action) { case EntryAction.delete: _showDeleteDialog(context); @@ -33,6 +37,77 @@ class SelectionActionDelegate with PermissionAwareMixin { } } + void onCollectionActionSelected(BuildContext context, CollectionAction action) { + switch (action) { + case CollectionAction.copy: + _moveSelection(context, copy: true); + break; + case CollectionAction.move: + _moveSelection(context, copy: false); + break; + default: + break; + } + } + + Future _moveSelection(BuildContext context, {@required bool copy}) async { + final filter = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + final source = collection.source; + return FilterGridPage( + source: source, + appBar: SliverAppBar( + leading: const BackButton(), + title: Text(copy ? 'Copy to Album' : 'Move to Album'), + actions: [ + IconButton( + icon: const Icon(AIcons.createAlbum), + onPressed: () { + // TODO TLAD album creation + }, + tooltip: 'Create album', + ), + ], + floating: true, + ), + filterEntries: source.getAlbumEntries(), + filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), + onPressed: (filter) => Navigator.pop(context, filter), + ); + }, + ), + ); + if (filter == null) return; + + final selection = collection.selection.toList(); + if (!await checkStoragePermission(context, selection)) return; + + _showOpReport( + context: context, + selection: selection, + opStream: ImageFileService.move(selection, copy: copy, destinationPath: filter.album), + onDone: (Set processed) { + debugPrint('$runtimeType _moveSelection onDone'); + final movedUris = processed.where((e) => e.success).map((e) => e.uri); + final movedCount = movedUris.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')}'); + } + if (movedCount > 0) { + processed.forEach((event) { + debugPrint('$runtimeType _moveSelection moved entry uri=${event.uri} newFields=${event.newFields}'); + // TODO TLAD update source + }); + } + collection.browse(); + }, + ); + } + void _showDeleteDialog(BuildContext context) async { final selection = collection.selection.toList(); final count = selection.length; @@ -63,7 +138,7 @@ class SelectionActionDelegate with PermissionAwareMixin { context: context, selection: selection, opStream: ImageFileService.delete(selection), - onDone: (processed) { + onDone: (Set processed) { final deletedUris = processed.where((e) => e.success).map((e) => e.uri); final deletedCount = deletedUris.length; final selectionCount = selection.length; @@ -90,16 +165,22 @@ class SelectionActionDelegate with PermissionAwareMixin { static const _overlayAnimationDuration = Duration(milliseconds: 300); - void _showOpReport({ + void _showOpReport({ @required BuildContext context, @required List selection, - @required Stream opStream, - @required void Function(Set processed) onDone, + @required Stream opStream, + @required void Function(Set processed) onDone, }) { - final processed = {}; + final processed = {}; + + // do not handle completion inside `StreamBuilder` + // as it could be called multiple times + final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed)); + opStream.listen(null, onError: (error) => onComplete(), onDone: onComplete); + _opReportOverlayEntry = OverlayEntry( builder: (context) { - return StreamBuilder( + return StreamBuilder( stream: opStream, builder: (context, snapshot) { if (snapshot.hasData) { @@ -107,9 +188,7 @@ class SelectionActionDelegate with PermissionAwareMixin { } Widget child = const SizedBox.shrink(); - if (snapshot.hasError || snapshot.connectionState == ConnectionState.done) { - _hideOpReportOverlay().then((_) => onDone(processed)); - } else if (snapshot.connectionState == ConnectionState.active) { + if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) { final percent = processed.length.toDouble() / selection.length; child = CircularPercentIndicator( percent: percent, diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 11d7d4465..4ddd2da0f 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -25,6 +25,7 @@ class AIcons { // actions static const IconData clear = OMIcons.clear; static const IconData collapse = OMIcons.expandLess; + static const IconData createAlbum = OMIcons.addCircleOutline; static const IconData debug = OMIcons.whatshot; static const IconData delete = OMIcons.delete; static const IconData expand = OMIcons.expandMore; diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index 272cec92b..658671fcd 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -49,7 +49,7 @@ class DebugPageState extends State { child: Scaffold( appBar: AppBar( title: const Text('Debug'), - bottom: TabBar( + bottom: const TabBar( tabs: [ Tab(icon: Icon(OMIcons.whatshot)), Tab(icon: Icon(OMIcons.settings)), diff --git a/lib/widgets/filter_grid_page.dart b/lib/widgets/filter_grid_page.dart index b0f6743db..a7e66a6c1 100644 --- a/lib/widgets/filter_grid_page.dart +++ b/lib/widgets/filter_grid_page.dart @@ -12,19 +12,60 @@ import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class FilterGridPage extends StatelessWidget { +class FilterNavigationPage extends StatelessWidget { final CollectionSource source; final String title; final Map filterEntries; final CollectionFilter Function(String key) filterBuilder; - const FilterGridPage({ + const FilterNavigationPage({ @required this.source, @required this.title, @required this.filterEntries, @required this.filterBuilder, }); + @override + Widget build(BuildContext context) { + return FilterGridPage( + source: source, + appBar: SliverAppBar( + title: Text(title), + floating: true, + ), + filterEntries: filterEntries, + filterBuilder: filterBuilder, + onPressed: (filter) => Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => CollectionPage(CollectionLens( + source: source, + filters: [filter], + groupFactor: settings.collectionGroupFactor, + sortFactor: settings.collectionSortFactor, + )), + ), + (route) => false, + ), + ); + } +} + +class FilterGridPage extends StatelessWidget { + final CollectionSource source; + final Widget appBar; + final Map filterEntries; + final CollectionFilter Function(String key) filterBuilder; + final FilterCallback onPressed; + + const FilterGridPage({ + @required this.source, + @required this.appBar, + @required this.filterEntries, + @required this.filterBuilder, + @required this.onPressed, + }); + List get filterKeys => filterEntries.keys.toList(); @override @@ -34,10 +75,7 @@ class FilterGridPage extends StatelessWidget { body: SafeArea( child: CustomScrollView( slivers: [ - SliverAppBar( - title: Text(title), - floating: true, - ), + appBar, SliverPadding( padding: EdgeInsets.all(AvesFilterChip.buttonBorderWidth), sliver: SliverGrid( @@ -62,18 +100,7 @@ class FilterGridPage extends StatelessWidget { filter: filterBuilder(key), showGenericIcon: false, decoration: decoration, - onPressed: (filter) => Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (context) => CollectionPage(CollectionLens( - source: source, - filters: [filter], - groupFactor: settings.collectionGroupFactor, - sortFactor: settings.collectionSortFactor, - )), - ), - (route) => false, - ), + onPressed: onPressed, ); }, childCount: filterKeys.length, diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index 48376d6de..809939e8f 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -1,6 +1,9 @@ +import 'dart:collection'; + import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; +import 'package:aves/services/metadata_service.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:flutter/material.dart'; @@ -17,115 +20,152 @@ class _FullscreenDebugPageState extends State { Future _dbDateLoader; Future _dbMetadataLoader; Future _dbAddressLoader; + Future _contentResolverMetadataLoader; int get contentId => widget.entry.contentId; @override void initState() { super.initState(); - _startDbReport(); + _initFutures(); } @override Widget build(BuildContext context) { - final catalog = widget.entry.catalogMetadata; - return Scaffold( - appBar: AppBar( - title: const Text('Debug'), - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - FutureBuilder( - future: _dbDateLoader, - builder: (context, AsyncSnapshot snapshot) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); - final data = snapshot.data; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('DB date:${data == null ? ' no row' : ''}'), - if (data != null) - InfoRowGroup({ - 'dateMillis': '${data.dateMillis}', - }), - ], - ); - }, - ), - const SizedBox(height: 16), - FutureBuilder( - future: _dbMetadataLoader, - builder: (context, AsyncSnapshot snapshot) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); - final data = snapshot.data; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('DB metadata:${data == null ? ' no row' : ''}'), - if (data != null) - InfoRowGroup({ - 'dateMillis': '${data.dateMillis}', - 'isAnimated': '${data.isAnimated}', - 'videoRotation': '${data.videoRotation}', - 'latitude': '${data.latitude}', - 'longitude': '${data.longitude}', - 'xmpSubjects': '${data.xmpSubjects}', - 'xmpTitleDescription': '${data.xmpTitleDescription}', - }), - ], - ); - }, - ), - const SizedBox(height: 16), - FutureBuilder( - future: _dbAddressLoader, - builder: (context, AsyncSnapshot snapshot) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); - final data = snapshot.data; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('DB address:${data == null ? ' no row' : ''}'), - if (data != null) - InfoRowGroup({ - 'addressLine': '${data.addressLine}', - 'countryCode': '${data.countryCode}', - 'countryName': '${data.countryName}', - 'adminArea': '${data.adminArea}', - 'locality': '${data.locality}', - }), - ], - ); - }, - ), - const Divider(), - Text('Catalog metadata:${catalog == null ? ' no data' : ''}'), - if (catalog != null) - InfoRowGroup({ - 'contentId': '${catalog.contentId}', - 'dateMillis': '${catalog.dateMillis}', - 'isAnimated': '${catalog.isAnimated}', - 'videoRotation': '${catalog.videoRotation}', - 'latitude': '${catalog.latitude}', - 'longitude': '${catalog.longitude}', - 'xmpSubjects': '${catalog.xmpSubjects}', - 'xmpTitleDescription': '${catalog.xmpTitleDescription}', - }), - ], + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Debug'), + bottom: const TabBar( + tabs: [ + Tab(text: 'DB'), + Tab(text: 'Content Resolver'), + ], + ), + ), + body: SafeArea( + child: TabBarView( + children: [ + _buildDbTabView(), + _buildContentResolverTabView(), + ], + ), ), ), ); } - void _startDbReport() { + Widget _buildDbTabView() { + final catalog = widget.entry.catalogMetadata; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + FutureBuilder( + future: _dbDateLoader, + builder: (context, AsyncSnapshot snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + final data = snapshot.data; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('DB date:${data == null ? ' no row' : ''}'), + if (data != null) + InfoRowGroup({ + 'dateMillis': '${data.dateMillis}', + }), + ], + ); + }, + ), + const SizedBox(height: 16), + FutureBuilder( + future: _dbMetadataLoader, + builder: (context, AsyncSnapshot snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + final data = snapshot.data; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('DB metadata:${data == null ? ' no row' : ''}'), + if (data != null) + InfoRowGroup({ + 'dateMillis': '${data.dateMillis}', + 'isAnimated': '${data.isAnimated}', + 'videoRotation': '${data.videoRotation}', + 'latitude': '${data.latitude}', + 'longitude': '${data.longitude}', + 'xmpSubjects': '${data.xmpSubjects}', + 'xmpTitleDescription': '${data.xmpTitleDescription}', + }), + ], + ); + }, + ), + const SizedBox(height: 16), + FutureBuilder( + future: _dbAddressLoader, + builder: (context, AsyncSnapshot snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + final data = snapshot.data; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('DB address:${data == null ? ' no row' : ''}'), + if (data != null) + InfoRowGroup({ + 'addressLine': '${data.addressLine}', + 'countryCode': '${data.countryCode}', + 'countryName': '${data.countryName}', + 'adminArea': '${data.adminArea}', + 'locality': '${data.locality}', + }), + ], + ); + }, + ), + const Divider(), + Text('Catalog metadata:${catalog == null ? ' no data' : ''}'), + if (catalog != null) + InfoRowGroup({ + 'contentId': '${catalog.contentId}', + 'dateMillis': '${catalog.dateMillis}', + 'isAnimated': '${catalog.isAnimated}', + 'videoRotation': '${catalog.videoRotation}', + 'latitude': '${catalog.latitude}', + 'longitude': '${catalog.longitude}', + 'xmpSubjects': '${catalog.xmpSubjects}', + 'xmpTitleDescription': '${catalog.xmpTitleDescription}', + }), + ], + ); + } + + Widget _buildContentResolverTabView() { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + FutureBuilder( + future: _contentResolverMetadataLoader, + builder: (context, AsyncSnapshot snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null'))); + return InfoRowGroup(data); + }, + ), + ], + ); + } + + void _initFutures() { _dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); + _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(widget.entry); setState(() {}); } }