debug: content resolver metadata, copy/move WIP

This commit is contained in:
Thibault Deckers 2020-05-27 12:11:01 +09:00
parent cdf435420f
commit 1cd333d419
19 changed files with 613 additions and 176 deletions

View file

@ -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'

View file

@ -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<Map> entryMapList;
private Map<String, Object> argMap;
private List<Map<String, Object>> entryMapList;
private String op;
public ImageOpStreamHandler(Activity activity, Object arguments) {
this.activity = activity;
if (arguments instanceof Map) {
Map argMap = (Map) arguments;
argMap = (Map<String, Object>) arguments;
this.op = (String) argMap.get("op");
this.entryMapList = new ArrayList<>();
List rawEntries = (List) argMap.get("entries");
List<Map<String, Object>> rawEntries = (List<Map<String, Object>>) 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<String, Object> newFields]}
private void success(final Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> result = new HashMap<String, Object>() {{
put("uri", uriString);
}};
try {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> newFields) {
Map<String, Object> result = new HashMap<String, Object>() {{
put("uri", uriString);
put("success", true);
}};
success(result);
}
@Override
public void onFailure() {
Map<String, Object> result = new HashMap<String, Object>() {{
put("uri", uriString);
put("success", false);
}};
success(result);
}
});
Map<String, Object> result = new HashMap<String, Object>() {{
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();
}

View file

@ -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<String, Object> 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 <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {

View file

@ -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<Object> delete(final Activity activity, final String path, final Uri uri) {
return Futures.immediateFailedFuture(new UnsupportedOperationException());
}
public ListenableFuture<Map<String, Object>> 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) {

View file

@ -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<Object> delete(final Activity activity, final String path, final Uri uri) {
SettableFuture<Object> 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<Map<String, Object>> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) {
SettableFuture<Map<String, Object>> 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<String, Object> 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 {

View file

@ -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;

View file

@ -27,11 +27,11 @@ public class PathComponents {
return storage;
}
String getFolder() {
public String getFolder() {
return folder;
}
String getFilename() {
public String getFilename() {
return filename;
}
}

View file

@ -65,4 +65,9 @@ class AlbumFilter extends CollectionFilter {
@override
int get hashCode => hashValues('AlbumFilter', album);
@override
String toString() {
return 'AlbumFilter{album=$album}';
}
}

View file

@ -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

View file

@ -33,4 +33,9 @@ class TagFilter extends CollectionFilter {
@override
int get hashCode => hashValues('TagFilter', tag);
@override
String toString() {
return 'TagFilter{tag=$tag}';
}
}

View file

@ -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<MoveOpEvent> move(List<ImageEntry> entries, {@required bool copy, @required String destinationPath}) {
debugPrint('move ${entries.length} entries');
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
'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<Map> 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}';
}
}

View file

@ -70,4 +70,18 @@ class MetadataService {
}
return null;
}
static Future<Map> getContentResolverMetadata(ImageEntry entry) async {
try {
// return map with all data available from the content resolver
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
'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 {};
}
}

View file

@ -160,7 +160,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> 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<CollectionAppBar> 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<CollectionAppBar> 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 }

View file

@ -105,9 +105,11 @@ class _AppDrawerState extends State<AppDrawer> {
videoEntry,
favouriteEntry,
_buildSpecialAlbumSection(),
const Divider(),
_buildRegularAlbumSection(),
_buildCountrySection(),
_buildTagSection(),
const Divider(),
aboutEntry,
if (kDebugMode) ...[
const Divider(),
@ -252,7 +254,7 @@ class _AppDrawerState extends State<AppDrawer> {
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<AppDrawer> {
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<AppDrawer> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FilterGridPage(
builder: (context) => FilterNavigationPage(
source: source,
title: 'Tags',
filterEntries: source.getTagEntries(),

View file

@ -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<AlbumFilter>(
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<AlbumFilter>(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<MoveOpEvent> 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<ImageOpEvent> 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<T extends ImageOpEvent>({
@required BuildContext context,
@required List<ImageEntry> selection,
@required Stream<ImageOpEvent> opStream,
@required void Function(Set<ImageOpEvent> processed) onDone,
@required Stream<T> opStream,
@required void Function(Set<T> processed) onDone,
}) {
final processed = <ImageOpEvent>{};
final processed = <T>{};
// 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<ImageOpEvent>(
return StreamBuilder<T>(
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,

View file

@ -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;

View file

@ -49,7 +49,7 @@ class DebugPageState extends State<DebugPage> {
child: Scaffold(
appBar: AppBar(
title: const Text('Debug'),
bottom: TabBar(
bottom: const TabBar(
tabs: [
Tab(icon: Icon(OMIcons.whatshot)),
Tab(icon: Icon(OMIcons.settings)),

View file

@ -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<String, ImageEntry> 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<String, ImageEntry> 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<String> 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,

View file

@ -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<FullscreenDebugPage> {
Future<DateMetadata> _dbDateLoader;
Future<CatalogMetadata> _dbMetadataLoader;
Future<AddressDetails> _dbAddressLoader;
Future<Map> _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<DateMetadata> 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<CatalogMetadata> 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<AddressDetails> 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<DateMetadata> 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<CatalogMetadata> 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<AddressDetails> 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<Map> 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(() {});
}
}