Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-10-16 23:12:23 +09:00
commit 915cfc60fc
92 changed files with 2039 additions and 1922 deletions

View file

@ -8,7 +8,7 @@
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/1-S10-collection.jpg" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/2-S10-image.jpg" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/5-S10-stats.jpg" alt='Stats screenshot' height="400" />
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/1-S10-collection.png" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/2-S10-image.png" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/5-S10-stats.png" alt='Stats screenshot' height="400" />
## Features

View file

@ -109,10 +109,10 @@ dependencies {
// enable support for Java 8 language APIs (stream, optional, etc.)
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
implementation 'androidx.core:core:1.5.0-alpha03' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
implementation 'androidx.core:core:1.5.0-alpha04' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
implementation "androidx.exifinterface:exifinterface:1.3.0"
implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.14.0'
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
implementation 'com.github.bumptech.glide:glide:4.11.0'
implementation 'com.google.guava:guava:29.0-android'

View file

@ -18,10 +18,9 @@ import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.Key;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey;
import java.io.ByteArrayOutputStream;
import java.io.File;
@ -34,12 +33,12 @@ import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import deckers.thibault.aves.utils.Utils;
import deckers.thibault.aves.utils.LogUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
private static final String LOG_TAG = Utils.createLogTag(AppAdapterHandler.class);
private static final String LOG_TAG = LogUtils.createTag(AppAdapterHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/app";
@ -173,25 +172,21 @@ public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
.path(String.valueOf(iconResourceId))
.build();
// add signature to ignore cache for images which got modified but kept the same URI
Key signature = new ObjectKey(packageName + size);
RequestOptions options = new RequestOptions()
.signature(signature)
.format(DecodeFormat.PREFER_RGB_565)
.centerCrop()
.override(size, size);
FutureTarget<Bitmap> target = Glide.with(context)
.asBitmap()
.apply(options)
.centerCrop()
.load(uri)
.signature(signature)
.submit(size, size);
try {
Bitmap bmp = target.get();
if (bmp != null) {
Bitmap bitmap = target.get();
if (bitmap != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
bmp.compress(Bitmap.CompressFormat.PNG, 100, stream);
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream);
data = stream.toByteArray();
}
} catch (Exception e) {

View file

@ -12,13 +12,11 @@ import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import java.util.List;
import deckers.thibault.aves.MainActivity;
import deckers.thibault.aves.R;
import deckers.thibault.aves.utils.BitmapUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
@ -57,12 +55,15 @@ public class AppShortcutHandler implements MethodChannel.MethodCallHandler {
return;
}
IconCompat icon;
IconCompat icon = null;
if (iconBytes != null && iconBytes.length > 0) {
Bitmap bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length);
bitmap = TransformationUtils.centerCrop(new LruBitmapPool(2 << 24), bitmap, 256, 256);
icon = IconCompat.createWithBitmap(bitmap);
} else {
bitmap = BitmapUtils.centerSquareCrop(context, bitmap, 256);
if (bitmap != null) {
icon = IconCompat.createWithBitmap(bitmap);
}
}
if (icon == null) {
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection);
}

View file

@ -5,19 +5,20 @@ import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.provider.MediaStore;
import android.util.Log;
import android.util.Size;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.Key;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey;
@ -27,21 +28,28 @@ import java.io.IOException;
import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.BitmapUtils;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodChannel;
public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, ImageDecodeTask.Result> {
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class);
private static final String LOG_TAG = LogUtils.createTag(ImageDecodeTask.class);
static class Params {
AvesImageEntry entry;
Integer width, height, defaultSize;
Uri uri;
String mimeType;
Long dateModifiedSecs;
Integer rotationDegrees, width, height, defaultSize;
Boolean isFlipped;
MethodChannel.Result result;
Params(AvesImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
this.entry = entry;
Params(@NonNull String uri, @NonNull String mimeType, @NonNull Long dateModifiedSecs, @NonNull Integer rotationDegrees, @NonNull Boolean isFlipped, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
this.uri = Uri.parse(uri);
this.mimeType = mimeType;
this.dateModifiedSecs = dateModifiedSecs;
this.rotationDegrees = rotationDegrees;
this.isFlipped = isFlipped;
this.width = width;
this.height = height;
this.result = result;
@ -80,14 +88,19 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
if (w == null || h == null || w == 0 || h == 0) {
p.width = p.defaultSize;
p.height = p.defaultSize;
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bitmap = getThumbnailBytesByResolver(p);
} else {
bitmap = getThumbnailBytesByMediaStore(p);
// EXIF orientations with flipping are not well supported by the Media Store:
// the content resolver may return a thumbnail that is automatically rotated
// according to EXIF orientation, but not flip it when necessary
if (!p.isFlipped) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bitmap = getThumbnailBytesByResolver(p);
} else {
bitmap = getThumbnailBytesByMediaStore(p);
}
} catch (Exception e) {
exception = e;
}
} catch (Exception e) {
exception = e;
}
}
@ -100,7 +113,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
exception = e;
}
} else {
Log.d(LOG_TAG, "getThumbnail with uri=" + p.entry.uri + " cancelled");
Log.d(LOG_TAG, "getThumbnail with uri=" + p.uri + " cancelled");
}
byte[] data = null;
@ -124,70 +137,66 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
@RequiresApi(api = Build.VERSION_CODES.Q)
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
AvesImageEntry entry = params.entry;
Integer width = params.width;
Integer height = params.height;
ContentResolver resolver = activity.getContentResolver();
Bitmap bitmap = resolver.loadThumbnail(entry.uri, new Size(width, height), null);
String mimeType = entry.mimeType;
Bitmap bitmap = resolver.loadThumbnail(params.uri, new Size(params.width, params.height), null);
String mimeType = params.mimeType;
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
}
return bitmap;
}
private Bitmap getThumbnailBytesByMediaStore(Params params) {
AvesImageEntry entry = params.entry;
long contentId = ContentUris.parseId(entry.uri);
long contentId = ContentUris.parseId(params.uri);
ContentResolver resolver = activity.getContentResolver();
if (entry.isVideo()) {
if (MimeTypes.isVideo(params.mimeType)) {
return MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null);
} else {
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null);
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
}
return bitmap;
}
}
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
AvesImageEntry entry = params.entry;
Uri uri = params.uri;
String mimeType = params.mimeType;
Long dateModifiedSecs = params.dateModifiedSecs;
Integer rotationDegrees = params.rotationDegrees;
Boolean isFlipped = params.isFlipped;
int width = params.width;
int height = params.height;
// add signature to ignore cache for images which got modified but kept the same URI
Key signature = new ObjectKey("" + entry.dateModifiedSecs + entry.width + entry.rotationDegrees);
RequestOptions options = new RequestOptions()
.signature(signature)
.format(DecodeFormat.PREFER_RGB_565)
// add signature to ignore cache for images which got modified but kept the same URI
.signature(new ObjectKey("" + dateModifiedSecs + rotationDegrees + isFlipped + width))
.override(width, height);
FutureTarget<Bitmap> target;
if (entry.isVideo()) {
if (MimeTypes.isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(new VideoThumbnail(activity, entry.uri))
.signature(signature)
.load(new VideoThumbnail(activity, uri))
.submit(width, height);
} else {
target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(entry.uri)
.signature(signature)
.load(uri)
.submit(width, height);
}
try {
Bitmap bitmap = target.get();
String mimeType = entry.mimeType;
if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
}
return bitmap;
} finally {
@ -195,24 +204,15 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
}
}
private Bitmap rotateBitmap(Bitmap bitmap, Integer rotationDegrees) {
if (bitmap != null && rotationDegrees != null) {
// TODO TLAD use exif orientation to rotate & flip?
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
}
return bitmap;
}
@Override
protected void onPostExecute(Result result) {
Params params = result.params;
MethodChannel.Result r = params.result;
AvesImageEntry entry = params.entry;
String uri = entry.uri.toString();
String uri = params.uri.toString();
if (result.data != null) {
r.success(result.data);
} else {
r.error("getThumbnail-null", "failed to get thumbnail for uri=" + uri + ", path=" + entry.path, result.errorDetails);
r.error("getThumbnail-null", "failed to get thumbnail for uri=" + uri, result.errorDetails);
}
}
}

View file

@ -12,7 +12,7 @@ import com.bumptech.glide.Glide;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.ExifOrientationOp;
import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
@ -58,6 +58,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "rotate":
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
break;
case "flip":
new Thread(() -> flip(call, new MethodResultWrapper(result))).start();
break;
default:
result.notImplemented();
break;
@ -65,12 +68,16 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map<String, Object> entryMap = call.argument("entry");
String uri = call.argument("uri");
String mimeType = call.argument("mimeType");
Number dateModifiedSecs = (Number)call.argument("dateModifiedSecs");
Integer rotationDegrees = call.argument("rotationDegrees");
Boolean isFlipped = call.argument("isFlipped");
Double widthDip = call.argument("widthDip");
Double heightDip = call.argument("heightDip");
Double defaultSizeDip = call.argument("defaultSizeDip");
if (entryMap == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
result.error("getThumbnail-args", "failed because of missing arguments", null);
return;
}
@ -80,8 +87,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
int height = (int) Math.round(heightDip * density);
int defaultSize = (int) Math.round(defaultSizeDip * density);
AvesImageEntry entry = new AvesImageEntry(entryMap);
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result));
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(uri, mimeType, dateModifiedSecs.longValue(), rotationDegrees, isFlipped, width, height, defaultSize, result));
}
private void getObsoleteEntries(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
@ -167,7 +173,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
result.error("rotate-provider", "failed to find provider for uri=" + uri, null);
return;
}
provider.rotate(activity, path, uri, mimeType, clockwise, new ImageProvider.ImageOpCallback() {
ExifOrientationOp op = clockwise ? ExifOrientationOp.ROTATE_CW : ExifOrientationOp.ROTATE_CCW;
provider.changeOrientation(activity, path, uri, mimeType, op, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
@ -179,4 +186,32 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
});
}
private void flip(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map<String, Object> entryMap = call.argument("entry");
if (entryMap == null) {
result.error("flip-args", "failed because of missing arguments", null);
return;
}
Uri uri = Uri.parse((String) entryMap.get("uri"));
String path = (String) entryMap.get("path");
String mimeType = (String) entryMap.get("mimeType");
ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) {
result.error("flip-provider", "failed to find provider for uri=" + uri, null);
return;
}
provider.changeOrientation(activity, path, uri, mimeType, ExifOrientationOp.FLIP, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
}
@Override
public void onFailure(Throwable throwable) {
new Handler(Looper.getMainLooper()).post(() -> result.error("flip-failure", "failed to flip", throwable.getMessage()));
}
});
}
}

View file

@ -8,8 +8,8 @@ import android.os.Handler;
import android.os.Looper;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
@ -19,6 +19,7 @@ import java.io.InputStream;
import java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.utils.BitmapUtils;
import deckers.thibault.aves.utils.MimeTypes;
import io.flutter.plugin.common.EventChannel;
@ -29,6 +30,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private Uri uri;
private String mimeType;
private int rotationDegrees;
private boolean isFlipped;
private EventChannel.EventSink eventSink;
private Handler handler;
@ -40,6 +42,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri"));
this.rotationDegrees = (int) argMap.get("rotationDegrees");
this.isFlipped = (boolean) argMap.get("isFlipped");
}
}
@ -71,9 +74,13 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private void getImage() {
// request a fresh image with the highest quality format
RequestOptions options = new RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true);
if (MimeTypes.isVideo(mimeType)) {
RequestOptions options = new RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap()
.apply(options)
@ -95,21 +102,27 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally {
Glide.with(activity).clear(target);
}
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees)) {
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// we convert the image on platform side first, when Dart Image.memory does not support it
FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(uri)
.submit();
try {
Bitmap bitmap = target.get();
if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
}
if (bitmap != null) {
// TODO TLAD use exif orientation to rotate & flip?
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
// Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
if (MimeTypes.canHaveAlpha(mimeType)) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
}
success(stream.toByteArray());
} else {
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);

View file

@ -17,11 +17,11 @@ import java.util.stream.Collectors;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.utils.Utils;
import deckers.thibault.aves.utils.LogUtils;
import io.flutter.plugin.common.EventChannel;
public class ImageOpStreamHandler implements EventChannel.StreamHandler {
private static final String LOG_TAG = Utils.createLogTag(ImageOpStreamHandler.class);
private static final String LOG_TAG = LogUtils.createTag(ImageOpStreamHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/imageopstream";

View file

@ -1,20 +0,0 @@
package deckers.thibault.aves.channel.streams;
import io.flutter.plugin.common.EventChannel;
public class IntentStreamHandler implements EventChannel.StreamHandler {
private EventChannel.EventSink eventSink;
@Override
public void onListen(Object args, EventChannel.EventSink eventSink) {
this.eventSink = eventSink;
}
@Override
public void onCancel(Object arguments) {
}
public void notifyNewIntent() {
eventSink.success(true);
}
}

View file

@ -1,53 +0,0 @@
package deckers.thibault.aves.channel.streams;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import java.util.Map;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
import io.flutter.plugin.common.EventChannel;
public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/mediastorestream";
private Context context;
private EventChannel.EventSink eventSink;
private Handler handler;
private Map<Integer, Integer> knownEntries;
@SuppressWarnings("unchecked")
public MediaStoreStreamHandler(Context context, Object arguments) {
this.context = context;
if (arguments instanceof Map) {
Map<String, Object> argMap = (Map<String, Object>) arguments;
this.knownEntries = (Map<Integer, Integer>) argMap.get("knownEntries");
}
}
@Override
public void onListen(Object args, EventChannel.EventSink eventSink) {
this.eventSink = eventSink;
this.handler = new Handler(Looper.getMainLooper());
new Thread(this::fetchAll).start();
}
@Override
public void onCancel(Object args) {
// nothing
}
private void success(final Map<String, Object> result) {
handler.post(() -> eventSink.success(result));
}
private void endOfStream() {
handler.post(() -> eventSink.endOfStream());
}
void fetchAll() {
new MediaStoreImageProvider().fetchAll(context, knownEntries, this::success); // 350ms
endOfStream();
}
}

View file

@ -1,52 +0,0 @@
package deckers.thibault.aves.channel.streams;
import android.app.Activity;
import android.os.Handler;
import android.os.Looper;
import java.util.Map;
import deckers.thibault.aves.utils.PermissionManager;
import io.flutter.plugin.common.EventChannel;
// starting activity to give access with the native dialog
// breaks the regular `MethodChannel` so we use a stream channel instead
public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/storageaccessstream";
private Activity activity;
private EventChannel.EventSink eventSink;
private Handler handler;
private String path;
@SuppressWarnings("unchecked")
public StorageAccessStreamHandler(Activity activity, Object arguments) {
this.activity = activity;
if (arguments instanceof Map) {
Map<String, Object> argMap = (Map<String, Object>) arguments;
this.path = (String) argMap.get("path");
}
}
@Override
public void onListen(Object args, EventChannel.EventSink eventSink) {
this.eventSink = eventSink;
this.handler = new Handler(Looper.getMainLooper());
Runnable onGranted = () -> success(true); // user gave access to a directory, with no guarantee that it matches the specified `path`
Runnable onDenied = () -> success(false); // user cancelled
PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied);
}
@Override
public void onCancel(Object o) {
}
private void success(final boolean result) {
handler.post(() -> eventSink.success(result));
endOfStream();
}
private void endOfStream() {
handler.post(() -> eventSink.endOfStream());
}
}

