debug: content resolver metadata, copy/move WIP
This commit is contained in:
parent
cdf435420f
commit
1cd333d419
19 changed files with 613 additions and 176 deletions
|
@ -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'
|
||||||
|
|
|
@ -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
|
Map<String, Object> result = new HashMap<String, Object>() {{
|
||||||
public void onFailure() {
|
put("uri", uriString);
|
||||||
Map<String, Object> result = new HashMap<String, Object>() {{
|
}};
|
||||||
put("uri", uriString);
|
try {
|
||||||
put("success", false);
|
provider.delete(activity, path, uri).get();
|
||||||
}};
|
result.put("success", true);
|
||||||
success(result);
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
}
|
Log.w(LOG_TAG, "failed to delete entry with path=" + path, e);
|
||||||
});
|
result.put("success", false);
|
||||||
|
}
|
||||||
|
success(result);
|
||||||
|
|
||||||
}
|
}
|
||||||
endOfStream();
|
endOfStream();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
@Override
|
||||||
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) {
|
||||||
// check write access permission to SD card
|
SettableFuture<Object> future = SettableFuture.create();
|
||||||
// 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.
|
|
||||||
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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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)),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,115 +20,152 @@ 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,
|
||||||
appBar: AppBar(
|
child: Scaffold(
|
||||||
title: const Text('Debug'),
|
appBar: AppBar(
|
||||||
),
|
title: const Text('Debug'),
|
||||||
body: SafeArea(
|
bottom: const TabBar(
|
||||||
child: ListView(
|
tabs: [
|
||||||
padding: const EdgeInsets.all(16),
|
Tab(text: 'DB'),
|
||||||
children: [
|
Tab(text: 'Content Resolver'),
|
||||||
FutureBuilder(
|
],
|
||||||
future: _dbDateLoader,
|
),
|
||||||
builder: (context, AsyncSnapshot<DateMetadata> snapshot) {
|
),
|
||||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
body: SafeArea(
|
||||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
child: TabBarView(
|
||||||
final data = snapshot.data;
|
children: [
|
||||||
return Column(
|
_buildDbTabView(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
_buildContentResolverTabView(),
|
||||||
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}',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
_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(() {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue