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 '../..' source '../..'
} }
repositories {
maven {
url "https://s3.amazonaws.com/repo.commonsware.com"
}
}
dependencies { dependencies {
// enable support for Java 8 language APIs (stream, optional, etc.) // enable support for Java 8 language APIs (stream, optional, etc.)
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5' 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.drewnoakes:metadata-extractor:2.14.0'
implementation 'com.github.bumptech.glide:glide:4.11.0' implementation 'com.github.bumptech.glide:glide:4.11.0'
implementation 'com.google.guava:guava:29.0-android' implementation 'com.google.guava:guava:29.0-android'

View file

@ -4,36 +4,40 @@ import android.app.Activity;
import android.net.Uri; import android.net.Uri;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.model.provider.ImageProvider; import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory; import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel;
public class ImageOpStreamHandler implements EventChannel.StreamHandler { 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"; public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
private Activity activity; private Activity activity;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
private List<Map> entryMapList; private Map<String, Object> argMap;
private List<Map<String, Object>> entryMapList;
private String op; private String op;
public ImageOpStreamHandler(Activity activity, Object arguments) { public ImageOpStreamHandler(Activity activity, Object arguments) {
this.activity = activity; this.activity = activity;
if (arguments instanceof Map) { if (arguments instanceof Map) {
Map argMap = (Map) arguments; argMap = (Map<String, Object>) arguments;
this.op = (String) argMap.get("op"); this.op = (String) argMap.get("op");
this.entryMapList = new ArrayList<>(); 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) { if (rawEntries != null) {
for (Object entry : rawEntries) { entryMapList.addAll(rawEntries);
entryMapList.add((Map) entry);
}
} }
} }
} }
@ -44,6 +48,8 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
this.handler = new Handler(Looper.getMainLooper()); this.handler = new Handler(Looper.getMainLooper());
if ("delete".equals(op)) { if ("delete".equals(op)) {
new Thread(this::delete).start(); new Thread(this::delete).start();
} else if ("move".equals(op)) {
new Thread(this::move).start();
} else { } else {
endOfStream(); endOfStream();
} }
@ -53,7 +59,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
public void onCancel(Object o) { 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) { private void success(final Map<String, Object> result) {
handler.post(() -> eventSink.success(result)); handler.post(() -> eventSink.success(result));
} }
@ -66,6 +72,47 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
handler.post(() -> eventSink.endOfStream()); 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() { private void delete() {
if (entryMapList.size() == 0) { if (entryMapList.size() == 0) {
endOfStream(); endOfStream();
@ -73,7 +120,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
} }
// assume same provider for all entries // 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")); Uri firstUri = Uri.parse((String) firstEntry.get("uri"));
ImageProvider provider = ImageProviderFactory.getProvider(firstUri); ImageProvider provider = ImageProviderFactory.getProvider(firstUri);
if (provider == null) { if (provider == null) {
@ -81,29 +128,23 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
return; return;
} }
for (Map entryMap : entryMapList) { for (Map<String, Object> entryMap : entryMapList) {
String uriString = (String) entryMap.get("uri"); String uriString = (String) entryMap.get("uri");
Uri uri = Uri.parse(uriString); Uri uri = Uri.parse(uriString);
String path = (String) entryMap.get("path"); 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>() {{ Map<String, Object> result = new HashMap<String, Object>() {{
put("uri", uriString); put("uri", uriString);
put("success", false);
}}; }};
success(result); 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(); endOfStream();
} }

View file

@ -1,8 +1,11 @@
package deckers.thibault.aves.channelhandlers; package deckers.thibault.aves.channelhandlers;
import android.content.ContentUris;
import android.content.Context; import android.content.Context;
import android.database.Cursor;
import android.media.MediaMetadataRetriever; import android.media.MediaMetadataRetriever;
import android.net.Uri; import android.net.Uri;
import android.provider.MediaStore;
import android.text.format.Formatter; import android.text.format.Formatter;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -87,6 +90,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
case "getOverlayMetadata": case "getOverlayMetadata":
new Thread(() -> getOverlayMetadata(call, new MethodResultWrapper(result))).start(); new Thread(() -> getOverlayMetadata(call, new MethodResultWrapper(result))).start();
break; break;
case "getContentResolverMetadata":
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
break;
default: default:
result.notImplemented(); result.notImplemented();
break; 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 // convenience methods
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) { 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.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Matrix; import android.graphics.Matrix;
import android.media.ExifInterface;
import android.media.MediaScannerConnection; import android.media.MediaScannerConnection;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
@ -20,11 +19,14 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import com.drew.imaging.ImageMetadataReader; import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException; import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata; import com.drew.metadata.Metadata;
import com.drew.metadata.file.FileTypeDirectory; 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.File;
import java.io.FileDescriptor; import java.io.FileDescriptor;
@ -49,8 +51,12 @@ public abstract class ImageProvider {
callback.onFailure(); callback.onFailure();
} }
public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) { public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri uri) {
callback.onFailure(); 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) { 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.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
@ -14,6 +15,11 @@ import android.util.Log;
import androidx.annotation.NonNull; 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.HashMap;
import java.util.Map; import java.util.Map;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -21,6 +27,7 @@ import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PathComponents;
import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
@ -171,38 +178,112 @@ public class MediaStoreImageProvider extends ImageProvider {
return !MimeTypes.SVG.equals(mimeType); return !MimeTypes.SVG.equals(mimeType);
} }
@Override
public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) {
// check write access permission to SD card // check write access permission to SD card
// Before KitKat, we do whatever we want on the 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 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. // From Lollipop, we can request the permission at the SD card root level.
@Override
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri uri) {
SettableFuture<Object> future = SettableFuture.create();
if (Env.isOnSdCard(activity, path)) { if (Env.isOnSdCard(activity, path)) {
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity); Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
if (sdCardTreeUri == null) { 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)); 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 // 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 // but it doesn't delete the file, even if the app has the permission
StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path); StorageUtils.deleteFromSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), path);
Log.d(LOG_TAG, "deleted from SD card at path=" + uri); Log.d(LOG_TAG, "deleted from SD card at path=" + uri);
callback.onSuccess(null); future.set(null);
return; return future;
} }
try { try {
if (activity.getContentResolver().delete(uri, null, null) > 0) { if (activity.getContentResolver().delete(uri, null, null) > 0) {
Log.d(LOG_TAG, "deleted from content resolver uri=" + uri); Log.d(LOG_TAG, "deleted from content resolver uri=" + uri);
callback.onSuccess(null); future.set(null);
return; } else {
future.setException(new Exception("failed to delete row from content provider"));
} }
} catch (Exception e) { } catch (Exception e) {
Log.e(LOG_TAG, "failed to delete entry", 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 { public interface NewEntryHandler {

View file

@ -1,8 +1,7 @@
package deckers.thibault.aves.utils; package deckers.thibault.aves.utils;
import android.media.ExifInterface;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import java.text.DateFormat; import java.text.DateFormat;
import java.text.ParseException; import java.text.ParseException;

View file

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

View file

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

View file

@ -35,11 +35,16 @@ class LocationFilter extends CollectionFilter {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; if (other.runtimeType != runtimeType) return false;
return other is LocationFilter && other._location == _location; return other is LocationFilter && other.level == level && other._location == _location;
} }
@override @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+0041 Latin Capital letter A
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A

View file

@ -33,4 +33,9 @@ class TagFilter extends CollectionFilter {
@override @override
int get hashCode => hashValues('TagFilter', tag); 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/model/image_entry.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.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 { static Future<Map> rename(ImageEntry entry, String newName) async {
try { try {
// return map with: 'contentId' 'path' 'title' 'uri' (all optional) // return map with: 'contentId' 'path' 'title' 'uri' (all optional)
@ -125,15 +141,55 @@ class ImageFileService {
} }
class ImageOpEvent { class ImageOpEvent {
final String uri;
final bool success; final bool success;
final String uri;
ImageOpEvent({this.uri, this.success}); ImageOpEvent({
this.success,
this.uri,
});
factory ImageOpEvent.fromMap(Map map) { factory ImageOpEvent.fromMap(Map map) {
return ImageOpEvent( return ImageOpEvent(
uri: map['uri'],
success: map['success'] ?? false, 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; 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) { builder: (context, child) {
return IconButton( return IconButton(
icon: Icon(action.getIcon()), 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(), tooltip: action.getText(),
); );
}, },
@ -182,6 +182,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
), ),
], ],
if (collection.isSelecting) ...[ 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( const PopupMenuItem(
value: CollectionAction.selectAll, value: CollectionAction.selectAll,
child: MenuRow(text: 'Select all'), 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 // wait for the popup menu to hide before proceeding with the action
await Future.delayed(Constants.popupMenuTransitionDuration); await Future.delayed(Constants.popupMenuTransitionDuration);
switch (action) { switch (action) {
case CollectionAction.copy:
case CollectionAction.move:
_actionDelegate.onCollectionActionSelected(context, action);
break;
case CollectionAction.select: case CollectionAction.select:
collection.select(); collection.select();
break; 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, videoEntry,
favouriteEntry, favouriteEntry,
_buildSpecialAlbumSection(), _buildSpecialAlbumSection(),
const Divider(),
_buildRegularAlbumSection(), _buildRegularAlbumSection(),
_buildCountrySection(), _buildCountrySection(),
_buildTagSection(), _buildTagSection(),
const Divider(),
aboutEntry, aboutEntry,
if (kDebugMode) ...[ if (kDebugMode) ...[
const Divider(), const Divider(),
@ -252,7 +254,7 @@ class _AppDrawerState extends State<AppDrawer> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => FilterGridPage( builder: (context) => FilterNavigationPage(
source: source, source: source,
title: 'Albums', title: 'Albums',
filterEntries: source.getAlbumEntries(), filterEntries: source.getAlbumEntries(),
@ -267,7 +269,7 @@ class _AppDrawerState extends State<AppDrawer> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => FilterGridPage( builder: (context) => FilterNavigationPage(
source: source, source: source,
title: 'Countries', title: 'Countries',
filterEntries: source.getCountryEntries(), filterEntries: source.getCountryEntries(),
@ -282,7 +284,7 @@ class _AppDrawerState extends State<AppDrawer> {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => FilterGridPage( builder: (context) => FilterNavigationPage(
source: source, source: source,
title: 'Tags', title: 'Tags',
filterEntries: source.getTagEntries(), filterEntries: source.getTagEntries(),

View file

@ -1,11 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_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/action_delegates/permission_aware.dart';
import 'package:aves/widgets/common/entry_actions.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:collection/collection.dart';
import 'package:flushbar/flushbar.dart'; import 'package:flushbar/flushbar.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -20,7 +24,7 @@ class SelectionActionDelegate with PermissionAwareMixin {
@required this.collection, @required this.collection,
}); });
void onActionSelected(BuildContext context, EntryAction action) { void onEntryActionSelected(BuildContext context, EntryAction action) {
switch (action) { switch (action) {
case EntryAction.delete: case EntryAction.delete:
_showDeleteDialog(context); _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 { void _showDeleteDialog(BuildContext context) async {
final selection = collection.selection.toList(); final selection = collection.selection.toList();
final count = selection.length; final count = selection.length;
@ -63,7 +138,7 @@ class SelectionActionDelegate with PermissionAwareMixin {
context: context, context: context,
selection: selection, selection: selection,
opStream: ImageFileService.delete(selection), opStream: ImageFileService.delete(selection),
onDone: (processed) { onDone: (Set<ImageOpEvent> processed) {
final deletedUris = processed.where((e) => e.success).map((e) => e.uri); final deletedUris = processed.where((e) => e.success).map((e) => e.uri);
final deletedCount = deletedUris.length; final deletedCount = deletedUris.length;
final selectionCount = selection.length; final selectionCount = selection.length;
@ -90,16 +165,22 @@ class SelectionActionDelegate with PermissionAwareMixin {
static const _overlayAnimationDuration = Duration(milliseconds: 300); static const _overlayAnimationDuration = Duration(milliseconds: 300);
void _showOpReport({ void _showOpReport<T extends ImageOpEvent>({
@required BuildContext context, @required BuildContext context,
@required List<ImageEntry> selection, @required List<ImageEntry> selection,
@required Stream<ImageOpEvent> opStream, @required Stream<T> opStream,
@required void Function(Set<ImageOpEvent> processed) onDone, @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( _opReportOverlayEntry = OverlayEntry(
builder: (context) { builder: (context) {
return StreamBuilder<ImageOpEvent>( return StreamBuilder<T>(
stream: opStream, stream: opStream,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
@ -107,9 +188,7 @@ class SelectionActionDelegate with PermissionAwareMixin {
} }
Widget child = const SizedBox.shrink(); Widget child = const SizedBox.shrink();
if (snapshot.hasError || snapshot.connectionState == ConnectionState.done) { if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
_hideOpReportOverlay().then((_) => onDone(processed));
} else if (snapshot.connectionState == ConnectionState.active) {
final percent = processed.length.toDouble() / selection.length; final percent = processed.length.toDouble() / selection.length;
child = CircularPercentIndicator( child = CircularPercentIndicator(
percent: percent, percent: percent,

View file

@ -25,6 +25,7 @@ class AIcons {
// actions // actions
static const IconData clear = OMIcons.clear; static const IconData clear = OMIcons.clear;
static const IconData collapse = OMIcons.expandLess; static const IconData collapse = OMIcons.expandLess;
static const IconData createAlbum = OMIcons.addCircleOutline;
static const IconData debug = OMIcons.whatshot; static const IconData debug = OMIcons.whatshot;
static const IconData delete = OMIcons.delete; static const IconData delete = OMIcons.delete;
static const IconData expand = OMIcons.expandMore; static const IconData expand = OMIcons.expandMore;

View file

@ -49,7 +49,7 @@ class DebugPageState extends State<DebugPage> {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Debug'), title: const Text('Debug'),
bottom: TabBar( bottom: const TabBar(
tabs: [ tabs: [
Tab(icon: Icon(OMIcons.whatshot)), Tab(icon: Icon(OMIcons.whatshot)),
Tab(icon: Icon(OMIcons.settings)), 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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class FilterGridPage extends StatelessWidget { class FilterNavigationPage extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
final String title; final String title;
final Map<String, ImageEntry> filterEntries; final Map<String, ImageEntry> filterEntries;
final CollectionFilter Function(String key) filterBuilder; final CollectionFilter Function(String key) filterBuilder;
const FilterGridPage({ const FilterNavigationPage({
@required this.source, @required this.source,
@required this.title, @required this.title,
@required this.filterEntries, @required this.filterEntries,
@required this.filterBuilder, @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(); List<String> get filterKeys => filterEntries.keys.toList();
@override @override
@ -34,10 +75,7 @@ class FilterGridPage extends StatelessWidget {
body: SafeArea( body: SafeArea(
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( appBar,
title: Text(title),
floating: true,
),
SliverPadding( SliverPadding(
padding: EdgeInsets.all(AvesFilterChip.buttonBorderWidth), padding: EdgeInsets.all(AvesFilterChip.buttonBorderWidth),
sliver: SliverGrid( sliver: SliverGrid(
@ -62,18 +100,7 @@ class FilterGridPage extends StatelessWidget {
filter: filterBuilder(key), filter: filterBuilder(key),
showGenericIcon: false, showGenericIcon: false,
decoration: decoration, decoration: decoration,
onPressed: (filter) => Navigator.pushAndRemoveUntil( onPressed: onPressed,
context,
MaterialPageRoute(
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
(route) => false,
),
); );
}, },
childCount: filterKeys.length, childCount: filterKeys.length,

View file

@ -1,6 +1,9 @@
import 'dart:collection';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.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:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -17,24 +20,45 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Future<DateMetadata> _dbDateLoader; Future<DateMetadata> _dbDateLoader;
Future<CatalogMetadata> _dbMetadataLoader; Future<CatalogMetadata> _dbMetadataLoader;
Future<AddressDetails> _dbAddressLoader; Future<AddressDetails> _dbAddressLoader;
Future<Map> _contentResolverMetadataLoader;
int get contentId => widget.entry.contentId; int get contentId => widget.entry.contentId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_startDbReport(); _initFutures();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final catalog = widget.entry.catalogMetadata; return DefaultTabController(
return Scaffold( length: 2,
child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Debug'), title: const Text('Debug'),
bottom: const TabBar(
tabs: [
Tab(text: 'DB'),
Tab(text: 'Content Resolver'),
],
),
), ),
body: SafeArea( body: SafeArea(
child: ListView( child: TabBarView(
children: [
_buildDbTabView(),
_buildContentResolverTabView(),
],
),
),
),
);
}
Widget _buildDbTabView() {
final catalog = widget.entry.catalogMetadata;
return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
FutureBuilder( FutureBuilder(
@ -117,15 +141,31 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'xmpTitleDescription': '${catalog.xmpTitleDescription}', 'xmpTitleDescription': '${catalog.xmpTitleDescription}',
}), }),
], ],
),
),
); );
} }
void _startDbReport() { 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)); _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)); _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)); _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(widget.entry);
setState(() {}); setState(() {});
} }
} }