View file

@ -9,19 +9,13 @@ import com.bumptech.glide.Glide;
import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser;
import com.bumptech.glide.module.AppGlideModule;
import com.bumptech.glide.request.RequestOptions;
import org.jetbrains.annotations.NotNull;
@GlideModule
public class AvesAppGlideModule extends AppGlideModule {
@Override
public void applyOptions(@NotNull Context context, @NonNull GlideBuilder builder) {
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
// hide noisy warning (e.g. for images that can't be decoded)
builder.setLogLevel(Log.ERROR);
}

View file

@ -1,18 +1,44 @@
package deckers.thibault.aves.model.provider;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.MediaStore;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
import deckers.thibault.aves.model.SourceImageEntry;
class ContentImageProvider extends ImageProvider {
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
SourceImageEntry entry = new SourceImageEntry(uri, mimeType).fillPreCatalogMetadata(context);
Map<String, Object> map = new HashMap<>();
map.put("uri", uri.toString());
map.put("sourceMimeType", mimeType);
if (entry.getHasSize() || entry.isSvg()) {
String[] projection = {
MediaStore.MediaColumns.SIZE,
MediaStore.MediaColumns.DISPLAY_NAME,
};
try {
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
map.put("sizeBytes", cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)));
map.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)));
}
cursor.close();
}
} catch (Exception e) {
callback.onFailure(e);
return;
}
SourceImageEntry entry = new SourceImageEntry(map).fillPreCatalogMetadata(context);
if (entry.isSized() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure(new Exception("entry has no size"));

View file

@ -8,14 +8,13 @@ import androidx.annotation.NonNull;
import java.io.File;
import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.FileUtils;
class FileImageProvider extends ImageProvider {
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
SourceImageEntry entry = new SourceImageEntry(uri, mimeType);
String path = FileUtils.getPathFromUri(context, uri);
String path = uri.getPath();
if (path != null) {
try {
File file = new File(path);
@ -28,7 +27,7 @@ class FileImageProvider extends ImageProvider {
}
entry.fillPreCatalogMetadata(context);
if (entry.getHasSize() || entry.isSvg()) {
if (entry.isSized() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure(new Exception("entry has no size"));

View file

@ -23,9 +23,10 @@ import java.util.List;
import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.ExifOrientationOp;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
// *** about file access to write/rename/delete
// * primary volume
@ -37,7 +38,7 @@ import deckers.thibault.aves.utils.Utils;
// from 21/Lollipop, use `DocumentFile` (not `File`) after getting permission to the volume root
public abstract class ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
private static final String LOG_TAG = LogUtils.createTag(ImageProvider.class);
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException());
@ -94,7 +95,7 @@ public abstract class ImageProvider {
}
}
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
public void changeOrientation(final Context context, final String path, final Uri uri, final String mimeType, final ExifOrientationOp op, final ImageOpCallback callback) {
if (!canEditExif(mimeType)) {
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
return;
@ -124,7 +125,17 @@ public abstract class ImageProvider {
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL));
}
exif.rotate(clockwise ? 90 : -90);
switch (op) {
case ROTATE_CW:
exif.rotate(90);
break;
case ROTATE_CCW:
exif.rotate(-90);
break;
case FLIP:
exif.flipHorizontally();
break;
}
exif.saveAttributes();
// copy the edited temporary file back to the original
@ -137,26 +148,22 @@ public abstract class ImageProvider {
return;
}
// ContentResolver contentResolver = context.getContentResolver();
// ContentValues values = new ContentValues();
// // from Android Q, media store update needs to be flagged IS_PENDING first
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
// // TODO TLAD catch RecoverableSecurityException
// contentResolver.update(uri, values, null, null);
// values.clear();
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
// }
// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
// values.put(MediaStore.Images.Media.ORIENTATION, rotationDegrees);
// // TODO TLAD catch RecoverableSecurityException
// int updatedRowCount = contentResolver.update(uri, values, null, null);
// if (updatedRowCount > 0) {
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
// } else {
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
// callback.onSuccess(newFields);
// }
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> {
String[] projection = {MediaStore.MediaColumns.DATE_MODIFIED};
try {
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
}
cursor.close();
}
} catch (Exception e) {
callback.onFailure(e);
return;
}
callback.onSuccess(newFields);
});
}
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {

View file

@ -31,12 +31,12 @@ import java.util.stream.Stream;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
public class MediaStoreImageProvider extends ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class);
private static final String LOG_TAG = LogUtils.createTag(MediaStoreImageProvider.class);
private static final String[] BASE_PROJECTION = {
MediaStore.MediaColumns._ID,
@ -437,7 +437,7 @@ public class MediaStoreImageProvider extends ImageProvider {
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
fullPath = destinationDir;
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
volumePath = StorageUtils.getVolumePath(context, destinationDir);
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
}
}

View file

@ -1,218 +0,0 @@
/*
* Copyright (C) 2007-2008 OpenIntents.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* This file was modified by the Flutter authors from the following original file:
* https://raw.githubusercontent.com/iPaulPro/aFileChooser/master/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java
*/
// TLAD: formatted code copied from:
// https://raw.githubusercontent.com/flutter/plugins/master/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java
// do not add code to this file!
package deckers.thibault.aves.utils;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.webkit.MimeTypeMap;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class FileUtils {
// useful (for Download, File, etc.) but slower
// than directly using `MediaStore.MediaColumns.DATA` from the MediaStore query
public static String getPathFromUri(final Context context, final Uri uri) {
String path = getPathFromLocalUri(context, uri);
if (path == null) {
path = getPathFromRemoteUri(context, uri);
}
return path;
}
@SuppressLint("NewApi")
private static String getPathFromLocalUri(final Context context, final Uri uri) {
final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
if ("primary".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/" + split[1];
}
} else if (isDownloadsDocument(uri)) {
final String id = DocumentsContract.getDocumentId(uri);
if (!TextUtils.isEmpty(id)) {
try {
final Uri contentUri =
ContentUris.withAppendedId(
Uri.parse(Environment.DIRECTORY_DOWNLOADS), Long.valueOf(id));
return getDataColumn(context, contentUri, null, null);
} catch (NumberFormatException e) {
return null;
}
}
} else if (isMediaDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
final String[] split = docId.split(":");
final String type = split[0];
Uri contentUri = null;
if ("image".equals(type)) {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
} else if ("video".equals(type)) {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
} else if ("audio".equals(type)) {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}
final String selection = "_id=?";
final String[] selectionArgs = new String[]{split[1]};
return getDataColumn(context, contentUri, selection, selectionArgs);
}
} else if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
// Return the remote address
if (isGooglePhotosUri(uri)) {
return null;
}
return getDataColumn(context, uri, null, null);
} else if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}
return null;
}
private static String getDataColumn(
Context context, Uri uri, String selection, String[] selectionArgs) {
final String column = "_data";
final String[] projection = {column};
try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) {
if (cursor != null && cursor.moveToFirst()) {
final int column_index = cursor.getColumnIndex(column);
//yandex.disk and dropbox do not have _data column
if (column_index == -1) {
return null;
}
return cursor.getString(column_index);
}
}
return null;
}
private static String getPathFromRemoteUri(final Context context, final Uri uri) {
// The code below is why Java now has try-with-resources and the Files utility.
File file = null;
InputStream inputStream = null;
OutputStream outputStream = null;
boolean success = false;
try {
String extension = getImageExtension(context, uri);
inputStream = context.getContentResolver().openInputStream(uri);
file = File.createTempFile("image_picker", extension, context.getCacheDir());
outputStream = new FileOutputStream(file);
if (inputStream != null) {
copy(inputStream, outputStream);
success = true;
}
} catch (IOException ignored) {
} finally {
try {
if (inputStream != null) inputStream.close();
} catch (IOException ignored) {
}
try {
if (outputStream != null) outputStream.close();
} catch (IOException ignored) {
// If closing the output stream fails, we cannot be sure that the
// target file was written in full. Flushing the stream merely moves
// the bytes into the OS, not necessarily to the file.
success = false;
}
}
return success ? file.getPath() : null;
}
/**
* @return extension of image with dot, or default .jpg if it none.
*/
private static String getImageExtension(Context context, Uri uriImage) {
String extension = null;
try (Cursor cursor = context
.getContentResolver()
.query(uriImage, new String[]{MediaStore.MediaColumns.MIME_TYPE}, null, null, null)) {
if (cursor != null && cursor.moveToNext()) {
String mimeType = cursor.getString(0);
extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
}
}
if (extension == null) {
//default extension for matches the previous behavior of the plugin
extension = "jpg";
}
return "." + extension;
}
private static void copy(InputStream in, OutputStream out) throws IOException {
final byte[] buffer = new byte[4 * 1024];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
out.flush();
}
private static boolean isExternalStorageDocument(Uri uri) {
return "com.android.externalstorage.documents".equals(uri.getAuthority());
}
private static boolean isDownloadsDocument(Uri uri) {
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
}
private static boolean isMediaDocument(Uri uri) {
return "com.android.providers.media.documents".equals(uri.getAuthority());
}
private static boolean isGooglePhotosUri(Uri uri) {
return "com.google.android.apps.photos.contentprovider".equals(uri.getAuthority());
}
}

View file

@ -1,180 +0,0 @@
package deckers.thibault.aves.utils;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.UriPermission;
import android.net.Uri;
import android.os.Build;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;
import com.google.common.base.Splitter;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class PermissionManager {
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
public static final int VOLUME_ROOT_PERMISSION_REQUEST_CODE = 1;
// permission request code to pending runnable
private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>();
public static void requestVolumeAccess(@NonNull Activity activity, @NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) {
Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + path);
pendingPermissionMap.put(VOLUME_ROOT_PERMISSION_REQUEST_CODE, new PendingPermissionHandler(path, onGranted, onDenied));
Intent intent = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
StorageManager sm = activity.getSystemService(StorageManager.class);
if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(path));
if (volume != null) {
intent = volume.createOpenDocumentTreeIntent();
}
}
}
// fallback to basic open document tree intent
if (intent == null) {
intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
}
ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null);
}
public static void onPermissionResult(int requestCode, @Nullable Uri treeUri) {
Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", treeUri=" + treeUri);
boolean granted = treeUri != null;
PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode);
if (handler == null) return;
Runnable runnable = granted ? handler.onGranted : handler.onDenied;
if (runnable == null) return;
runnable.run();
}
public static Optional<String> getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) {
return getAccessibleDirs(context).stream().filter(anyPath::startsWith).findFirst();
}
public static List<Map<String, String>> getInaccessibleDirectories(@NonNull Context context, @NonNull List<String> dirPaths) {
Set<String> accessibleDirs = getAccessibleDirs(context);
// find set of inaccessible directories for each volume
Map<String, Set<String>> dirsPerVolume = new HashMap<>();
for (String dirPath : dirPaths) {
if (!dirPath.endsWith(File.separator)) {
dirPath += File.separator;
}
if (accessibleDirs.stream().noneMatch(dirPath::startsWith)) {
// inaccessible dirs
StorageUtils.PathSegments segments = new StorageUtils.PathSegments(context, dirPath);
Set<String> dirSet = dirsPerVolume.getOrDefault(segments.volumePath, new HashSet<>());
if (dirSet != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// request primary directory on volume from Android R
String relativeDir = segments.relativeDir;
if (relativeDir != null) {
Iterator<String> iterator = Splitter.on(File.separatorChar).omitEmptyStrings().split(relativeDir).iterator();
if (iterator.hasNext()) {
// primary dir
dirSet.add(iterator.next());
}
}
} else {
// request volume root until Android Q
dirSet.add("");
}
}
dirsPerVolume.put(segments.volumePath, dirSet);
}
}
// format for easier handling on Flutter
List<Map<String, String>> inaccessibleDirs = new ArrayList<>();
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
for (Map.Entry<String, Set<String>> volumeEntry : dirsPerVolume.entrySet()) {
String volumePath = volumeEntry.getKey();
String volumeDescription = "";
try {
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null) {
volumeDescription = volume.getDescription(context);
}
} catch (IllegalArgumentException e) {
// ignore
}
for (String relativeDir : volumeEntry.getValue()) {
HashMap<String, String> dirMap = new HashMap<>();
dirMap.put("volumePath", volumePath);
dirMap.put("volumeDescription", volumeDescription);
dirMap.put("relativeDir", relativeDir);
inaccessibleDirs.add(dirMap);
}
}
}
Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=" + dirPaths + " -> inaccessibleDirs=" + inaccessibleDirs);
return inaccessibleDirs;
}
public static void revokeDirectoryAccess(Context context, String path) {
Optional<Uri> uri = StorageUtils.convertDirPathToTreeUri(context, path);
if (uri.isPresent()) {
int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
context.getContentResolver().releasePersistableUriPermission(uri.get(), flags);
}
}
// returns paths matching URIs granted by the user
public static Set<String> getGrantedDirs(Context context) {
Set<String> grantedDirs = new HashSet<>();
for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) {
Optional<String> dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.getUri());
dirPath.ifPresent(grantedDirs::add);
}
return grantedDirs;
}
// returns paths accessible to the app (granted by the user or by default)
private static Set<String> getAccessibleDirs(Context context) {
Set<String> accessibleDirs = new HashSet<>(getGrantedDirs(context));
// from Android R, we no longer have access permission by default on primary volume
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
String primaryPath = StorageUtils.getPrimaryVolumePath();
accessibleDirs.add(primaryPath);
}
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=" + accessibleDirs);
return accessibleDirs;
}
static class PendingPermissionHandler {
final String path;
final Runnable onGranted; // user gave access to a directory, with no guarantee that it matches the specified `path`
final Runnable onDenied; // user cancelled
PendingPermissionHandler(@NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) {
this.path = path;
this.onGranted = onGranted;
this.onDenied = onDenied;
}
}
}

View file

@ -1,488 +0,0 @@
package deckers.thibault.aves.utils;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.util.Log;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class StorageUtils {
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
/**
* Volume paths
*/
// volume paths, with trailing "/"
private static String[] mStorageVolumePaths;
// primary volume path, with trailing "/"
private static String mPrimaryVolumePath;
public static String getPrimaryVolumePath() {
if (mPrimaryVolumePath == null) {
mPrimaryVolumePath = findPrimaryVolumePath();
}
return mPrimaryVolumePath;
}
public static String[] getVolumePaths(@NonNull Context context) {
if (mStorageVolumePaths == null) {
mStorageVolumePaths = findVolumePaths(context);
}
return mStorageVolumePaths;
}
public static Optional<String> getVolumePath(@NonNull Context context, @NonNull String anyPath) {
return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst();
}
@Nullable
private static Iterator<String> getPathStepIterator(@NonNull Context context, @NonNull String anyPath, @Nullable String root) {
if (root == null) {
root = getVolumePath(context, anyPath).orElse(null);
if (root == null) return null;
}
String relativePath = null, filename = null;
int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1;
int rootLength = root.length();
if (lastSeparatorIndex > rootLength) {
filename = anyPath.substring(lastSeparatorIndex);
relativePath = anyPath.substring(rootLength, lastSeparatorIndex);
}
if (relativePath == null) return null;
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
.trimResults().omitEmptyStrings().split(relativePath));
if (filename.length() > 0) {
pathSteps.add(filename);
}
return pathSteps.iterator();
}
private static String findPrimaryVolumePath() {
String primaryVolumePath = Environment.getExternalStorageDirectory().getAbsolutePath();
if (!primaryVolumePath.endsWith(File.separator)) {
primaryVolumePath += File.separator;
}
return primaryVolumePath;
}
/**
* Returns all available SD-Cards in the system (include emulated)
* <p/>
* Warning: Hack! Based on Android source code of version 4.3 (API 18)
* Because there is no standard way to get it.
* Edited by hendrawd
*
* @return paths to all available SD-Cards in the system (include emulated)
*/
@SuppressLint("ObsoleteSdkInt")
private static String[] findVolumePaths(Context context) {
// Final set of paths
final Set<String> rv = new HashSet<>();
// Primary emulated SD-CARD
final String rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET");
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
// fix of empty raw emulated storage on marshmallow
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
List<File> files;
boolean validFiles;
do {
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
// (e.g. on API 30 emulator) so we retry until the file system is ready
files = Arrays.asList(context.getExternalFilesDirs(null));
validFiles = !files.contains(null);
if (!validFiles) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Log.e(LOG_TAG, "insomnia", e);
}
}
} while (!validFiles);
for (File file : files) {
String applicationSpecificAbsolutePath = file.getAbsolutePath();
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
rv.add(emulatedRootPath);
}
} else {
// Primary physical SD-CARD (not emulated)
final String rawExternalStorage = System.getenv("EXTERNAL_STORAGE");
// Device has physical external storage; use plain paths.
if (TextUtils.isEmpty(rawExternalStorage)) {
// EXTERNAL_STORAGE undefined; falling back to default.
rv.addAll(Arrays.asList(getPhysicalPaths()));
} else {
rv.add(rawExternalStorage);
}
}
} else {
// Device has emulated storage; external storage paths should have userId burned into them.
final String rawUserId;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
rawUserId = "";
} else {
final String path = Environment.getExternalStorageDirectory().getAbsolutePath();
final String[] folders = path.split(File.separator);
final String lastFolder = folders[folders.length - 1];
boolean isDigit = TextUtils.isDigitsOnly(lastFolder);
rawUserId = isDigit ? lastFolder : "";
}
// /storage/emulated/0[1,2,...]
if (TextUtils.isEmpty(rawUserId)) {
rv.add(rawEmulatedStorageTarget);
} else {
rv.add(rawEmulatedStorageTarget + File.separator + rawUserId);
}
}
// All Secondary SD-CARDs (all exclude primary) separated by ":"
final String rawSecondaryStoragesStr = System.getenv("SECONDARY_STORAGE");
// Add all secondary storages
if (!TextUtils.isEmpty(rawSecondaryStoragesStr)) {
// All Secondary SD-CARDs split into array
final String[] rawSecondaryStorages = rawSecondaryStoragesStr.split(File.pathSeparator);
Collections.addAll(rv, rawSecondaryStorages);
}
String[] paths = rv.toArray(new String[0]);
for (int i = 0; i < paths.length; i++) {
String path = paths[i];
if (!path.endsWith(File.separator)) {
paths[i] = path + File.separator;
}
}
return paths;
}
/**
* @return physicalPaths based on phone model
*/
@SuppressLint("SdCardPath")
private static String[] getPhysicalPaths() {
return new String[]{
"/storage/sdcard0",
"/storage/sdcard1", //Motorola Xoom
"/storage/extsdcard", //Samsung SGS3
"/storage/sdcard0/external_sdcard", //User request
"/mnt/extsdcard",
"/mnt/sdcard/external_sd", //Samsung galaxy family
"/mnt/external_sd",
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
"/removable/microsd", //Asus transformer prime
"/mnt/emmc",
"/storage/external_SD", //LG
"/storage/ext_sd", //HTC One Max
"/storage/removable/sdcard1", //Sony Xperia Z1
"/data/sdext",
"/data/sdext2",
"/data/sdext3",
"/data/sdext4",
"/sdcard1", //Sony Xperia Z
"/sdcard2", //HTC One M8s
"/storage/microsd" //ASUS ZenFone 2
};
}
/**
* Volume tree URIs
*/
private static Optional<String> getVolumeUuidForTreeUri(@NonNull Context context, @NonNull String anyPath) {
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(anyPath));
if (volume != null) {
if (volume.isPrimary()) {
return Optional.of("primary");
}
String uuid = volume.getUuid();
if (uuid != null) {
return Optional.of(uuid.toUpperCase());
}
}
}
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath);
return Optional.empty();
}
private static Optional<String> getVolumePathFromTreeUriUuid(@NonNull Context context, @NonNull String uuid) {
if (uuid.equals("primary")) {
return Optional.of(getPrimaryVolumePath());
}
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
for (String volumePath : StorageUtils.getVolumePaths(context)) {
try {
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) {
return Optional.of(volumePath);
}
} catch (IllegalArgumentException e) {
// ignore
}
}
}
Log.e(LOG_TAG, "failed to find volume path for UUID=" + uuid);
return Optional.empty();
}
// e.g.
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
static Optional<Uri> convertDirPathToTreeUri(@NonNull Context context, @NonNull String dirPath) {
Optional<String> uuid = getVolumeUuidForTreeUri(context, dirPath);
if (uuid.isPresent()) {
String relativeDir = new PathSegments(context, dirPath).relativeDir;
if (relativeDir == null) {
relativeDir = "";
} else if (relativeDir.endsWith(File.separator)) {
relativeDir = relativeDir.substring(0, relativeDir.length() - 1);
}
Uri treeUri = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", uuid.get() + ":" + relativeDir);
return Optional.of(treeUri);
}
Log.e(LOG_TAG, "failed to convert dirPath=" + dirPath + " to tree URI");
return Optional.empty();
}
// e.g.
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
static Optional<String> convertTreeUriToDirPath(@NonNull Context context, @NonNull Uri treeUri) {
String encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length());
Matcher matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded));
if (matcher.find()) {
String uuid = matcher.group(1);
String relativePath = matcher.group(2);
if (uuid != null && relativePath != null) {
Optional<String> volumePath = getVolumePathFromTreeUriUuid(context, uuid);
if (volumePath.isPresent()) {
String dirPath = volumePath.get() + relativePath;
if (!dirPath.endsWith(File.separator)) {
dirPath += File.separator;
}
return Optional.of(dirPath);
}
}
}
Log.e(LOG_TAG, "failed to convert treeUri=" + treeUri + " to path");
return Optional.empty();
}
/**
* Document files
*/
@Nullable
public static DocumentFileCompat getDocumentFile(@NonNull Context context, @NonNull String anyPath, @NonNull Uri mediaUri) {
if (requireAccessPermission(anyPath)) {
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// cleanest API to get it
Uri docUri = MediaStore.getDocumentUri(context, mediaUri);
if (docUri != null) {
return DocumentFileCompat.fromSingleUri(context, docUri);
}
}
// fallback for older APIs
return getVolumePath(context, anyPath)
.flatMap(volumePath -> convertDirPathToTreeUri(context, volumePath)
.flatMap(treeUri -> getDocumentFileFromVolumeTree(context, treeUri, anyPath)))
.orElse(null);
}
// good old `File`
return DocumentFileCompat.fromFile(new File(anyPath));
}
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
// returns null if directory does not exist and could not be created
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Context context, @NonNull String dirPath) {
if (!dirPath.endsWith(File.separator)) {
dirPath += File.separator;
}
if (requireAccessPermission(dirPath)) {
String grantedDir = PermissionManager.getGrantedDirForPath(context, dirPath).orElse(null);
if (grantedDir == null) return null;
Uri rootTreeUri = convertDirPathToTreeUri(context, grantedDir).orElse(null);
if (rootTreeUri == null) return null;
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
if (parentFile == null) return null;
Iterator<String> pathIterator = getPathStepIterator(context, dirPath, grantedDir);
while (pathIterator != null && pathIterator.hasNext()) {
String dirName = pathIterator.next();
DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName);
if (dirFile == null || !dirFile.exists()) {
try {
dirFile = parentFile.createDirectory(dirName);
if (dirFile == null) {
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile);
return null;
}
} catch (FileNotFoundException e) {
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, e);
return null;
}
}
parentFile = dirFile;
}
return parentFile;
} else {
File directory = new File(dirPath);
if (!directory.exists()) {
if (!directory.mkdirs()) {
Log.e(LOG_TAG, "failed to create directories at path=" + dirPath);
return null;
}
}
return DocumentFileCompat.fromFile(directory);
}
}
public static String copyFileToTemp(@NonNull DocumentFileCompat documentFile, @NonNull String path) {
String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(path)).toString());
try {
File temp = File.createTempFile("aves", '.' + extension);
documentFile.copyTo(DocumentFileCompat.fromFile(temp));
temp.deleteOnExit();
return temp.getPath();
} catch (IOException e) {
Log.e(LOG_TAG, "failed to copy file from path=" + path);
}
return null;
}
private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, @NonNull Uri rootTreeUri, @NonNull String anyPath) {
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
if (documentFile == null) {
return Optional.empty();
}
// follow the entry path down the document tree
Iterator<String> pathIterator = getPathStepIterator(context, anyPath, null);
while (pathIterator != null && pathIterator.hasNext()) {
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next());
if (documentFile == null) {
return Optional.empty();
}
}
return Optional.of(documentFile);
}
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
private static DocumentFileCompat findDocumentFileIgnoreCase(DocumentFileCompat documentFile, String displayName) {
for (DocumentFileCompat doc : documentFile.listFiles()) {
if (displayName.equalsIgnoreCase(doc.getName())) {
return doc;
}
}
return null;
}
/**
* Misc
*/
public static boolean requireAccessPermission(@NonNull String anyPath) {
// on Android R, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
return true;
}
boolean onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath());
return !onPrimaryVolume;
}
private static boolean isMediaStoreContentUri(Uri uri) {
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost());
}
public static InputStream openInputStream(@NonNull Context context, @NonNull Uri uri) throws FileNotFoundException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// we get a permission denial if we require original from a provider other than the media store
if (isMediaStoreContentUri(uri)) {
uri = MediaStore.setRequireOriginal(uri);
}
}
return context.getContentResolver().openInputStream(uri);
}
public static MediaMetadataRetriever openMetadataRetriever(@NonNull Context context, @NonNull Uri uri) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// we get a permission denial if we require original from a provider other than the media store
if (isMediaStoreContentUri(uri)) {
uri = MediaStore.setRequireOriginal(uri);
}
}
retriever.setDataSource(context, uri);
} catch (Exception e) {
// unsupported format
return null;
}
return retriever;
}
public static class PathSegments {
String fullPath; // should match "volumePath + relativeDir + filename"
String volumePath; // with trailing "/"
String relativeDir; // with trailing "/"
String filename; // null for directories
PathSegments(@NonNull Context context, @NonNull String fullPath) {
this.fullPath = fullPath;
volumePath = StorageUtils.getVolumePath(context, fullPath).orElse(null);
if (volumePath == null) return;
int lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1;
int volumePathLength = volumePath.length();
if (lastSeparatorIndex > volumePathLength) {
filename = fullPath.substring(lastSeparatorIndex);
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex);
}
}
}
}

View file

@ -12,15 +12,15 @@ import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.Utils
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
private val LOG_TAG = Utils.createLogTag(MainActivity::class.java)
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
}
@ -139,7 +139,7 @@ class MainActivity : FlutterActivity() {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) {
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
val treeUri = data.data
if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null)

View file

@ -1,8 +1,10 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
@ -12,6 +14,7 @@ import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader
import com.drew.imaging.ImageProcessingException
import com.drew.lang.Rational
@ -23,28 +26,36 @@ import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.Metadata.isFlippedForExifCode
import deckers.thibault.aves.utils.Metadata.parseVideoMetadataDate
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDescription
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.utils.MimeTypes.getMimeTypeForExtension
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.Metadata.parseVideoMetadataDate
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.XMP
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.XMP.getSafeLocalizedText
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.*
import kotlin.math.roundToLong
@ -67,7 +78,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null)
return
@ -79,7 +90,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
@ -120,7 +131,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
val allTags = describeAll(exif).toMutableMap()
if (foundXmp) {
@ -167,14 +178,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val extension = call.argument<String>("extension")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension))
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType))
if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
}
@ -183,14 +193,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap)
}
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, extension: String?): Map<String, Any> {
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
@ -200,14 +210,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`)
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
val detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
if (detectedMimeType != null && detectedMimeType != mimeType) {
// file extension is unreliable, but we use it as a tie breaker
val extensionMimeType = extension?.toLowerCase(Locale.ROOT)?.let { getMimeTypeForExtension(it) }
if (extensionMimeType == null || detectedMimeType == extensionMimeType) {
metadataMap[KEY_MIME_TYPE] = detectedMimeType
}
// file extension is unreliable
// in the end, `metadata-extractor` is the most reliable, unless it reports `tiff`
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
if (it != MimeTypes.TIFF) {
metadataMap[KEY_MIME_TYPE] = it
}
}
}
@ -278,7 +285,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
@ -289,7 +296,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
}
val latLong = exif.latLong
if (latLong != null && latLong.size == 2) {
if (latLong?.size == 2) {
metadataMap[KEY_LATITUDE] = latLong[0]
metadataMap[KEY_LONGITUDE] = latLong[1]
}
@ -320,22 +327,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) {
val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
val latitudeString = locationMatcher.group(1)
val longitudeString = locationMatcher.group(2)
if (latitudeString != null && longitudeString != null) {
try {
val latitude = latitudeString.toDoubleOrNull() ?: 0
val longitude = longitudeString.toDoubleOrNull() ?: 0
// keep `0.0` as `0.0`, not `0`
if (latitude != 0.0 || longitude != 0.0) {
metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude
}
} catch (e: NumberFormatException) {
// ignore
}
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (matcher.find() && matcher.groupCount() >= 2) {
// keep `0.0` as `0.0`, not `0`
val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0
val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0
if (latitude != 0.0 || longitude != 0.0) {
metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude
}
}
}
@ -350,7 +349,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
return
@ -362,7 +361,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
return
}
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
@ -382,7 +381,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
result.success(metadataMap)
}
} ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null)
} catch (e: Exception) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
} catch (e: NoClassDefFoundError) {
@ -392,20 +391,27 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
return
}
val id = ContentUris.parseId(uri)
var contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
try {
val id = ContentUris.parseId(uri)
contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
}
} catch (e: NumberFormatException) {
// ignore
}
}
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
@ -436,21 +442,21 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
return
}
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
val metadataMap = HashMap<String, String?>()
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag)
}
result.success(metadataMap)
}
} ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
@ -459,7 +465,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
return
@ -483,7 +489,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
return
@ -505,7 +511,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return
@ -513,9 +519,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val thumbnails = ArrayList<ByteArray>()
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
exif.thumbnailBytes?.let { thumbnails.add(it) }
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
exif.thumbnailBitmap?.let {
val bitmap = TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), it, orientation)
if (bitmap != null) {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
thumbnails.add(stream.toByteArray())
}
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
@ -526,7 +542,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
return
@ -535,7 +551,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val thumbnails = ArrayList<ByteArray>()
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
@ -567,7 +583,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
companion object {
private val LOG_TAG = Utils.createLogTag(MetadataHandler::class.java)
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata"
// catalog metadata

View file

@ -0,0 +1,18 @@
package deckers.thibault.aves.channel.streams
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
class IntentStreamHandler : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink
override fun onListen(arguments: Any?, eventSink: EventSink) {
this.eventSink = eventSink
}
override fun onCancel(arguments: Any?) {}
fun notifyNewIntent() {
eventSink.success(true)
}
}

View file

@ -0,0 +1,46 @@
package deckers.thibault.aves.channel.streams
import android.content.Context
import android.os.Handler
import android.os.Looper
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
private var knownEntries: Map<Int, Int>? = null
init {
if (arguments is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
knownEntries = arguments["knownEntries"] as Map<Int, Int>?
}
}
override fun onListen(arguments: Any?, eventSink: EventSink) {
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
Thread { fetchAll() }.start()
}
override fun onCancel(arguments: Any?) {}
private fun success(result: Map<String, Any>) {
handler.post { eventSink.success(result) }
}
private fun endOfStream() {
handler.post { eventSink.endOfStream() }
}
private fun fetchAll() {
MediaStoreImageProvider().fetchAll(context, knownEntries) { success(it) }
endOfStream()
}
companion object {
const val CHANNEL = "deckers.thibault/aves/mediastorestream"
}
}

View file

@ -0,0 +1,44 @@
package deckers.thibault.aves.channel.streams
import android.app.Activity
import android.os.Handler
import android.os.Looper
import deckers.thibault.aves.utils.PermissionManager.requestVolumeAccess
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
// starting activity to give access with the native dialog
// breaks the regular `MethodChannel` so we use a stream channel instead
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
private var path: String? = null
init {
if (arguments is Map<*, *>) {
path = arguments["path"] as String?
}
}
override fun onListen(arguments: Any?, eventSink: EventSink) {
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
requestVolumeAccess(activity, path!!, { success(true) }, { success(false) })
}
override fun onCancel(arguments: Any?) {}
private fun success(result: Boolean) {
handler.post { eventSink.success(result) }
endOfStream()
}
private fun endOfStream() {
handler.post { eventSink.endOfStream() }
}
companion object {
const val CHANNEL = "deckers.thibault/aves/storageaccessstream"
}
}

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.utils
package deckers.thibault.aves.metadata
import android.util.Log
import androidx.exifinterface.media.ExifInterface
@ -8,12 +8,13 @@ import com.drew.metadata.exif.*
import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
import deckers.thibault.aves.utils.LogUtils
import java.util.*
import kotlin.math.floor
import kotlin.math.roundToLong
object ExifInterfaceHelper {
private val LOG_TAG = Utils.createLogTag(ExifInterfaceHelper::class.java)
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java)
// ExifInterface always states it has the following attributes
// and returns "0" instead of "null" when they are actually missing

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.utils
package deckers.thibault.aves.metadata
import android.content.Context
import android.media.MediaFormat

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.utils
package deckers.thibault.aves.metadata
import androidx.exifinterface.media.ExifInterface
import java.text.ParseException
@ -34,6 +34,16 @@ object Metadata {
else -> false
}
@JvmStatic
fun getExifCode(rotationDegrees: Int, isFlipped: Boolean): Int {
return when (rotationDegrees) {
90 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSVERSE else ExifInterface.ORIENTATION_ROTATE_90
180 -> if (isFlipped) ExifInterface.ORIENTATION_FLIP_VERTICAL else ExifInterface.ORIENTATION_ROTATE_180
270 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSPOSE else ExifInterface.ORIENTATION_ROTATE_270
else -> if (isFlipped) ExifInterface.ORIENTATION_FLIP_HORIZONTAL else ExifInterface.ORIENTATION_NORMAL
}
}
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
@JvmStatic
fun parseVideoMetadataDate(metadataDate: String?): Long {

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.utils
package deckers.thibault.aves.metadata
import com.drew.lang.Rational
import com.drew.metadata.Directory
@ -11,6 +11,10 @@ object MetadataExtractorHelper {
if (this.containsTag(tag)) save(this.getDescription(tag))
}
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
if (this.containsTag(tag)) save(this.getString(tag))
}
fun Directory.getSafeBoolean(tag: Int, save: (value: Boolean) -> Unit) {
if (this.containsTag(tag)) save(this.getBoolean(tag))
}

View file

@ -1,9 +1,13 @@
package deckers.thibault.aves.utils
package deckers.thibault.aves.metadata
import android.util.Log
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
import deckers.thibault.aves.utils.LogUtils
object XMP {
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
@ -15,12 +19,15 @@ object XMP {
private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US"
@Throws(XMPException::class)
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) save(item.value)
try {
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) save(item.value)
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to get text for XMP propName=$propName", e)
}
}
}

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.model
import android.net.Uri
import deckers.thibault.aves.utils.MimeTypes
class AvesImageEntry(map: Map<String?, Any?>) {
@JvmField
@ -21,18 +20,4 @@ class AvesImageEntry(map: Map<String?, Any?>) {
@JvmField
val rotationDegrees = map["rotationDegrees"] as Int
@JvmField
val dateModifiedSecs = toLong(map["dateModifiedSecs"])
val isVideo: Boolean
get() = MimeTypes.isVideo(mimeType)
companion object {
// convenience method
private fun toLong(o: Any?): Long? = when (o) {
is Int -> o.toLong()
else -> o as? Long
}
}
}

View file

@ -0,0 +1,5 @@
package deckers.thibault.aves.model
enum class ExifOrientationOp {
ROTATE_CW, ROTATE_CCW, FLIP
}

View file

@ -14,16 +14,16 @@ import com.drew.metadata.jpeg.JpegDirectory
import com.drew.metadata.mp4.Mp4Directory
import com.drew.metadata.mp4.media.Mp4VideoDirectory
import com.drew.metadata.photoshop.PsdHeaderDirectory
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException
@ -101,15 +101,12 @@ class SourceImageEntry {
return null
}
val hasSize: Boolean
val isSized: Boolean
get() = width ?: 0 > 0 && height ?: 0 > 0
private val hasDuration: Boolean
get() = durationMillis ?: 0 > 0
private val isImage: Boolean
get() = MimeTypes.isImage(sourceMimeType)
private val isVideo: Boolean
get() = MimeTypes.isVideo(sourceMimeType)
@ -123,15 +120,15 @@ class SourceImageEntry {
if (isSvg) return this
if (isVideo) {
fillVideoByMediaMetadataRetriever(context)
if (hasSize && hasDuration) return this
if (isSized && hasDuration) return this
}
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
fillByMetadataExtractor(context)
if (hasSize && foundExif) return this
if (isSized && foundExif) return this
}
if (ExifInterface.isSupportedMimeType(sourceMimeType)) {
fillByExifInterface(context)
if (hasSize) return this
if (isSized) return this
}
fillByBitmapDecode(context)
return this
@ -158,7 +155,7 @@ class SourceImageEntry {
// finds: width, height, orientation, date, duration
private fun fillByMetadataExtractor(context: Context) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
// do not switch on specific mime types, as the reported mime type could be wrong
@ -209,7 +206,7 @@ class SourceImageEntry {
// finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input)
foundExif = true
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
@ -226,7 +223,7 @@ class SourceImageEntry {
// finds: width, height
private fun fillByBitmapDecode(context: Context) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
StorageUtils.openInputStream(context, uri)?.use { input ->
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, options)

View file

@ -0,0 +1,26 @@
package deckers.thibault.aves.utils
import android.content.Context
import android.graphics.Bitmap
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode
object BitmapUtils {
@JvmStatic
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
if (rotationDegrees == 0 && !isFlipped) return bitmap
val exifOrientation = getExifCode(rotationDegrees, isFlipped)
return TransformationUtils.rotateImageExif(getBitmapPool(context), bitmap, exifOrientation)
}
@JvmStatic
fun centerSquareCrop(context: Context, bitmap: Bitmap?, size: Int): Bitmap? {
bitmap ?: return bitmap
return TransformationUtils.centerCrop(getBitmapPool(context), bitmap, size, size)
}
@JvmStatic
fun getBitmapPool(context: Context) = Glide.get(context).bitmapPool
}

View file

@ -2,12 +2,13 @@ package deckers.thibault.aves.utils
import java.util.regex.Pattern
object Utils {
object LogUtils {
private const val LOG_TAG_MAX_LENGTH = 23
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.")
// create an Android logger friendly log tag for the specified class
@JvmStatic
fun createLogTag(clazz: Class<*>): String {
fun createTag(clazz: Class<*>): String {
// shorten class name to "a.b.CccDdd"
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.")
if (logTag.length > LOG_TAG_MAX_LENGTH) {

View file

@ -1,7 +1,5 @@
package deckers.thibault.aves.utils
import java.util.*
object MimeTypes {
private const val IMAGE = "image"
@ -12,44 +10,20 @@ object MimeTypes {
private const val HEIF = "image/heif"
private const val ICO = "image/x-icon"
private const val JPEG = "image/jpeg"
private const val PCX = "image/x-pcx"
private const val PNG = "image/png"
private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop"
private const val TIFF = "image/tiff"
const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp"
// raw raster
private const val ARW = "image/x-sony-arw"
private const val CR2 = "image/x-canon-cr2"
private const val CRW = "image/x-canon-crw"
private const val DCR = "image/x-kodak-dcr"
private const val DNG = "image/x-adobe-dng"
private const val ERF = "image/x-epson-erf"
private const val K25 = "image/x-kodak-k25"
private const val KDC = "image/x-kodak-kdc"
private const val MRW = "image/x-minolta-mrw"
private const val NEF = "image/x-nikon-nef"
private const val NRW = "image/x-nikon-nrw"
private const val ORF = "image/x-olympus-orf"
private const val PEF = "image/x-pentax-pef"
private const val RAF = "image/x-fuji-raf"
private const val RAW = "image/x-panasonic-raw"
private const val RW2 = "image/x-panasonic-rw2"
private const val SR2 = "image/x-sony-sr2"
private const val SRF = "image/x-sony-srf"
private const val SRW = "image/x-samsung-srw"
private const val X3F = "image/x-sigma-x3f"
// vector
const val SVG = "image/svg+xml"
private const val VIDEO = "video"
private const val AVI = "video/avi"
private const val MOV = "video/quicktime"
private const val MP2T = "video/mp2t"
private const val MP4 = "video/mp4"
private const val WEBM = "video/webm"
@JvmStatic
@ -58,11 +32,19 @@ object MimeTypes {
@JvmStatic
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
@JvmStatic
// returns whether the specified MIME type represents
// a raster image format that allows an alpha channel
fun canHaveAlpha(mimeType: String?) = when (mimeType) {
BMP, GIF, ICO, PNG, TIFF, WEBP -> true
else -> false
}
// as of Flutter v1.22.0
@JvmStatic
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?) = when (mimeType) {
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
else -> false
}
@ -75,6 +57,8 @@ object MimeTypes {
// Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide:
// https://github.com/bumptech/glide/blob/master/gradle.properties#L21
@JvmStatic
fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
DNG, HEIC, HEIF, PNG, WEBP -> true
@ -89,50 +73,4 @@ object MimeTypes {
DNG, PNG -> true
else -> false
}
@JvmStatic
fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) {
// generic raster
".bmp" -> BMP
".gif" -> GIF
".heic" -> HEIC
".heif" -> HEIF
".ico" -> ICO
".jpg", ".jpeg", ".jpe" -> JPEG
".pcx" -> PCX
".png" -> PNG
".psd" -> PSD
".tiff", ".tif" -> TIFF
".wbmp" -> WBMP
".webp" -> WEBP
// raw raster
".arw" -> ARW
".cr2" -> CR2
".crw" -> CRW
".dcr" -> DCR
".dng" -> DNG
".erf" -> ERF
".k25" -> K25
".kdc" -> KDC
".mrw" -> MRW
".nef" -> NEF
".nrw" -> NRW
".orf" -> ORF
".pef" -> PEF
".raf" -> RAF
".raw" -> RAW
".rw2" -> RW2
".sr2" -> SR2
".srf" -> SRF
".srw" -> SRW
".x3f" -> X3F
// vector
".svg" -> SVG
// video
".avi" -> AVI
".m2ts" -> MP2T
".mov", ".qt" -> MOV
".mp4", ".m4a", ".m4p", ".m4b", ".m4r", ".m4v" -> MP4
else -> null
}
}

View file

@ -0,0 +1,139 @@
package deckers.thibault.aves.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import deckers.thibault.aves.utils.LogUtils.createTag
import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File
import java.util.*
import java.util.concurrent.ConcurrentHashMap
object PermissionManager {
private val LOG_TAG = createTag(PermissionManager::class.java)
const val VOLUME_ACCESS_REQUEST_CODE = 1
// permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@JvmStatic
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) {
Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path")
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied)
var intent: Intent? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val sm = activity.getSystemService(StorageManager::class.java)
intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent()
}
// fallback to basic open document tree intent
if (intent == null) {
intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
}
ActivityCompat.startActivityForResult(activity, intent, VOLUME_ACCESS_REQUEST_CODE, null)
}
fun onPermissionResult(requestCode: Int, treeUri: Uri?) {
Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, treeUri=$treeUri")
val handler = pendingPermissionMap.remove(requestCode) ?: return
(if (treeUri != null) handler.onGranted else handler.onDenied)()
}
@JvmStatic
fun getGrantedDirForPath(context: Context, anyPath: String): String? {
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
}
@JvmStatic
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
val accessibleDirs = getAccessibleDirs(context)
// find set of inaccessible directories for each volume
val dirsPerVolume = HashMap<String, MutableSet<String>>()
for (dirPath in dirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) {
if (accessibleDirs.none { dirPath.startsWith(it) }) {
// inaccessible dirs
val segments = PathSegments(context, dirPath)
segments.volumePath?.let { volumePath ->
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// request primary directory on volume from Android R
segments.relativeDir?.apply {
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() }
primaryDir?.let { dirSet.add(it) }
}
} else {
// request volume root until Android Q
dirSet.add("")
}
dirsPerVolume[volumePath] = dirSet
}
}
}
// format for easier handling on Flutter
val inaccessibleDirs = ArrayList<Map<String, String>>()
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) {
for ((volumePath, relativeDirs) in dirsPerVolume) {
var volumeDescription: String? = null
try {
volumeDescription = sm.getStorageVolume(File(volumePath))?.getDescription(context)
} catch (e: IllegalArgumentException) {
// ignore
}
for (relativeDir in relativeDirs) {
val dirMap = HashMap<String, String>()
dirMap["volumePath"] = volumePath
dirMap["volumeDescription"] = volumeDescription ?: ""
dirMap["relativeDir"] = relativeDir
inaccessibleDirs.add(dirMap)
}
}
}
Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=$dirPaths -> inaccessibleDirs=$inaccessibleDirs")
return inaccessibleDirs
}
@JvmStatic
fun revokeDirectoryAccess(context: Context, path: String) {
StorageUtils.convertDirPathToTreeUri(context, path)?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.releasePersistableUriPermission(it, flags)
}
}
// returns paths matching URIs granted by the user
@JvmStatic
fun getGrantedDirs(context: Context): Set<String> {
val grantedDirs = HashSet<String>()
for (uriPermission in context.contentResolver.persistedUriPermissions) {
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
dirPath?.let { grantedDirs.add(it) }
}
return grantedDirs
}
// returns paths accessible to the app (granted by the user or by default)
private fun getAccessibleDirs(context: Context): Set<String> {
val accessibleDirs = HashSet(getGrantedDirs(context))
// from Android R, we no longer have access permission by default on primary volume
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
accessibleDirs.add(StorageUtils.primaryVolumePath)
}
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs")
return accessibleDirs
}
// onGranted: user gave access to a directory, with no guarantee that it matches the specified `path`
// onDenied: user cancelled
internal data class PendingPermissionHandler(val path: String, val onGranted: () -> Unit, val onDenied: () -> Unit)
}

View file

@ -0,0 +1,428 @@
package deckers.thibault.aves.utils
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.text.TextUtils
import android.util.Log
import android.webkit.MimeTypeMap
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.utils.LogUtils.createTag
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.util.*
import java.util.regex.Pattern
object StorageUtils {
private val LOG_TAG = createTag(StorageUtils::class.java)
/**
* Volume paths
*/
// volume paths, with trailing "/"
private var mStorageVolumePaths: Array<String>? = null
// primary volume path, with trailing "/"
private var mPrimaryVolumePath: String? = null
val primaryVolumePath: String
get() {
if (mPrimaryVolumePath == null) {
mPrimaryVolumePath = findPrimaryVolumePath()
}
return mPrimaryVolumePath!!
}
@JvmStatic
fun getVolumePaths(context: Context): Array<String> {
if (mStorageVolumePaths == null) {
mStorageVolumePaths = findVolumePaths(context)
}
return mStorageVolumePaths!!
}
@JvmStatic
fun getVolumePath(context: Context, anyPath: String): String? {
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
}
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
var filename: String? = null
var relativePath: String? = null
val lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1
if (lastSeparatorIndex > rootLength) {
filename = anyPath.substring(lastSeparatorIndex)
relativePath = anyPath.substring(rootLength, lastSeparatorIndex)
}
relativePath ?: return null
val pathSteps = relativePath.split(File.separator).filter { it.isNotEmpty() }.toMutableList()
if (filename?.isNotEmpty() == true) {
pathSteps.add(filename)
}
return pathSteps.iterator()
}
private fun findPrimaryVolumePath(): String {
return ensureTrailingSeparator(Environment.getExternalStorageDirectory().absolutePath)
}
@SuppressLint("ObsoleteSdkInt")
private fun findVolumePaths(context: Context): Array<String> {
// Final set of paths
val paths = HashSet<String>()
// Primary emulated SD-CARD
val rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET") ?: ""
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
// fix of empty raw emulated storage on marshmallow
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
lateinit var files: List<File>
var validFiles: Boolean
do {
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
// (e.g. on API 30 emulator) so we retry until the file system is ready
val externalFilesDirs = context.getExternalFilesDirs(null)
validFiles = !externalFilesDirs.contains(null)
if (validFiles) {
files = externalFilesDirs.filterNotNull()
} else {
try {
Thread.sleep(100)
} catch (e: InterruptedException) {
Log.e(LOG_TAG, "insomnia", e)
}
}
} while (!validFiles)
for (file in files) {
val appSpecificAbsolutePath = file.absolutePath
val emulatedRootPath = appSpecificAbsolutePath.substring(0, appSpecificAbsolutePath.indexOf("Android/data"))
paths.add(emulatedRootPath)
}
} else {
// Primary physical SD-CARD (not emulated)
val rawExternalStorage = System.getenv("EXTERNAL_STORAGE") ?: ""
// Device has physical external storage; use plain paths.
if (TextUtils.isEmpty(rawExternalStorage)) {
// EXTERNAL_STORAGE undefined; falling back to default.
paths.addAll(physicalPaths)
} else {
paths.add(rawExternalStorage)
}
}
} else {
// Device has emulated storage; external storage paths should have userId burned into them.
val path = Environment.getExternalStorageDirectory().absolutePath
val rawUserId = path.split(File.separator).lastOrNull()?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
// /storage/emulated/0[1,2,...]
if (TextUtils.isEmpty(rawUserId)) {
paths.add(rawEmulatedStorageTarget)
} else {
paths.add(rawEmulatedStorageTarget + File.separator + rawUserId)
}
}
// All Secondary SD-CARDs (all exclude primary) separated by ":"
System.getenv("SECONDARY_STORAGE")?.let { secondaryStorages ->
paths.addAll(secondaryStorages.split(File.pathSeparator).filter { it.isNotEmpty() })
}
return paths.map { ensureTrailingSeparator(it) }.toTypedArray()
}
// return physicalPaths based on phone model
private val physicalPaths: Array<String>
@SuppressLint("SdCardPath")
get() = arrayOf(
"/storage/sdcard0",
"/storage/sdcard1", //Motorola Xoom
"/storage/extsdcard", //Samsung SGS3
"/storage/sdcard0/external_sdcard", //User request
"/mnt/extsdcard",
"/mnt/sdcard/external_sd", //Samsung galaxy family
"/mnt/external_sd",
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
"/removable/microsd", //Asus transformer prime
"/mnt/emmc",
"/storage/external_SD", //LG
"/storage/ext_sd", //HTC One Max
"/storage/removable/sdcard1", //Sony Xperia Z1
"/data/sdext",
"/data/sdext2",
"/data/sdext3",
"/data/sdext4",
"/sdcard1", //Sony Xperia Z
"/sdcard2", //HTC One M8s
"/storage/microsd" //ASUS ZenFone 2
)
/**
* Volume tree URIs
*/
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) {
val volume = sm.getStorageVolume(File(anyPath))
if (volume != null) {
if (volume.isPrimary) {
return "primary"
}
val uuid = volume.uuid
if (uuid != null) {
return uuid.toUpperCase(Locale.ROOT)
}
}
}
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=$anyPath")
return null
}
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
if (uuid == "primary") {
return primaryVolumePath
}
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) {
for (volumePath in getVolumePaths(context)) {
try {
val volume = sm.getStorageVolume(File(volumePath))
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
return volumePath
}
} catch (e: IllegalArgumentException) {
// ignore
}
}
}
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
return null
}
// e.g.
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
val uuid = getVolumeUuidForTreeUri(context, dirPath)
if (uuid != null) {
var relativeDir = PathSegments(context, dirPath).relativeDir ?: ""
if (relativeDir.endsWith(File.separator)) {
relativeDir = relativeDir.substring(0, relativeDir.length - 1)
}
return DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "$uuid:$relativeDir")
}
Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree URI")
return null
}
// e.g.
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? {
val encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length)
val matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded))
with(matcher) {
if (find()) {
val uuid = group(1)
val relativePath = group(2)
if (uuid != null && relativePath != null) {
val volumePath = getVolumePathFromTreeUriUuid(context, uuid)
if (volumePath != null) {
return ensureTrailingSeparator(volumePath + relativePath)
}
}
}
}
Log.e(LOG_TAG, "failed to convert treeUri=$treeUri to path")
return null
}
/**
* Document files
*/
@JvmStatic
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
if (requireAccessPermission(anyPath)) {
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// cleanest API to get it
val docUri = MediaStore.getDocumentUri(context, mediaUri)
if (docUri != null) {
return DocumentFileCompat.fromSingleUri(context, docUri)
}
}
// fallback for older APIs
return getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
}
// good old `File`
return DocumentFileCompat.fromFile(File(anyPath))
}
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
// returns null if directory does not exist and could not be created
@JvmStatic
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
val cleanDirPath = ensureTrailingSeparator(dirPath)
return if (requireAccessPermission(cleanDirPath)) {
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
val pathIterator = getPathStepIterator(context, cleanDirPath, grantedDir)
while (pathIterator?.hasNext() == true) {
val dirName = pathIterator.next()
var dirFile = findDocumentFileIgnoreCase(parentFile, dirName)
if (dirFile == null || !dirFile.exists()) {
try {
dirFile = parentFile?.createDirectory(dirName)
if (dirFile == null) {
Log.e(LOG_TAG, "failed to create directory with name=$dirName from parent=$parentFile")
return null
}
} catch (e: FileNotFoundException) {
Log.e(LOG_TAG, "failed to create directory with name=$dirName from parent=$parentFile", e)
return null
}
}
parentFile = dirFile
}
parentFile
} else {
val directory = File(cleanDirPath)
if (!directory.exists() && !directory.mkdirs()) {
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
return null
}
DocumentFileCompat.fromFile(directory)
}
}
@JvmStatic
fun copyFileToTemp(documentFile: DocumentFileCompat, path: String): String? {
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString())
try {
val temp = File.createTempFile("aves", ".$extension")
documentFile.copyTo(DocumentFileCompat.fromFile(temp))
temp.deleteOnExit()
return temp.path
} catch (e: IOException) {
Log.e(LOG_TAG, "failed to copy file from path=$path")
}
return null
}
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? {
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
// follow the entry path down the document tree
val pathIterator = getPathStepIterator(context, anyPath, null)
while (pathIterator?.hasNext() == true) {
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next()) ?: return null
}
return documentFile
}
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
private fun findDocumentFileIgnoreCase(documentFile: DocumentFileCompat?, displayName: String?): DocumentFileCompat? {
documentFile ?: return null
for (doc in documentFile.listFiles()) {
if (displayName.equals(doc.name, ignoreCase = true)) {
return doc
}
}
return null
}
/**
* Misc
*/
@JvmStatic
fun requireAccessPermission(anyPath: String): Boolean {
// on Android R, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
return true
}
val onPrimaryVolume = anyPath.startsWith(primaryVolumePath)
return !onPrimaryVolume
}
private fun isMediaStoreContentUri(uri: Uri?): Boolean {
uri ?: return false
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
return ContentResolver.SCHEME_CONTENT.equals(uri.scheme, ignoreCase = true) && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)
}
fun openInputStream(context: Context, uri: Uri): InputStream? {
var effectiveUri = uri
// we get a permission denial if we require original from a provider other than the media store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
effectiveUri = MediaStore.setRequireOriginal(uri)
}
return try {
context.contentResolver.openInputStream(effectiveUri)
} catch (e: FileNotFoundException) {
Log.w(LOG_TAG, "failed to find file at uri=$effectiveUri")
null
}
}
@JvmStatic
fun openMetadataRetriever(context: Context, uri: Uri): MediaMetadataRetriever? {
var effectiveUri = uri
// we get a permission denial if we require original from a provider other than the media store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
effectiveUri = MediaStore.setRequireOriginal(uri)
}
return try {
MediaMetadataRetriever().apply {
setDataSource(context, effectiveUri)
}
} catch (e: Exception) {
// unsupported format
null
}
}
// convenience methods
private fun ensureTrailingSeparator(dirPath: String): String {
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
}
// `fullPath` should match "volumePath + relativeDir + filename"
class PathSegments(context: Context, fullPath: String) {
var volumePath: String? = null // `volumePath` with trailing "/"
var relativeDir: String? = null // `relativeDir` with trailing "/"
var filename: String? = null // null for directories
init {
volumePath = getVolumePath(context, fullPath)
if (volumePath != null) {
val lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1
val volumePathLength = volumePath!!.length
if (lastSeparatorIndex > volumePathLength) {
filename = fullPath.substring(lastSeparatorIndex)
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex)
}
}
}
}
}

View file

@ -8,7 +8,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:3.6.3' // do not upgrade to 4+ until this is fixed: https://github.com/flutter/flutter/issues/58247
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.3'
classpath 'com.google.gms:google-services:4.3.4'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0'
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

View file

@ -65,6 +65,21 @@ class _AvesAppState extends State<AvesApp> {
),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
primary: accentColor,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
primary: accentColor,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
primary: Colors.white,
),
),
);
Widget get firstPage => settings.hasAcceptedTerms ? HomePage() : WelcomePage();

View file

@ -0,0 +1,45 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
class EntryCache {
static Future<void> evict(
String uri,
String mimeType,
int dateModifiedSecs,
int oldRotationDegrees,
bool oldIsFlipped,
) async {
// evict fullscreen image
await UriImage(
uri: uri,
mimeType: mimeType,
rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped,
).evict();
// evict low quality thumbnail (without specified extents)
await ThumbnailProvider(ThumbnailProviderKey(
uri: uri,
mimeType: mimeType,
dateModifiedSecs: dateModifiedSecs,
rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped,
)).evict();
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
final extents = List.generate(6, (index) => pow(2, index + 5).toDouble());
await Future.forEach<double>(
extents,
(extent) => ThumbnailProvider(ThumbnailProviderKey(
uri: uri,
mimeType: mimeType,
dateModifiedSecs: dateModifiedSecs,
rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped,
extent: extent,
)).evict());
}
}

View file

@ -1,7 +1,9 @@
import 'dart:async';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart';
@ -75,8 +77,8 @@ class ImageEntry {
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis,
)
.._catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
.._addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
return copied;
}
@ -154,7 +156,7 @@ class ImageEntry {
// the MIME type reported by the Media Store is unreliable
// so we use the one found during cataloguing if possible
String get mimeType => catalogMetadata?.mimeType ?? sourceMimeType;
String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType;
String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
@ -173,13 +175,11 @@ class ImageEntry {
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
bool get canEdit => path != null;
bool get canPrint => !isVideo;
bool get canRotate => canEdit && canEditExif;
bool get canRotateAndFlip => canEdit && canEditExif;
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.0
@ -194,7 +194,7 @@ class ImageEntry {
}
}
bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.rotationDegrees : rotationDegrees) % 180 == 90;
bool get portrait => rotationDegrees % 180 == 90;
double get displayAspectRatio {
if (width == 0 || height == 0) return 1;
@ -220,13 +220,17 @@ class ImageEntry {
return _bestDate;
}
int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees ?? 0;
set rotationDegrees(int rotationDegrees) {
sourceRotationDegrees = rotationDegrees;
catalogMetadata?.rotationDegrees = rotationDegrees;
_catalogMetadata?.rotationDegrees = rotationDegrees;
}
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) {
@ -276,10 +280,16 @@ class ImageEntry {
}
set catalogMetadata(CatalogMetadata newMetadata) {
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
catalogDateMillis = newMetadata?.dateMillis;
_catalogMetadata = newMetadata;
_bestTitle = null;
metadataChangeNotifier.notifyListeners();
_onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
}
void clearMetadata() {
@ -351,12 +361,7 @@ class ImageEntry {
return false;
}
Future<bool> rename(String newName) async {
if (newName == filenameWithoutExtension) return true;
final newFields = await ImageFileService.rename(this, '$newName$extension');
if (newFields.isEmpty) return false;
Future<void> _applyNewFields(Map newFields) async {
final uri = newFields['uri'];
if (uri is String) this.uri = uri;
final path = newFields['path'];
@ -365,6 +370,24 @@ class ImageEntry {
if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title'];
if (sourceTitle is String) this.sourceTitle = sourceTitle;
final dateModifiedSecs = newFields['dateModifiedSecs'];
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
final rotationDegrees = newFields['rotationDegrees'];
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
final isFlipped = newFields['isFlipped'];
if (isFlipped is bool) this.isFlipped = isFlipped;
await metadataDb.saveEntries({this});
await metadataDb.saveMetadata({catalogMetadata});
}
Future<bool> rename(String newName) async {
if (newName == filenameWithoutExtension) return true;
final newFields = await ImageFileService.rename(this, '$newName$extension');
if (newFields.isEmpty) return false;
await _applyNewFields(newFields);
_bestTitle = null;
metadataChangeNotifier.notifyListeners();
return true;
@ -374,14 +397,23 @@ class ImageEntry {
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false;
final width = newFields['width'];
if (width is int) this.width = width;
final height = newFields['height'];
if (height is int) this.height = height;
final rotationDegrees = newFields['rotationDegrees'];
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(newFields);
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true;
}
imageChangeNotifier.notifyListeners();
Future<bool> flip() async {
final newFields = await ImageFileService.flip(this);
if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(newFields);
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true;
}
@ -399,6 +431,16 @@ class ImageEntry {
return completer.future;
}
// when the entry image itself changed (e.g. after rotation)
void _onImageChanged(int oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notifyListeners();
}
}
// favourites
void toggleFavourite() {
if (isFavourite) {
removeFromFavourites();
@ -419,18 +461,29 @@ class ImageEntry {
}
}
// compare by:
// 1) title ascending
// 2) extension ascending
static int compareByName(ImageEntry a, ImageEntry b) {
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
}
// compare by:
// 1) size descending
// 2) name ascending
static int compareBySize(ImageEntry a, ImageEntry b) {
final c = b.sizeBytes.compareTo(a.sizeBytes);
return c != 0 ? c : compareByName(a, b);
}
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0);
// compare by:
// 1) date descending
// 2) name ascending
static int compareByDate(ImageEntry a, ImageEntry b) {
final c = b.bestDate?.compareTo(a.bestDate) ?? -1;
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
return c != 0 ? c : compareByName(a, b);
}
}

View file

@ -110,7 +110,9 @@ class Settings extends ChangeNotifier {
double get collectionTileExtent => _prefs.getDouble(collectionTileExtentKey) ?? 0;
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue);
// do not notify, as `collectionTileExtent` is only used internally by `TileExtentManager`
// and should not trigger rebuilding by change notification
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue, notify: false);
bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true);
@ -189,7 +191,7 @@ class Settings extends ChangeNotifier {
return _prefs.getStringList(key)?.map((s) => values.firstWhere((el) => el.toString() == s, orElse: () => null))?.where((el) => el != null)?.toList() ?? defaultValue;
}
void setAndNotify(String key, dynamic newValue) {
void setAndNotify(String key, dynamic newValue, {bool notify = true}) {
var oldValue = _prefs.get(key);
if (newValue == null) {
_prefs.remove(key);
@ -209,7 +211,7 @@ class Settings extends ChangeNotifier {
oldValue = _prefs.getBool(key);
_prefs.setBool(key, newValue);
}
if (oldValue != newValue) {
if (oldValue != newValue && notify) {
notifyListeners();
}
}

View file

@ -1,6 +1,6 @@
enum Activity { browse, select }
enum ChipSortFactor { date, name }
enum ChipSortFactor { date, name, count }
enum EntrySortFactor { date, size, name }

View file

@ -30,7 +30,15 @@ class AppShortcutService {
Uint8List iconBytes;
if (iconEntry != null) {
final size = iconEntry.isVideo ? 0.0 : 256.0;
iconBytes = await ImageFileService.getThumbnail(iconEntry, size, size);
iconBytes = await ImageFileService.getThumbnail(
iconEntry.uri,
iconEntry.mimeType,
iconEntry.dateModifiedSecs,
iconEntry.rotationDegrees,
iconEntry.isFlipped,
size,
size,
);
}
try {
await platform.invokeMethod('pin', <String, dynamic>{

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -24,6 +25,7 @@ class ImageFileService {
'width': entry.width,
'height': entry.height,
'rotationDegrees': entry.rotationDegrees,
'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs,
};
}
@ -66,7 +68,14 @@ class ImageFileService {
return null;
}
static Future<Uint8List> getImage(String uri, String mimeType, {int rotationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) {
static Future<Uint8List> getImage(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped, {
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
}) {
try {
final completer = Completer<Uint8List>.sync();
final sink = _OutputBuffer();
@ -75,6 +84,7 @@ class ImageFileService {
'uri': uri,
'mimeType': mimeType,
'rotationDegrees': rotationDegrees ?? 0,
'isFlipped': isFlipped ?? false,
}).listen(
(data) {
final chunk = data as Uint8List;
@ -103,15 +113,29 @@ class ImageFileService {
return Future.sync(() => null);
}
static Future<Uint8List> getThumbnail(ImageEntry entry, double width, double height, {Object taskKey, int priority}) {
if (entry.isSvg) {
static Future<Uint8List> getThumbnail(
String uri,
String mimeType,
int dateModifiedSecs,
int rotationDegrees,
bool isFlipped,
double width,
double height, {
Object taskKey,
int priority,
}) {
if (mimeType == MimeTypes.svg) {
return Future.sync(() => null);
}
return servicePolicy.call(
() async {
try {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'uri': uri,
'mimeType': mimeType,
'dateModifiedSecs': dateModifiedSecs,
'rotationDegrees': rotationDegrees,
'isFlipped': isFlipped,
'widthDip': width,
'heightDip': height,
'defaultSizeDip': thumbnailDefaultSize,
@ -183,7 +207,7 @@ class ImageFileService {
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
try {
// return map with: 'width' 'height' 'rotationDegrees' (all optional)
// return map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise,
@ -194,6 +218,19 @@ class ImageFileService {
}
return {};
}
static Future<Map> flip(ImageEntry entry) async {
try {
// return map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
}
@immutable

View file

@ -43,7 +43,6 @@ class MetadataService {
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'extension': entry.extension,
}) as Map;
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);

View file

@ -19,18 +19,18 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_svg/flutter_svg.dart';
class DebugPage extends StatefulWidget {
class AppDebugPage extends StatefulWidget {
static const routeName = '/debug';
final CollectionSource source;
const DebugPage({this.source});
const AppDebugPage({this.source});
@override
State<StatefulWidget> createState() => DebugPageState();
State<StatefulWidget> createState() => AppDebugPageState();
}
class DebugPageState extends State<DebugPage> {
class AppDebugPageState extends State<AppDebugPage> {
Future<int> _dbFileSizeLoader;
Future<List<ImageEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader;
@ -103,7 +103,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('Crashlytics'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: FirebaseCrashlytics.instance.crash,
child: Text('Crash'),
),
@ -123,7 +123,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () {
imageCache.clear();
setState(() {});
@ -138,7 +138,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () {
PictureProvider.clearCache();
setState(() {});
@ -153,7 +153,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('Glide disk cache: ?'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
child: Text('Clear'),
),
@ -171,7 +171,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
child: Text('Reset'),
),
@ -190,7 +190,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('DB entry rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
@ -209,7 +209,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('DB date rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
child: Text('Clear'),
),
@ -228,7 +228,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('DB metadata rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
child: Text('Clear'),
),
@ -247,7 +247,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('DB address rows: ${snapshot.data.length}'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
child: Text('Clear'),
),
@ -266,7 +266,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () => favourites.clear().then((_) => _startDbReport()),
child: Text('Clear'),
),
@ -288,7 +288,7 @@ class DebugPageState extends State<DebugPage> {
child: Text('Settings'),
),
SizedBox(width: 8),
RaisedButton(
ElevatedButton(
onPressed: () => settings.reset().then((_) => setState(() {})),
child: Text('Reset'),
),

View file

@ -43,28 +43,42 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
@override
void initState() {
super.initState();
_initProvider();
_registerWidget(widget);
}
@override
void didUpdateWidget(ThumbnailRasterImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.entry != entry) {
_pauseProvider();
_initProvider();
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_pauseProvider();
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(ThumbnailRasterImage widget) {
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
_initProvider();
}
void _unregisterWidget(ThumbnailRasterImage widget) {
widget.entry.imageChangeNotifier?.removeListener(_onImageChanged);
_pauseProvider();
}
void _initProvider() {
_fastThumbnailProvider = ThumbnailProvider(entry: entry);
_fastThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry),
);
if (!entry.isVideo) {
_sizedThumbnailProvider = ThumbnailProvider(entry: entry, extent: requestExtent);
_sizedThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry, extent: requestExtent),
);
}
}
@ -139,6 +153,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
if (imageCache.statusForKey(imageProvider).keepAlive) {
@ -153,4 +168,12 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
child: image,
);
}
// when the entry image itself changed (e.g. after rotation)
void _onImageChanged() async {
// rebuild to refresh the thumbnails
_pauseProvider();
_initProvider();
setState(() {});
}
}

View file

@ -48,14 +48,14 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
onSubmitted: (_) => _submit(context),
),
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return FlatButton(
return TextButton(
onPressed: isValid ? () => _submit(context) : null,
child: Text('Add'.toUpperCase()),
);

View file

@ -92,14 +92,14 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
),
],
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return FlatButton(
return TextButton(
onPressed: isValid ? () => _submit(context) : null,
child: Text('Create'.toUpperCase()),
);

View file

@ -8,7 +8,7 @@ import 'package:aves/widgets/common/action_delegates/rename_entry_dialog.dart';
import 'package:aves/widgets/common/aves_dialog.dart';
import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/fullscreen/debug.dart';
import 'package:aves/widgets/fullscreen/fullscreen_debug_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
@ -60,6 +60,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
case EntryAction.rotateCW:
_rotate(context, entry, clockwise: true);
break;
case EntryAction.flip:
_flip(context, entry);
break;
case EntryAction.setAs:
AndroidAppService.setAs(entry.uri, entry.mimeType);
break;
@ -76,12 +79,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
final uri = entry.uri;
final mimeType = entry.mimeType;
final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
final documentName = entry.bestTitle ?? 'Aves';
final doc = pdf.Document(title: documentName);
PdfImage pdfImage;
if (entry.isSvg) {
final bytes = await ImageFileService.getImage(uri, mimeType, rotationDegrees: entry.rotationDegrees);
final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped);
if (bytes != null && bytes.isNotEmpty) {
final svgRoot = await svg.fromSvgBytes(bytes, uri);
final viewBox = svgRoot.viewport.viewBox;
@ -101,6 +105,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
uri: uri,
mimeType: mimeType,
rotationDegrees: rotationDegrees,
isFlipped: isFlipped,
),
);
}
@ -113,6 +118,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
}
}
Future<void> _flip(BuildContext context, ImageEntry entry) async {
if (!await checkStoragePermission(context, [entry])) return;
final success = await entry.flip();
if (!success) showFeedback(context, 'Failed');
}
Future<void> _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async {
if (!await checkStoragePermission(context, [entry])) return;
@ -127,11 +139,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
return AvesDialog(
content: Text('Are you sure?'),
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'.toUpperCase()),
),

View file

@ -28,11 +28,11 @@ mixin PermissionAwareMixin {
title: 'Storage Volume Access',
content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'),
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('OK'.toUpperCase()),
),

View file

@ -54,14 +54,14 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
);
}),
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return FlatButton(
return TextButton(
onPressed: isValid ? () => _submit(context) : null,
child: Text('Apply'.toUpperCase()),
);

View file

@ -48,14 +48,14 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
onSubmitted: (_) => _submit(context),
),
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
builder: (context, isValid, child) {
return FlatButton(
return TextButton(
onPressed: isValid ? () => _submit(context) : null,
child: Text('Apply'.toUpperCase()),
);

View file

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/collection/collection_actions.dart';
@ -14,11 +16,14 @@ import 'package:aves/widgets/common/aves_dialog.dart';
import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final CollectionLens collection;
@ -58,39 +63,49 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
final source = collection.source;
final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source);
final destinationAlbum = await Navigator.push(
context,
MaterialPageRoute<String>(
builder: (context) {
return FilterGridPage(
source: source,
appBar: SliverAppBar(
leading: BackButton(),
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
actions: [
IconButton(
icon: Icon(AIcons.createAlbum),
onPressed: () async {
final newAlbum = await showDialog<String>(
context: context,
builder: (context) => CreateAlbumDialog(),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
Navigator.pop<String>(context, newAlbum);
}
},
tooltip: 'Create album',
return Selector<Settings, ChipSortFactor>(
selector: (context, s) => s.albumSortFactor,
builder: (context, sortFactor, child) {
return FilterGridPage(
source: source,
appBar: SliverAppBar(
leading: BackButton(),
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
actions: [
IconButton(
icon: Icon(AIcons.createAlbum),
onPressed: () async {
final newAlbum = await showDialog<String>(
context: context,
builder: (context) => CreateAlbumDialog(),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
Navigator.pop<String>(context, newAlbum);
}
},
tooltip: 'Create album',
),
IconButton(
icon: Icon(AIcons.sort),
onPressed: () => chipSetActionDelegate.onActionSelected(context, ChipSetAction.sort),
),
],
floating: true,
),
],
floating: true,
),
filterEntries: AlbumListPage.getAlbumEntries(source),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: 'No albums',
),
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
filterEntries: AlbumListPage.getAlbumEntries(source),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: 'No albums',
),
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
);
},
);
},
),
@ -148,11 +163,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
return AvesDialog(
content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'),
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'.toUpperCase()),
),

View file

@ -38,7 +38,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
title: widget.title,
scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(),
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
enum EntryAction {
delete,
edit,
flip,
info,
open,
openMap,
@ -29,8 +30,6 @@ class EntryActions {
EntryAction.share,
EntryAction.delete,
EntryAction.rename,
EntryAction.rotateCCW,
EntryAction.rotateCW,
EntryAction.print,
];
@ -56,9 +55,11 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.rename:
return 'Rename';
case EntryAction.rotateCCW:
return 'Rotate left';
return 'Rotate counterclockwise';
case EntryAction.rotateCW:
return 'Rotate right';
return 'Rotate clockwise';
case EntryAction.flip:
return 'Flip horizontally';
case EntryAction.print:
return 'Print';
case EntryAction.share:
@ -94,6 +95,8 @@ extension ExtraEntryAction on EntryAction {
return AIcons.rotateLeft;
case EntryAction.rotateCW:
return AIcons.rotateRight;
case EntryAction.flip:
return AIcons.flip;
case EntryAction.print:
return AIcons.print;
case EntryAction.share:

View file

@ -11,6 +11,7 @@ class AIcons {
static const IconData allCollection = Icons.collections_outlined;
static const IconData image = Icons.photo_outlined;
static const IconData video = Icons.movie_outlined;
static const IconData audio = Icons.audiotrack_outlined;
static const IconData vector = Icons.code_outlined;
static const IconData android = Icons.android;
@ -34,6 +35,7 @@ class AIcons {
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
static const IconData expand = Icons.expand_more_outlined;
static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite;
static const IconData goUp = Icons.arrow_upward_outlined;

View file

@ -39,11 +39,13 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
try {
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
if (bytes == null) return null;
if (bytes == null) {
throw StateError('$packageName app icon loading failed');
}
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error');
return null;
throw StateError('$packageName app icon decoding failed');
}
}
}

View file

@ -6,93 +6,108 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
ThumbnailProvider({
@required this.entry,
this.extent = 0,
this.scale = 1,
}) : assert(entry != null),
assert(extent != null),
assert(scale != null) {
_cancellationKey = _buildKey(ImageConfiguration.empty);
}
final ThumbnailProviderKey key;
final ImageEntry entry;
final double extent;
final double scale;
Object _cancellationKey;
ThumbnailProvider(this.key) : assert(key != null);
@override
Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) {
// configuration can be empty (e.g. when obtaining key for eviction)
// so we do not compute the target width/height here
// and pass it to the key, to use it later for image loading
return SynchronousFuture<ThumbnailProviderKey>(_buildKey(configuration));
return SynchronousFuture<ThumbnailProviderKey>(key);
}
ThumbnailProviderKey _buildKey(ImageConfiguration configuration) => ThumbnailProviderKey(
entry: entry,
extent: extent,
scale: scale,
);
@override
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: () sync* {
yield ErrorDescription('uri=${entry.uri}, extent=$extent');
yield ErrorDescription('uri=${key.uri}, extent=${key.extent}');
},
);
}
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
var uri = key.uri;
var mimeType = key.mimeType;
try {
final bytes = await ImageFileService.getThumbnail(key.entry, extent, extent, taskKey: _cancellationKey);
if (bytes == null) return null;
final bytes = await ImageFileService.getThumbnail(
uri,
mimeType,
key.dateModifiedSecs,
key.rotationDegrees,
key.isFlipped,
key.extent,
key.extent,
taskKey: key,
);
if (bytes == null) {
throw StateError('$uri ($mimeType) loading failed');
}
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with path=${entry.path}, error=$error');
return null;
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
throw StateError('$mimeType decoding failed');
}
}
@override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeThumbnail(_cancellationKey);
ImageFileService.resumeThumbnail(key);
super.resolveStreamForKey(configuration, stream, key, handleError);
}
void pause() => ImageFileService.cancelThumbnail(_cancellationKey);
void pause() => ImageFileService.cancelThumbnail(key);
}
class ThumbnailProviderKey {
final ImageEntry entry;
final double extent;
final double scale;
final String uri, mimeType;
final int dateModifiedSecs, rotationDegrees;
final bool isFlipped;
final double extent, scale;
// do not access `contentId` via `entry` for hashCode and equality purposes
// as an entry is not constant and its contentId can change
final int contentId;
const ThumbnailProviderKey({
@required this.uri,
@required this.mimeType,
@required this.dateModifiedSecs,
@required this.rotationDegrees,
@required this.isFlipped,
this.extent = 0,
this.scale = 1,
}) : assert(uri != null),
assert(mimeType != null),
assert(dateModifiedSecs != null),
assert(rotationDegrees != null),
assert(isFlipped != null),
assert(extent != null),
assert(scale != null);
ThumbnailProviderKey({
@required this.entry,
@required this.extent,
this.scale,
}) : contentId = entry.contentId;
// do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) {
return ThumbnailProviderKey(
uri: entry.uri,
mimeType: entry.mimeType,
dateModifiedSecs: entry.dateModifiedSecs ?? -1, // can happen in viewer mode
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
extent: extent,
);
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is ThumbnailProviderKey && other.contentId == contentId && other.extent == extent && other.scale == scale;
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.scale == scale;
}
@override
int get hashCode => hashValues(contentId, extent, scale);
int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale);
@override
String toString() {
return 'ThumbnailProviderKey{contentId=$contentId, extent=$extent, scale=$scale}';
return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
}
}

View file

@ -11,6 +11,7 @@ class UriImage extends ImageProvider<UriImage> {
@required this.uri,
@required this.mimeType,
@required this.rotationDegrees,
@required this.isFlipped,
this.expectedContentLength,
this.scale = 1.0,
}) : assert(uri != null),
@ -18,6 +19,7 @@ class UriImage extends ImageProvider<UriImage> {
final String uri, mimeType;
final int rotationDegrees, expectedContentLength;
final bool isFlipped;
final double scale;
@override
@ -46,7 +48,8 @@ class UriImage extends ImageProvider<UriImage> {
final bytes = await ImageFileService.getImage(
uri,
mimeType,
rotationDegrees: rotationDegrees,
rotationDegrees,
isFlipped,
expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent(
@ -55,11 +58,13 @@ class UriImage extends ImageProvider<UriImage> {
));
},
);
if (bytes == null) return null;
if (bytes == null) {
throw StateError('$uri ($mimeType) loading failed');
}
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
return null;
throw StateError('$mimeType decoding failed');
} finally {
unawaited(chunkEvents.close());
}

View file

@ -27,7 +27,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
assert(key == this);
final data = await ImageFileService.getImage(uri, mimeType);
final data = await ImageFileService.getImage(uri, mimeType, 0, false);
if (data == null || data.isEmpty) {
return null;
}

View file

@ -10,9 +10,9 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/app_debug_page.dart';
import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/debug_page.dart';
import 'package:aves/widgets/drawer/collection_tile.dart';
import 'package:aves/widgets/drawer/tile.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
@ -225,7 +225,7 @@ class _AppDrawerState extends State<AppDrawer> {
icon: AIcons.debug,
title: 'Debug',
topLevel: false,
routeName: DebugPage.routeName,
pageBuilder: (_) => DebugPage(source: source),
routeName: AppDebugPage.routeName,
pageBuilder: (_) => AppDebugPage(source: source),
);
}

View file

@ -62,42 +62,56 @@ class AlbumListPage extends StatelessWidget {
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
final entriesByDate = source.sortedEntriesForFilterList;
switch (settings.albumSortFactor) {
case ChipSortFactor.date:
final allAlbumMapEntries = source.sortedAlbums.map((album) => MapEntry(
album,
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
));
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allAlbumMapEntries, (e) => pinned.contains(e.key));
final pinnedMapEntries = (byPin[true] ?? [])..sort(FilterNavigationPage.compareChipsByDate);
final unpinnedMapEntries = (byPin[false] ?? [])..sort(FilterNavigationPage.compareChipsByDate);
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
case ChipSortFactor.name:
default:
final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in source.sortedAlbums) {
if (pinned.contains(album)) {
pinnedAlbums.add(album);
} else {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular:
regularAlbums.add(album);
break;
case AlbumType.app:
appAlbums.add(album);
break;
default:
specialAlbums.add(album);
break;
}
// albums are initially sorted by name at the source level
var sortedAlbums = source.sortedAlbums;
if (settings.albumSortFactor == ChipSortFactor.name) {
final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in sortedAlbums) {
if (pinned.contains(album)) {
pinnedAlbums.add(album);
} else {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular:
regularAlbums.add(album);
break;
case AlbumType.app:
appAlbums.add(album);
break;
default:
specialAlbums.add(album);
break;
}
}
return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) {
return MapEntry(
album,
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
);
}));
}
return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) {
return MapEntry(
album,
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
);
}));
}
if (settings.albumSortFactor == ChipSortFactor.count) {
CollectionFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album));
var filtersWithCount = List.of(sortedAlbums.map((s) => MapEntry(s, source.count(_buildFilter(s)))));
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
sortedAlbums = filtersWithCount.map((kv) => kv.key).toList();
}
final allMapEntries = sortedAlbums.map((album) => MapEntry(
album,
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
));
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
final pinnedMapEntries = (byPin[true] ?? []);
final unpinnedMapEntries = (byPin[false] ?? []);
if (settings.albumSortFactor == ChipSortFactor.date) {
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
}
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
}
}

View file

@ -65,11 +65,11 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
return AvesDialog(
content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'),
actions: [
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'.toUpperCase()),
),

View file

@ -46,6 +46,7 @@ abstract class ChipSetActionDelegate {
options: {
ChipSortFactor.date: 'By date',
ChipSortFactor.name: 'By name',
ChipSortFactor.count: 'By entry count',
},
title: 'Sort',
),

View file

@ -153,6 +153,11 @@ class FilterNavigationPage extends StatelessWidget {
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
}
static int compareChipsByEntryCount(MapEntry<String, num> a, MapEntry<String, num> b) {
final c = b.value.compareTo(a.value) ?? -1;
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
}
}
class FilterGridPage extends StatelessWidget {

View file

@ -39,7 +39,7 @@ class CountryListPage extends StatelessWidget {
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
],
filterEntries: _getCountryEntries(),
filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
filterBuilder: _buildFilter,
emptyBuilder: () => EmptyContent(
icon: AIcons.location,
text: 'No countries',
@ -50,12 +50,22 @@ class CountryListPage extends StatelessWidget {
);
}
CollectionFilter _buildFilter(String location) => LocationFilter(LocationLevel.country, location);
Map<String, ImageEntry> _getCountryEntries() {
final pinned = settings.pinnedFilters.whereType<LocationFilter>().map((f) => f.countryNameAndCode);
final entriesByDate = source.sortedEntriesForFilterList;
// countries are initially sorted by name at the source level
var sortedCountries = source.sortedCountries;
if (settings.countrySortFactor == ChipSortFactor.count) {
var filtersWithCount = List.of(sortedCountries.map((s) => MapEntry(s, source.count(_buildFilter(s)))));
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
sortedCountries = filtersWithCount.map((kv) => kv.key).toList();
}
final locatedEntries = entriesByDate.where((entry) => entry.isLocated);
final allMapEntries = source.sortedCountries.map((countryNameAndCode) {
final allMapEntries = sortedCountries.map((countryNameAndCode) {
final split = countryNameAndCode.split(LocationFilter.locationSeparator);
ImageEntry entry;
if (split.length > 1) {
@ -63,21 +73,16 @@ class CountryListPage extends StatelessWidget {
entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null);
}
return MapEntry(countryNameAndCode, entry);
}).toList();
});
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
final pinnedMapEntries = (byPin[true] ?? []);
final unpinnedMapEntries = (byPin[false] ?? []);
switch (settings.countrySortFactor) {
case ChipSortFactor.date:
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
break;
case ChipSortFactor.name:
// already sorted by name at the source level
break;
if (settings.countrySortFactor == ChipSortFactor.date) {
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
}
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
}
}

View file

@ -39,7 +39,7 @@ class TagListPage extends StatelessWidget {
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
],
filterEntries: _getTagEntries(),
filterBuilder: (s) => TagFilter(s),
filterBuilder: _buildFilter,
emptyBuilder: () => EmptyContent(
icon: AIcons.tag,
text: 'No tags',
@ -50,30 +50,33 @@ class TagListPage extends StatelessWidget {
);
}
CollectionFilter _buildFilter(String tag) => TagFilter(tag);
Map<String, ImageEntry> _getTagEntries() {
final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag);
final entriesByDate = source.sortedEntriesForFilterList;
final allMapEntries = source.sortedTags
.map((tag) => MapEntry(
tag,
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
))
.toList();
// tags are initially sorted by name at the source level
var sortedTags = source.sortedTags;
if (settings.tagSortFactor == ChipSortFactor.count) {
var filtersWithCount = List.of(sortedTags.map((s) => MapEntry(s, source.count(_buildFilter(s)))));
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
sortedTags = filtersWithCount.map((kv) => kv.key).toList();
}
final allMapEntries = sortedTags.map((tag) => MapEntry(
tag,
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
));
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
final pinnedMapEntries = (byPin[true] ?? []);
final unpinnedMapEntries = (byPin[false] ?? []);
switch (settings.tagSortFactor) {
case ChipSortFactor.date:
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
break;
case ChipSortFactor.name:
// already sorted by name at the source level
break;
if (settings.tagSortFactor == ChipSortFactor.date) {
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
}
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
}
}

View file

@ -1,329 +0,0 @@
import 'dart:collection';
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tuple/tuple.dart';
class FullscreenDebugPage extends StatefulWidget {
static const routeName = '/fullscreen/debug';
final ImageEntry entry;
const FullscreenDebugPage({@required this.entry});
@override
_FullscreenDebugPageState createState() => _FullscreenDebugPageState();
}
class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Future<DateMetadata> _dbDateLoader;
Future<CatalogMetadata> _dbMetadataLoader;
Future<AddressDetails> _dbAddressLoader;
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
ImageEntry get entry => widget.entry;
int get contentId => entry.contentId;
@override
void initState() {
super.initState();
_loadDatabase();
_loadMetadata();
}
@override
Widget build(BuildContext context) {
final tabs = <Tuple2<Tab, Widget>>[
Tuple2(Tab(text: 'Entry'), _buildEntryTabView()),
Tuple2(Tab(text: 'DB'), _buildDbTabView()),
Tuple2(Tab(icon: Icon(AIcons.android)), _buildContentResolverTabView()),
Tuple2(Tab(icon: Icon(AIcons.image)), _buildThumbnailsTabView()),
];
return DefaultTabController(
length: tabs.length,
child: Scaffold(
appBar: AppBar(
title: Text('Debug'),
bottom: TabBar(
tabs: tabs.map((t) => t.item1).toList(),
),
),
body: SafeArea(
child: TabBarView(
children: tabs.map((t) => t.item2).toList(),
),
),
),
);
}
Widget _buildEntryTabView() {
String toDateValue(int time, {int factor = 1}) {
var value = '$time';
if (time != null && time > 0) {
value += ' (${DateTime.fromMillisecondsSinceEpoch(time * factor)})';
}
return value;
}
return ListView(
padding: EdgeInsets.all(16),
children: [
InfoRowGroup({
'uri': '${entry.uri}',
'contentId': '${entry.contentId}',
'path': '${entry.path}',
'directory': '${entry.directory}',
'filenameWithoutExtension': '${entry.filenameWithoutExtension}',
'sourceTitle': '${entry.sourceTitle}',
'sourceMimeType': '${entry.sourceMimeType}',
'mimeType': '${entry.mimeType}',
}),
Divider(),
InfoRowGroup({
'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000),
'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis),
'bestDate': '${entry.bestDate}',
}),
Divider(),
InfoRowGroup({
'width': '${entry.width}',
'height': '${entry.height}',
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
'rotationDegrees': '${entry.rotationDegrees}',
'portrait': '${entry.portrait}',
'displayAspectRatio': '${entry.displayAspectRatio}',
'displaySize': '${entry.displaySize}',
}),
Divider(),
InfoRowGroup({
'durationMillis': '${entry.durationMillis}',
'durationText': '${entry.durationText}',
}),
Divider(),
InfoRowGroup({
'sizeBytes': '${entry.sizeBytes}',
'isFavourite': '${entry.isFavourite}',
'isSvg': '${entry.isSvg}',
'isPhoto': '${entry.isPhoto}',
'isVideo': '${entry.isVideo}',
'isCatalogued': '${entry.isCatalogued}',
'isAnimated': '${entry.isAnimated}',
'isFlipped': '${entry.isFlipped}',
'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}',
'canRotate': '${entry.canRotate}',
'xmpSubjects': '${entry.xmpSubjects}',
}),
Divider(),
InfoRowGroup({
'hasGps': '${entry.hasGps}',
'isLocated': '${entry.isLocated}',
'latLng': '${entry.latLng}',
'geoUri': '${entry.geoUri}',
}),
],
);
}
Widget _buildThumbnailsTabView() {
const extent = 128.0;
return ListView(
padding: EdgeInsets.all(16),
children: [
if (entry.isSvg) ...[
Text('SVG ($extent)'),
SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
width: extent,
height: extent,
)
],
if (!entry.isSvg) ...[
Text('Raster (fast)'),
Center(child: Image(image: ThumbnailProvider(entry: entry))),
SizedBox(height: 16),
Text('Raster ($extent)'),
Center(child: Image(image: ThumbnailProvider(entry: entry, extent: extent))),
],
],
);
}
Widget _buildDbTabView() {
return ListView(
padding: EdgeInsets.all(16),
children: [
Row(
children: [
Expanded(
child: Text('DB'),
),
SizedBox(width: 8),
RaisedButton(
onPressed: () async {
await metadataDb.removeIds([entry.contentId]);
_loadDatabase();
},
child: Text('Remove from DB'),
),
],
),
FutureBuilder<DateMetadata>(
future: _dbDateLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return 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}',
}),
],
);
},
),
SizedBox(height: 16),
FutureBuilder<CatalogMetadata>(
future: _dbMetadataLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = snapshot.data;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('DB metadata:${data == null ? ' no row' : ''}'),
if (data != null)
InfoRowGroup({
'mimeType': '${data.mimeType}',
'dateMillis': '${data.dateMillis}',
'isAnimated': '${data.isAnimated}',
'isFlipped': '${data.isFlipped}',
'rotationDegrees': '${data.rotationDegrees}',
'latitude': '${data.latitude}',
'longitude': '${data.longitude}',
'xmpSubjects': '${data.xmpSubjects}',
'xmpTitleDescription': '${data.xmpTitleDescription}',
}),
],
);
},
),
SizedBox(height: 16),
FutureBuilder<AddressDetails>(
future: _dbAddressLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return 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}',
}),
],
);
},
),
],
);
}
// MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
static const millisecondTimestampKeys = ['datetaken', 'datetime'];
Widget _buildContentResolverTabView() {
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
final key = k.toString();
var value = v?.toString() ?? 'null';
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
if (secondTimestampKeys.contains(key)) {
v *= 1000;
}
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
}
if (key == 'xmp' && v != null && v is Uint8List) {
value = String.fromCharCodes(v);
}
return MapEntry(key, value);
}));
return AvesExpansionTile(
title: title,
children: [
Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
data,
maxValueLength: Constants.infoGroupMaxValueLength,
),
)
],
);
}
return ListView(
padding: EdgeInsets.all(8),
children: [
FutureBuilder<Map>(
future: _contentResolverMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
),
FutureBuilder<Map>(
future: _exifInterfaceMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
),
FutureBuilder<Map>(
future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
),
],
);
}
void _loadDatabase() {
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
setState(() {});
}
void _loadMetadata() {
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
setState(() {});
}
}

View file

@ -0,0 +1,159 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
class DbTab extends StatefulWidget {
final ImageEntry entry;
const DbTab({@required this.entry});
@override
_DbTabState createState() => _DbTabState();
}
class _DbTabState extends State<DbTab> {
Future<DateMetadata> _dbDateLoader;
Future<ImageEntry> _dbEntryLoader;
Future<CatalogMetadata> _dbMetadataLoader;
Future<AddressDetails> _dbAddressLoader;
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_loadDatabase();
}
void _loadDatabase() {
final contentId = entry.contentId;
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_dbEntryLoader = metadataDb.loadEntries().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));
setState(() {});
}
@override
Widget build(BuildContext context) {
return ListView(
padding: EdgeInsets.all(16),
children: [
Row(
children: [
Expanded(
child: Text('DB'),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
await metadataDb.removeIds([entry.contentId]);
_loadDatabase();
},
child: Text('Remove from DB'),
),
],
),
FutureBuilder<DateMetadata>(
future: _dbDateLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return 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}',
}),
],
);
},
),
SizedBox(height: 16),
FutureBuilder<ImageEntry>(
future: _dbEntryLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = snapshot.data;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('DB entry:${data == null ? ' no row' : ''}'),
if (data != null)
InfoRowGroup({
'uri': '${data.uri}',
'path': '${data.path}',
'sourceMimeType': '${data.sourceMimeType}',
'width': '${data.width}',
'height': '${data.height}',
'sourceRotationDegrees': '${data.sourceRotationDegrees}',
'sizeBytes': '${data.sizeBytes}',
'sourceTitle': '${data.sourceTitle}',
'dateModifiedSecs': '${data.dateModifiedSecs}',
'sourceDateTakenMillis': '${data.sourceDateTakenMillis}',
'durationMillis': '${data.durationMillis}',
}),
],
);
},
),
SizedBox(height: 16),
FutureBuilder<CatalogMetadata>(
future: _dbMetadataLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = snapshot.data;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('DB metadata:${data == null ? ' no row' : ''}'),
if (data != null)
InfoRowGroup({
'mimeType': '${data.mimeType}',
'dateMillis': '${data.dateMillis}',
'isAnimated': '${data.isAnimated}',
'isFlipped': '${data.isFlipped}',
'rotationDegrees': '${data.rotationDegrees}',
'latitude': '${data.latitude}',
'longitude': '${data.longitude}',
'xmpSubjects': '${data.xmpSubjects}',
'xmpTitleDescription': '${data.xmpTitleDescription}',
}),
],
);
},
),
SizedBox(height: 16),
FutureBuilder<AddressDetails>(
future: _dbAddressLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return 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}',
}),
],
);
},
),
],
);
}
}

View file

@ -0,0 +1,94 @@
import 'dart:collection';
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
class MetadataTab extends StatefulWidget {
final ImageEntry entry;
const MetadataTab({@required this.entry});
@override
_MetadataTabState createState() => _MetadataTabState();
}
class _MetadataTabState extends State<MetadataTab> {
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
// MediaStore timestamp keys
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
static const millisecondTimestampKeys = ['datetaken', 'datetime'];
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_loadMetadata();
}
void _loadMetadata() {
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
setState(() {});
}
@override
Widget build(BuildContext context) {
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
final key = k.toString();
var value = v?.toString() ?? 'null';
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
if (secondTimestampKeys.contains(key)) {
v *= 1000;
}
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
}
if (key == 'xmp' && v != null && v is Uint8List) {
value = String.fromCharCodes(v);
}
return MapEntry(key, value);
}));
return AvesExpansionTile(
title: title,
children: [
Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
data,
maxValueLength: Constants.infoGroupMaxValueLength,
),
)
],
);
}
return ListView(
padding: EdgeInsets.all(8),
children: [
FutureBuilder<Map>(
future: _contentResolverMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
),
FutureBuilder<Map>(
future: _exifInterfaceMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
),
FutureBuilder<Map>(
future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
),
],
);
}
}

View file

@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:math';
import 'package:aves/model/filters/filters.dart';
@ -10,8 +9,6 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/fullscreen/image_page.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
@ -541,19 +538,6 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
// when the entry image itself changed (e.g. after rotation)
void _onImageChanged() async {
await UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
).evict();
// evict low quality thumbnail (without specified extents)
await ThumbnailProvider(entry: entry).evict();
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
final extents = List.generate(6, (index) => pow(2, index + 5).toDouble());
await Future.forEach<double>(extents, (extent) => ThumbnailProvider(entry: entry, extent: extent).evict());
await ThumbnailProvider(entry: entry).evict();
if (entry.path != null) await FileImage(File(entry.path)).evict();
// rebuild to refresh the Image inside ImagePage
setState(() {});
}

View file

@ -0,0 +1,154 @@
import 'package:aves/main.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/debug/db.dart';
import 'package:aves/widgets/fullscreen/debug/metadata.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tuple/tuple.dart';
class FullscreenDebugPage extends StatelessWidget {
static const routeName = '/fullscreen/debug';
final ImageEntry entry;
const FullscreenDebugPage({@required this.entry});
@override
Widget build(BuildContext context) {
final tabs = <Tuple2<Tab, Widget>>[
Tuple2(Tab(text: 'Entry'), _buildEntryTabView()),
if (AvesApp.mode != AppMode.view) Tuple2(Tab(text: 'DB'), DbTab(entry: entry)),
Tuple2(Tab(icon: Icon(AIcons.android)), MetadataTab(entry: entry)),
Tuple2(Tab(icon: Icon(AIcons.image)), _buildThumbnailsTabView()),
];
return DefaultTabController(
length: tabs.length,
child: Scaffold(
appBar: AppBar(
title: Text('Debug'),
bottom: TabBar(
tabs: tabs.map((t) => t.item1).toList(),
),
),
body: SafeArea(
child: TabBarView(
children: tabs.map((t) => t.item2).toList(),
),
),
),
);
}
Widget _buildEntryTabView() {
String toDateValue(int time, {int factor = 1}) {
var value = '$time';
if (time != null && time > 0) {
value += ' (${DateTime.fromMillisecondsSinceEpoch(time * factor)})';
}
return value;
}
return ListView(
padding: EdgeInsets.all(16),
children: [
InfoRowGroup({
'uri': '${entry.uri}',
'contentId': '${entry.contentId}',
'path': '${entry.path}',
'directory': '${entry.directory}',
'filenameWithoutExtension': '${entry.filenameWithoutExtension}',
'sourceTitle': '${entry.sourceTitle}',
'sourceMimeType': '${entry.sourceMimeType}',
'mimeType': '${entry.mimeType}',
}),
Divider(),
InfoRowGroup({
'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000),
'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis),
'bestDate': '${entry.bestDate}',
}),
Divider(),
InfoRowGroup({
'width': '${entry.width}',
'height': '${entry.height}',
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
'rotationDegrees': '${entry.rotationDegrees}',
'isFlipped': '${entry.isFlipped}',
'portrait': '${entry.portrait}',
'displayAspectRatio': '${entry.displayAspectRatio}',
'displaySize': '${entry.displaySize}',
}),
Divider(),
InfoRowGroup({
'durationMillis': '${entry.durationMillis}',
'durationText': '${entry.durationText}',
}),
Divider(),
InfoRowGroup({
'sizeBytes': '${entry.sizeBytes}',
'isFavourite': '${entry.isFavourite}',
'isSvg': '${entry.isSvg}',
'isPhoto': '${entry.isPhoto}',
'isVideo': '${entry.isVideo}',
'isCatalogued': '${entry.isCatalogued}',
'isAnimated': '${entry.isAnimated}',
'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}',
'canRotateAndFlip': '${entry.canRotateAndFlip}',
'xmpSubjects': '${entry.xmpSubjects}',
}),
Divider(),
InfoRowGroup({
'hasGps': '${entry.hasGps}',
'isLocated': '${entry.isLocated}',
'latLng': '${entry.latLng}',
'geoUri': '${entry.geoUri}',
}),
],
);
}
Widget _buildThumbnailsTabView() {
const extent = 128.0;
return ListView(
padding: EdgeInsets.all(16),
children: [
if (entry.isSvg) ...[
Text('SVG ($extent)'),
SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
),
width: extent,
height: extent,
)
],
if (!entry.isSvg) ...[
Text('Raster (fast)'),
Center(
child: Image(
image: ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry),
),
),
),
SizedBox(height: 16),
Text('Raster ($extent)'),
Center(
child: Image(
image: ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry, extent: extent),
),
),
),
],
],
);
}
}

View file

@ -57,7 +57,7 @@ class ImageView extends StatelessWidget {
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
// there's a black frame between the hero animation and the final image, even when it's cached.
final fastThumbnailProvider = ThumbnailProvider(entry: entry);
final fastThumbnailProvider = ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
// this loading builder shows a transition image until the final image is ready
// if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image
@ -98,11 +98,12 @@ class ImageView extends StatelessWidget {
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
child = PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.rotationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder`
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation

View file

@ -33,10 +33,10 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
bool get isVisible => widget.visibleNotifier.value;
// directory names from metadata-extractor
// special directory names
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor
static const videoDirectory = 'Video'; // additional generic video directory
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
@override
void initState() {
@ -87,13 +87,43 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength);
}
final dir = directoriesWithTitle[index - 1 - untitledDirectoryCount];
Widget thumbnail;
final prefixChildren = <Widget>[];
switch (dir.name) {
case exifThumbnailDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
break;
case xmpDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
break;
case mediaDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
Widget builder(IconData data) => Padding(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Icon(data),
);
if (dir.tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video));
if (dir.tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio));
if (dir.tags['Has Image'] == 'yes') {
int count;
if (dir.tags.containsKey('Image Count')) {
count = int.tryParse(dir.tags['Image Count']);
}
prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image)));
}
break;
}
return AvesExpansionTile(
title: dir.name,
expandedNotifier: _expandedDirectoryNotifier,
children: [
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry),
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry),
if (dir.name == videoDirectory) MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry),
if (prefixChildren.isNotEmpty)
Align(
alignment: AlignmentDirectional.topStart,
child: Wrap(children: prefixChildren),
),
if (thumbnail != null) thumbnail,
Container(
alignment: Alignment.topLeft,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),

View file

@ -50,19 +50,15 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
future: _loader,
builder: (context, snapshot) {
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
final turns = (entry.rotationDegrees / 90).round();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4),
child: Wrap(
children: snapshot.data.map((bytes) {
return RotatedBox(
quarterTurns: turns,
child: Image.memory(
bytes,
scale: devicePixelRatio,
),
return Image.memory(
bytes,
scale: devicePixelRatio,
);
}).toList(),
),

View file

@ -82,7 +82,8 @@ class FullscreenTopOverlay extends StatelessWidget {
return entry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
return entry.canRotate;
case EntryAction.flip:
return entry.canRotateAndFlip;
case EntryAction.print:
return entry.canPrint;
case EntryAction.openMap:
@ -136,6 +137,7 @@ class _TopOverlayRow extends StatelessWidget {
key: Key('entry-menu-button'),
itemBuilder: (context) => [
...inAppActions.map(_buildPopupMenuItem),
if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(),
PopupMenuDivider(),
...externalAppActions.map(_buildPopupMenuItem),
if (kDebugMode) ...[
@ -166,6 +168,7 @@ class _TopOverlayRow extends StatelessWidget {
case EntryAction.rename:
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
case EntryAction.print:
child = IconButton(
icon: Icon(action.getIcon()),
@ -207,6 +210,7 @@ class _TopOverlayRow extends StatelessWidget {
case EntryAction.rename:
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
case EntryAction.print:
case EntryAction.debug:
child = MenuRow(text: action.getText(), icon: action.getIcon());
@ -224,6 +228,40 @@ class _TopOverlayRow extends StatelessWidget {
child: child,
);
}
PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems() {
Widget buildDivider() => SizedBox(
height: 16,
child: VerticalDivider(
width: 1,
thickness: 1,
),
);
Widget buildItem(EntryAction action) => Expanded(
child: PopupMenuItem(
value: action,
child: Tooltip(
message: action.getText(),
child: Center(child: Icon(action.getIcon())),
),
),
);
return PopupMenuItem(
child: Row(
children: [
buildDivider(),
buildItem(EntryAction.rotateCCW),
buildDivider(),
buildItem(EntryAction.rotateCW),
buildDivider(),
buildItem(EntryAction.flip),
buildDivider(),
],
),
);
}
}
class _FavouriteToggler extends StatefulWidget {

View file

@ -80,7 +80,7 @@ class AvesVideoState extends State<AvesVideo> {
color: Colors.black,
);
final degree = entry.catalogMetadata?.rotationDegrees ?? 0;
final degree = entry.rotationDegrees ?? 0;
if (degree != 0) {
child = RotatedBox(
quarterTurns: degree ~/ 90,
@ -102,6 +102,7 @@ class AvesVideoState extends State<AvesVideo> {
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
),
width: entry.width.toDouble(),

View file

@ -46,13 +46,13 @@ class _GrantedDirectoriesState extends State<GrantedDirectories> {
children: [
Expanded(child: Text(path, style: textTheme.caption)),
SizedBox(width: 8),
OutlineButton(
OutlinedButton(
onPressed: () async {
await AndroidFileService.revokeDirectoryAccess(path);
_load();
setState(() {});
},
child: Text('Revoke'),
child: Text('Revoke'.toUpperCase()),
),
],
)),

View file

@ -64,6 +64,7 @@ class StatsPage extends StatelessWidget {
final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/')));
final mimeDonuts = Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes),
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'video', other: 'videos'), videoByMimeTypes),

View file

@ -110,7 +110,7 @@ class _WelcomePageState extends State<WelcomePage> {
],
);
final button = RaisedButton(
final button = ElevatedButton(
key: Key('continue-button'),
child: Text('Continue'),
onPressed: _hasAcceptedTerms

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.2.1+27
version: 1.2.2+28
# video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork)