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. 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 ## Features

View file

@ -109,10 +109,10 @@ dependencies {
// enable support for Java 8 language APIs (stream, optional, etc.) // enable support for Java 8 language APIs (stream, optional, etc.)
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9' // 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 "androidx.exifinterface:exifinterface:1.3.0"
implementation 'com.commonsware.cwac:document:0.4.1' 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.github.bumptech.glide:glide:4.11.0'
implementation 'com.google.guava:guava:29.0-android' implementation 'com.google.guava:guava:29.0-android'

View file

@ -18,10 +18,9 @@ import androidx.annotation.Nullable;
import androidx.core.content.FileProvider; import androidx.core.content.FileProvider;
import com.bumptech.glide.Glide; 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.FutureTarget;
import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
@ -34,12 +33,12 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; 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.MethodCall;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
public class AppAdapterHandler implements MethodChannel.MethodCallHandler { 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"; public static final String CHANNEL = "deckers.thibault/aves/app";
@ -173,25 +172,21 @@ public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
.path(String.valueOf(iconResourceId)) .path(String.valueOf(iconResourceId))
.build(); .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() RequestOptions options = new RequestOptions()
.signature(signature) .format(DecodeFormat.PREFER_RGB_565)
.centerCrop()
.override(size, size); .override(size, size);
FutureTarget<Bitmap> target = Glide.with(context) FutureTarget<Bitmap> target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.centerCrop()
.load(uri) .load(uri)
.signature(signature)
.submit(size, size); .submit(size, size);
try { try {
Bitmap bmp = target.get(); Bitmap bitmap = target.get();
if (bmp != null) { if (bitmap != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream(); ByteArrayOutputStream stream = new ByteArrayOutputStream();
bmp.compress(Bitmap.CompressFormat.PNG, 100, stream); bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream);
data = stream.toByteArray(); data = stream.toByteArray();
} }
} catch (Exception e) { } catch (Exception e) {

View file

@ -12,13 +12,11 @@ import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat; import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat; 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 java.util.List;
import deckers.thibault.aves.MainActivity; import deckers.thibault.aves.MainActivity;
import deckers.thibault.aves.R; import deckers.thibault.aves.R;
import deckers.thibault.aves.utils.BitmapUtils;
import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
@ -57,12 +55,15 @@ public class AppShortcutHandler implements MethodChannel.MethodCallHandler {
return; return;
} }
IconCompat icon; IconCompat icon = null;
if (iconBytes != null && iconBytes.length > 0) { if (iconBytes != null && iconBytes.length > 0) {
Bitmap bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length); Bitmap bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length);
bitmap = TransformationUtils.centerCrop(new LruBitmapPool(2 << 24), bitmap, 256, 256); bitmap = BitmapUtils.centerSquareCrop(context, bitmap, 256);
if (bitmap != null) {
icon = IconCompat.createWithBitmap(bitmap); icon = IconCompat.createWithBitmap(bitmap);
} else { }
}
if (icon == null) {
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection); 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.ContentResolver;
import android.content.ContentUris; import android.content.ContentUris;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build; import android.os.Build;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
import android.util.Size; import android.util.Size;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import com.bumptech.glide.Glide; 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.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey; import com.bumptech.glide.signature.ObjectKey;
@ -27,21 +28,28 @@ import java.io.IOException;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.decoder.VideoThumbnail; 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.MimeTypes;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, ImageDecodeTask.Result> { 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 { static class Params {
AvesImageEntry entry; Uri uri;
Integer width, height, defaultSize; String mimeType;
Long dateModifiedSecs;
Integer rotationDegrees, width, height, defaultSize;
Boolean isFlipped;
MethodChannel.Result result; MethodChannel.Result result;
Params(AvesImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) { 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.entry = entry; this.uri = Uri.parse(uri);
this.mimeType = mimeType;
this.dateModifiedSecs = dateModifiedSecs;
this.rotationDegrees = rotationDegrees;
this.isFlipped = isFlipped;
this.width = width; this.width = width;
this.height = height; this.height = height;
this.result = result; this.result = result;
@ -80,6 +88,10 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
if (w == null || h == null || w == 0 || h == 0) { if (w == null || h == null || w == 0 || h == 0) {
p.width = p.defaultSize; p.width = p.defaultSize;
p.height = p.defaultSize; p.height = p.defaultSize;
// 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 { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bitmap = getThumbnailBytesByResolver(p); bitmap = getThumbnailBytesByResolver(p);
@ -90,6 +102,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
exception = e; exception = e;
} }
} }
}
// fallback if the native methods failed or for higher quality thumbnails // fallback if the native methods failed or for higher quality thumbnails
try { try {
@ -100,7 +113,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
exception = e; exception = e;
} }
} else { } 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; byte[] data = null;
@ -124,70 +137,66 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
@RequiresApi(api = Build.VERSION_CODES.Q) @RequiresApi(api = Build.VERSION_CODES.Q)
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException { private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
AvesImageEntry entry = params.entry;
Integer width = params.width;
Integer height = params.height;
ContentResolver resolver = activity.getContentResolver(); ContentResolver resolver = activity.getContentResolver();
Bitmap bitmap = resolver.loadThumbnail(entry.uri, new Size(width, height), null); Bitmap bitmap = resolver.loadThumbnail(params.uri, new Size(params.width, params.height), null);
String mimeType = entry.mimeType; String mimeType = params.mimeType;
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) { if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees); bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
} }
return bitmap; return bitmap;
} }
private Bitmap getThumbnailBytesByMediaStore(Params params) { private Bitmap getThumbnailBytesByMediaStore(Params params) {
AvesImageEntry entry = params.entry; long contentId = ContentUris.parseId(params.uri);
long contentId = ContentUris.parseId(entry.uri);
ContentResolver resolver = activity.getContentResolver(); 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); return MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null);
} else { } else {
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null); 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 // from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) { 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; return bitmap;
} }
} }
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException { 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 width = params.width;
int height = params.height; 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() 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); .override(width, height);
FutureTarget<Bitmap> target; FutureTarget<Bitmap> target;
if (entry.isVideo()) { if (MimeTypes.isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE); options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
target = Glide.with(activity) target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(new VideoThumbnail(activity, entry.uri)) .load(new VideoThumbnail(activity, uri))
.signature(signature)
.submit(width, height); .submit(width, height);
} else { } else {
target = Glide.with(activity) target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(entry.uri) .load(uri)
.signature(signature)
.submit(width, height); .submit(width, height);
} }
try { try {
Bitmap bitmap = target.get(); Bitmap bitmap = target.get();
String mimeType = entry.mimeType;
if (MimeTypes.needRotationAfterGlide(mimeType)) { if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees); bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
} }
return bitmap; return bitmap;
} finally { } 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 @Override
protected void onPostExecute(Result result) { protected void onPostExecute(Result result) {
Params params = result.params; Params params = result.params;
MethodChannel.Result r = params.result; MethodChannel.Result r = params.result;
AvesImageEntry entry = params.entry; String uri = params.uri.toString();
String uri = entry.uri.toString();
if (result.data != null) { if (result.data != null) {
r.success(result.data); r.success(result.data);
} else { } 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.List;
import java.util.Map; 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.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory; import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider; import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
@ -58,6 +58,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "rotate": case "rotate":
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start(); new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
break; break;
case "flip":
new Thread(() -> flip(call, new MethodResultWrapper(result))).start();
break;
default: default:
result.notImplemented(); result.notImplemented();
break; break;
@ -65,12 +68,16 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
} }
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { 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 widthDip = call.argument("widthDip");
Double heightDip = call.argument("heightDip"); Double heightDip = call.argument("heightDip");
Double defaultSizeDip = call.argument("defaultSizeDip"); 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); result.error("getThumbnail-args", "failed because of missing arguments", null);
return; return;
} }
@ -80,8 +87,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
int height = (int) Math.round(heightDip * density); int height = (int) Math.round(heightDip * density);
int defaultSize = (int) Math.round(defaultSizeDip * density); int defaultSize = (int) Math.round(defaultSizeDip * density);
AvesImageEntry entry = new AvesImageEntry(entryMap); new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(uri, mimeType, dateModifiedSecs.longValue(), rotationDegrees, isFlipped, width, height, defaultSize, result));
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result));
} }
private void getObsoleteEntries(@NonNull MethodCall call, @NonNull MethodChannel.Result 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); result.error("rotate-provider", "failed to find provider for uri=" + uri, null);
return; 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 @Override
public void onSuccess(Map<String, Object> newFields) { public void onSuccess(Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(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 android.os.Looper;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.DiskCacheStrategy; 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.FutureTarget;
import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.RequestOptions;
@ -19,6 +19,7 @@ import java.io.InputStream;
import java.util.Map; import java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail; import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.utils.BitmapUtils;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel;
@ -29,6 +30,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private Uri uri; private Uri uri;
private String mimeType; private String mimeType;
private int rotationDegrees; private int rotationDegrees;
private boolean isFlipped;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
@ -40,6 +42,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
this.mimeType = (String) argMap.get("mimeType"); this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri")); this.uri = Uri.parse((String) argMap.get("uri"));
this.rotationDegrees = (int) argMap.get("rotationDegrees"); 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 // - 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 // - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private void getImage() { private void getImage() {
if (MimeTypes.isVideo(mimeType)) { // request a fresh image with the highest quality format
RequestOptions options = new RequestOptions() RequestOptions options = new RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE); .format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true);
if (MimeTypes.isVideo(mimeType)) {
FutureTarget<Bitmap> target = Glide.with(activity) FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
@ -95,21 +102,27 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally { } finally {
Glide.with(activity).clear(target); 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 // we convert the image on platform side first, when Dart Image.memory does not support it
FutureTarget<Bitmap> target = Glide.with(activity) FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(options)
.load(uri) .load(uri)
.submit(); .submit();
try { try {
Bitmap bitmap = target.get(); Bitmap bitmap = target.get();
if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
}
if (bitmap != null) { if (bitmap != null) {
// TODO TLAD use exif orientation to rotate & flip?
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream(); ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes // we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG // Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream); if (MimeTypes.canHaveAlpha(mimeType)) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
}
success(stream.toByteArray()); success(stream.toByteArray());
} else { } else {
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null); 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.AvesImageEntry;
import deckers.thibault.aves.model.provider.ImageProvider; import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory; import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.LogUtils;
import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel;
public class ImageOpStreamHandler implements EventChannel.StreamHandler { public class ImageOpStreamHandler implements EventChannel.StreamHandler {
private static final String LOG_TAG = Utils.createLogTag(ImageOpStreamHandler.class); private static final String LOG_TAG = LogUtils.createTag(ImageOpStreamHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/imageopstream"; 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.GlideBuilder;
import com.bumptech.glide.Registry; import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser; import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser;
import com.bumptech.glide.module.AppGlideModule; import com.bumptech.glide.module.AppGlideModule;
import com.bumptech.glide.request.RequestOptions;
import org.jetbrains.annotations.NotNull;
@GlideModule @GlideModule
public class AvesAppGlideModule extends AppGlideModule { public class AvesAppGlideModule extends AppGlideModule {
@Override @Override
public void applyOptions(@NotNull Context context, @NonNull GlideBuilder builder) { public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
// hide noisy warning (e.g. for images that can't be decoded) // hide noisy warning (e.g. for images that can't be decoded)
builder.setLogLevel(Log.ERROR); builder.setLogLevel(Log.ERROR);
} }

View file

@ -1,18 +1,44 @@
package deckers.thibault.aves.model.provider; package deckers.thibault.aves.model.provider;
import android.content.Context; import android.content.Context;
import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.provider.MediaStore;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
import deckers.thibault.aves.model.SourceImageEntry; import deckers.thibault.aves.model.SourceImageEntry;
class ContentImageProvider extends ImageProvider { class ContentImageProvider extends ImageProvider {
@Override @Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) { 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()); callback.onSuccess(entry.toMap());
} else { } else {
callback.onFailure(new Exception("entry has no size")); callback.onFailure(new Exception("entry has no size"));

View file

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

View file

@ -23,9 +23,10 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry; 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.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
// *** about file access to write/rename/delete // *** about file access to write/rename/delete
// * primary volume // * 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 // from 21/Lollipop, use `DocumentFile` (not `File`) after getting permission to the volume root
public abstract class ImageProvider { 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) { public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException()); 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)) { if (!canEditExif(mimeType)) {
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType)); callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
return; return;
@ -124,7 +125,17 @@ public abstract class ImageProvider {
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) { if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL)); 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(); exif.saveAttributes();
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
@ -137,26 +148,22 @@ public abstract class ImageProvider {
return; return;
} }
// ContentResolver contentResolver = context.getContentResolver(); MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> {
// ContentValues values = new ContentValues(); String[] projection = {MediaStore.MediaColumns.DATE_MODIFIED};
// // from Android Q, media store update needs to be flagged IS_PENDING first try {
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
// values.put(MediaStore.MediaColumns.IS_PENDING, 1); if (cursor != null) {
// // TODO TLAD catch RecoverableSecurityException if (cursor.moveToNext()) {
// contentResolver.update(uri, values, null, null); newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
// values.clear(); }
// values.put(MediaStore.MediaColumns.IS_PENDING, 0); cursor.close();
// } }
// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q } catch (Exception e) {
// values.put(MediaStore.Images.Media.ORIENTATION, rotationDegrees); callback.onFailure(e);
// // TODO TLAD catch RecoverableSecurityException return;
// int updatedRowCount = contentResolver.update(uri, values, null, null); }
// if (updatedRowCount > 0) { callback.onSuccess(newFields);
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);
// }
} }
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) { 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.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry; import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
public class MediaStoreImageProvider extends ImageProvider { 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 = { private static final String[] BASE_PROJECTION = {
MediaStore.MediaColumns._ID, MediaStore.MediaColumns._ID,
@ -437,7 +437,7 @@ public class MediaStoreImageProvider extends ImageProvider {
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) { MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
fullPath = destinationDir; fullPath = destinationDir;
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir); volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null); volumePath = StorageUtils.getVolumePath(context, destinationDir);
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null; 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 app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.Utils
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
companion object { 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 INTENT_CHANNEL = "deckers.thibault/aves/intent"
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
} }
@ -139,7 +139,7 @@ class MainActivity : FlutterActivity() {
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { 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 val treeUri = data.data
if (resultCode != RESULT_OK || treeUri == null) { if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null) PermissionManager.onPermissionResult(requestCode, null)

View file

@ -1,8 +1,10 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -12,6 +14,7 @@ import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils import com.adobe.internal.xmp.XMPUtils
import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.imaging.ImageProcessingException import com.drew.imaging.ImageProcessingException
import com.drew.lang.Rational 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.gif.GifAnimationDirectory
import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.utils.* import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.utils.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDescription import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.Metadata.isFlippedForExifCode import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.utils.Metadata.parseVideoMetadataDate import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.Metadata.parseVideoMetadataDate
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDescription import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription
import deckers.thibault.aves.utils.MimeTypes.getMimeTypeForExtension 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.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo 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.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -67,7 +78,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") 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) { if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null) result.error("getAllMetadata-args", "failed because of missing arguments", null)
return return
@ -79,7 +90,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (isSupportedByMetadataExtractor(mimeType)) { if (isSupportedByMetadataExtractor(mimeType)) {
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
@ -120,7 +131,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (!foundExif) { if (!foundExif) {
// fallback to read EXIF via ExifInterface // fallback to read EXIF via ExifInterface
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
val allTags = describeAll(exif).toMutableMap() val allTags = describeAll(exif).toMutableMap()
if (foundXmp) { if (foundXmp) {
@ -167,14 +178,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val extension = call.argument<String>("extension")
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null) result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return return
} }
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension)) val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType))
if (isVideo(mimeType)) { if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri)) metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
} }
@ -183,14 +193,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(metadataMap) 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>() val metadataMap = HashMap<String, Any>()
var foundExif = false var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) { if (isSupportedByMetadataExtractor(mimeType)) {
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) 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`) // the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`)
// `context.getContentResolver().getType()` sometimes return incorrect value // `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` // `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { // file extension is unreliable
val detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) // in the end, `metadata-extractor` is the most reliable, unless it reports `tiff`
if (detectedMimeType != null && detectedMimeType != mimeType) { dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
// file extension is unreliable, but we use it as a tie breaker if (it != MimeTypes.TIFF) {
val extensionMimeType = extension?.toLowerCase(Locale.ROOT)?.let { getMimeTypeForExtension(it) } metadataMap[KEY_MIME_TYPE] = it
if (extensionMimeType == null || detectedMimeType == extensionMimeType) {
metadataMap[KEY_MIME_TYPE] = detectedMimeType
}
} }
} }
} }
@ -278,7 +285,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (!foundExif) { if (!foundExif) {
// fallback to read EXIF via ExifInterface // fallback to read EXIF via ExifInterface
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
@ -289,7 +296,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
} }
val latLong = exif.latLong val latLong = exif.latLong
if (latLong != null && latLong.size == 2) { if (latLong?.size == 2) {
metadataMap[KEY_LATITUDE] = latLong[0] metadataMap[KEY_LATITUDE] = latLong[0]
metadataMap[KEY_LONGITUDE] = latLong[1] metadataMap[KEY_LONGITUDE] = latLong[1]
} }
@ -320,23 +327,15 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) { if (locationString != null) {
val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString) val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) { if (matcher.find() && matcher.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` // 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) { if (latitude != 0.0 || longitude != 0.0) {
metadataMap[KEY_LATITUDE] = latitude metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude metadataMap[KEY_LONGITUDE] = longitude
} }
} catch (e: NumberFormatException) {
// ignore
}
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -350,7 +349,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") 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) { if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null) result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
return return
@ -362,7 +361,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
return return
} }
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it } dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
@ -382,7 +381,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
} }
result.success(metadataMap) result.success(metadataMap)
} } ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null)
} catch (e: Exception) { } catch (e: Exception) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message) result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
} catch (e: NoClassDefFoundError) { } catch (e: NoClassDefFoundError) {
@ -392,14 +391,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") 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) { if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null) result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
return return
} }
var contentUri: Uri = uri
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
try {
val id = ContentUris.parseId(uri) val id = ContentUris.parseId(uri)
var contentUri = when { contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri else -> uri
@ -407,6 +409,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri) contentUri = MediaStore.setRequireOriginal(contentUri)
} }
} catch (e: NumberFormatException) {
// ignore
}
}
val cursor = context.contentResolver.query(contentUri, null, null, null, null) val cursor = context.contentResolver.query(contentUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
@ -436,21 +442,21 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) { 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) { if (uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null) result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
return return
} }
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
val metadataMap = HashMap<String, String?>() val metadataMap = HashMap<String, String?>()
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) { for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag) metadataMap[tag] = exif.getAttribute(tag)
} }
result.success(metadataMap) result.success(metadataMap)
} } ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
} catch (e: Exception) { } catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException // ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure // 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) { 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) { if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null) result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
return return
@ -483,7 +489,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { 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) { if (uri == null) {
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null) result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
return return
@ -505,7 +511,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) { 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) { if (uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null) result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return return
@ -513,9 +519,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val thumbnails = ArrayList<ByteArray>() val thumbnails = ArrayList<ByteArray>()
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(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) { } catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException // 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) { private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") 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) { if (mimeType == null || uri == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null) result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
return return
@ -535,7 +551,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val thumbnails = ArrayList<ByteArray>() val thumbnails = ArrayList<ByteArray>()
if (isSupportedByMetadataExtractor(mimeType)) { if (isSupportedByMetadataExtractor(mimeType)) {
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta val xmpMeta = dir.xmpMeta
@ -567,7 +583,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
companion object { 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" const val CHANNEL = "deckers.thibault/aves/metadata"
// catalog 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 android.util.Log
import androidx.exifinterface.media.ExifInterface 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.OlympusCameraSettingsMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
import deckers.thibault.aves.utils.LogUtils
import java.util.* import java.util.*
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.roundToLong import kotlin.math.roundToLong
object ExifInterfaceHelper { 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 // ExifInterface always states it has the following attributes
// and returns "0" instead of "null" when they are actually missing // 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.content.Context
import android.media.MediaFormat 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 androidx.exifinterface.media.ExifInterface
import java.text.ParseException import java.text.ParseException
@ -34,6 +34,16 @@ object Metadata {
else -> false 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)? // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
@JvmStatic @JvmStatic
fun parseVideoMetadataDate(metadataDate: String?): Long { 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.lang.Rational
import com.drew.metadata.Directory import com.drew.metadata.Directory
@ -11,6 +11,10 @@ object MetadataExtractorHelper {
if (this.containsTag(tag)) save(this.getDescription(tag)) 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) { fun Directory.getSafeBoolean(tag: Int, save: (value: Boolean) -> Unit) {
if (this.containsTag(tag)) save(this.getBoolean(tag)) 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.XMPException
import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMeta
import deckers.thibault.aves.utils.LogUtils
object XMP { 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 DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" 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/" 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 GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US" private const val SPECIFIC_LANG = "en-US"
@Throws(XMPException::class)
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) { fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
try {
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) { if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG) 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 // double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) save(item.value) 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 package deckers.thibault.aves.model
import android.net.Uri import android.net.Uri
import deckers.thibault.aves.utils.MimeTypes
class AvesImageEntry(map: Map<String?, Any?>) { class AvesImageEntry(map: Map<String?, Any?>) {
@JvmField @JvmField
@ -21,18 +20,4 @@ class AvesImageEntry(map: Map<String?, Any?>) {
@JvmField @JvmField
val rotationDegrees = map["rotationDegrees"] as Int 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.Mp4Directory
import com.drew.metadata.mp4.media.Mp4VideoDirectory import com.drew.metadata.mp4.media.Mp4VideoDirectory
import com.drew.metadata.photoshop.PsdHeaderDirectory import com.drew.metadata.photoshop.PsdHeaderDirectory
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException import java.io.IOException
@ -101,15 +101,12 @@ class SourceImageEntry {
return null return null
} }
val hasSize: Boolean val isSized: Boolean
get() = width ?: 0 > 0 && height ?: 0 > 0 get() = width ?: 0 > 0 && height ?: 0 > 0
private val hasDuration: Boolean private val hasDuration: Boolean
get() = durationMillis ?: 0 > 0 get() = durationMillis ?: 0 > 0
private val isImage: Boolean
get() = MimeTypes.isImage(sourceMimeType)
private val isVideo: Boolean private val isVideo: Boolean
get() = MimeTypes.isVideo(sourceMimeType) get() = MimeTypes.isVideo(sourceMimeType)
@ -123,15 +120,15 @@ class SourceImageEntry {
if (isSvg) return this if (isSvg) return this
if (isVideo) { if (isVideo) {
fillVideoByMediaMetadataRetriever(context) fillVideoByMediaMetadataRetriever(context)
if (hasSize && hasDuration) return this if (isSized && hasDuration) return this
} }
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) { if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
fillByMetadataExtractor(context) fillByMetadataExtractor(context)
if (hasSize && foundExif) return this if (isSized && foundExif) return this
} }
if (ExifInterface.isSupportedMimeType(sourceMimeType)) { if (ExifInterface.isSupportedMimeType(sourceMimeType)) {
fillByExifInterface(context) fillByExifInterface(context)
if (hasSize) return this if (isSized) return this
} }
fillByBitmapDecode(context) fillByBitmapDecode(context)
return this return this
@ -158,7 +155,7 @@ class SourceImageEntry {
// finds: width, height, orientation, date, duration // finds: width, height, orientation, date, duration
private fun fillByMetadataExtractor(context: Context) { private fun fillByMetadataExtractor(context: Context) {
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
// do not switch on specific mime types, as the reported mime type could be wrong // 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 // finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) { private fun fillByExifInterface(context: Context) {
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val exif = ExifInterface(input) val exif = ExifInterface(input)
foundExif = true foundExif = true
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it } exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
@ -226,7 +223,7 @@ class SourceImageEntry {
// finds: width, height // finds: width, height
private fun fillByBitmapDecode(context: Context) { private fun fillByBitmapDecode(context: Context) {
try { try {
StorageUtils.openInputStream(context, uri).use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val options = BitmapFactory.Options() val options = BitmapFactory.Options()
options.inJustDecodeBounds = true options.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, options) 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 import java.util.regex.Pattern
object Utils { object LogUtils {
private const val LOG_TAG_MAX_LENGTH = 23 private const val LOG_TAG_MAX_LENGTH = 23
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.") private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.")
// create an Android logger friendly log tag for the specified class
@JvmStatic @JvmStatic
fun createLogTag(clazz: Class<*>): String { fun createTag(clazz: Class<*>): String {
// shorten class name to "a.b.CccDdd" // shorten class name to "a.b.CccDdd"
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.") var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.")
if (logTag.length > LOG_TAG_MAX_LENGTH) { if (logTag.length > LOG_TAG_MAX_LENGTH) {

View file

@ -1,7 +1,5 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.utils
import java.util.*
object MimeTypes { object MimeTypes {
private const val IMAGE = "image" private const val IMAGE = "image"
@ -12,44 +10,20 @@ object MimeTypes {
private const val HEIF = "image/heif" private const val HEIF = "image/heif"
private const val ICO = "image/x-icon" private const val ICO = "image/x-icon"
private const val JPEG = "image/jpeg" private const val JPEG = "image/jpeg"
private const val PCX = "image/x-pcx"
private const val PNG = "image/png" private const val PNG = "image/png"
private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop" const val TIFF = "image/tiff"
private const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp" private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp" const val WEBP = "image/webp"
// raw raster // 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 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 // vector
const val SVG = "image/svg+xml" const val SVG = "image/svg+xml"
private const val VIDEO = "video" 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 MP2T = "video/mp2t"
private const val MP4 = "video/mp4"
private const val WEBM = "video/webm" private const val WEBM = "video/webm"
@JvmStatic @JvmStatic
@ -58,11 +32,19 @@ object MimeTypes {
@JvmStatic @JvmStatic
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) 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 // as of Flutter v1.22.0
@JvmStatic @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 JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0 PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
else -> false else -> false
} }
@ -75,6 +57,8 @@ object MimeTypes {
// Glide automatically applies EXIF orientation when decoding images of known formats // Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other 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 @JvmStatic
fun needRotationAfterGlide(mimeType: String) = when (mimeType) { fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
DNG, HEIC, HEIF, PNG, WEBP -> true DNG, HEIC, HEIF, PNG, WEBP -> true
@ -89,50 +73,4 @@ object MimeTypes {
DNG, PNG -> true DNG, PNG -> true
else -> false 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 { 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 '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 "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' 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(); 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 'dart:async';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
@ -75,8 +77,8 @@ class ImageEntry {
sourceDateTakenMillis: sourceDateTakenMillis, sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis, durationMillis: durationMillis,
) )
.._catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) ..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
.._addressDetails = _addressDetails?.copyWith(contentId: copyContentId); ..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
return copied; return copied;
} }
@ -154,7 +156,7 @@ class ImageEntry {
// the MIME type reported by the Media Store is unreliable // the MIME type reported by the Media Store is unreliable
// so we use the one found during cataloguing if possible // 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('/.*'), '/*'); String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
@ -173,13 +175,11 @@ class ImageEntry {
bool get isAnimated => _catalogMetadata?.isAnimated ?? false; bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
bool get canEdit => path != null; bool get canEdit => path != null;
bool get canPrint => !isVideo; bool get canPrint => !isVideo;
bool get canRotate => canEdit && canEditExif; bool get canRotateAndFlip => canEdit && canEditExif;
// support for writing EXIF // support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.0 // 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 { double get displayAspectRatio {
if (width == 0 || height == 0) return 1; if (width == 0 || height == 0) return 1;
@ -220,13 +220,17 @@ class ImageEntry {
return _bestDate; return _bestDate;
} }
int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees ?? 0;
set rotationDegrees(int rotationDegrees) { set rotationDegrees(int rotationDegrees) {
sourceRotationDegrees = 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; int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) { set dateModifiedSecs(int dateModifiedSecs) {
@ -276,10 +280,16 @@ class ImageEntry {
} }
set catalogMetadata(CatalogMetadata newMetadata) { set catalogMetadata(CatalogMetadata newMetadata) {
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
catalogDateMillis = newMetadata?.dateMillis; catalogDateMillis = newMetadata?.dateMillis;
_catalogMetadata = newMetadata; _catalogMetadata = newMetadata;
_bestTitle = null; _bestTitle = null;
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
_onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
} }
void clearMetadata() { void clearMetadata() {
@ -351,12 +361,7 @@ class ImageEntry {
return false; return false;
} }
Future<bool> rename(String newName) async { Future<void> _applyNewFields(Map newFields) async {
if (newName == filenameWithoutExtension) return true;
final newFields = await ImageFileService.rename(this, '$newName$extension');
if (newFields.isEmpty) return false;
final uri = newFields['uri']; final uri = newFields['uri'];
if (uri is String) this.uri = uri; if (uri is String) this.uri = uri;
final path = newFields['path']; final path = newFields['path'];
@ -365,6 +370,24 @@ class ImageEntry {
if (contentId is int) this.contentId = contentId; if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title']; final sourceTitle = newFields['title'];
if (sourceTitle is String) this.sourceTitle = sourceTitle; 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; _bestTitle = null;
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
return true; return true;
@ -374,14 +397,23 @@ class ImageEntry {
final newFields = await ImageFileService.rotate(this, clockwise: clockwise); final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final width = newFields['width']; final oldDateModifiedSecs = dateModifiedSecs;
if (width is int) this.width = width; final oldRotationDegrees = rotationDegrees;
final height = newFields['height']; final oldIsFlipped = isFlipped;
if (height is int) this.height = height; await _applyNewFields(newFields);
final rotationDegrees = newFields['rotationDegrees']; await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees; 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; return true;
} }
@ -399,6 +431,16 @@ class ImageEntry {
return completer.future; 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() { void toggleFavourite() {
if (isFavourite) { if (isFavourite) {
removeFromFavourites(); removeFromFavourites();
@ -419,18 +461,29 @@ class ImageEntry {
} }
} }
// compare by:
// 1) title ascending
// 2) extension ascending
static int compareByName(ImageEntry a, ImageEntry b) { static int compareByName(ImageEntry a, ImageEntry b) {
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle); final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension); return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
} }
// compare by:
// 1) size descending
// 2) name ascending
static int compareBySize(ImageEntry a, ImageEntry b) { static int compareBySize(ImageEntry a, ImageEntry b) {
final c = b.sizeBytes.compareTo(a.sizeBytes); final c = b.sizeBytes.compareTo(a.sizeBytes);
return c != 0 ? c : compareByName(a, b); 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) { 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); return c != 0 ? c : compareByName(a, b);
} }
} }

View file

@ -110,7 +110,9 @@ class Settings extends ChangeNotifier {
double get collectionTileExtent => _prefs.getDouble(collectionTileExtentKey) ?? 0; 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); 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; 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); var oldValue = _prefs.get(key);
if (newValue == null) { if (newValue == null) {
_prefs.remove(key); _prefs.remove(key);
@ -209,7 +211,7 @@ class Settings extends ChangeNotifier {
oldValue = _prefs.getBool(key); oldValue = _prefs.getBool(key);
_prefs.setBool(key, newValue); _prefs.setBool(key, newValue);
} }
if (oldValue != newValue) { if (oldValue != newValue && notify) {
notifyListeners(); notifyListeners();
} }
} }

View file

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

View file

@ -30,7 +30,15 @@ class AppShortcutService {
Uint8List iconBytes; Uint8List iconBytes;
if (iconEntry != null) { if (iconEntry != null) {
final size = iconEntry.isVideo ? 0.0 : 256.0; 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 { try {
await platform.invokeMethod('pin', <String, dynamic>{ await platform.invokeMethod('pin', <String, dynamic>{

View file

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

View file

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

View file

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

View file

@ -43,28 +43,42 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initProvider(); _registerWidget(widget);
} }
@override @override
void didUpdateWidget(ThumbnailRasterImage oldWidget) { void didUpdateWidget(ThumbnailRasterImage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.entry != entry) { if (oldWidget.entry != entry) {
_pauseProvider(); _unregisterWidget(oldWidget);
_initProvider(); _registerWidget(widget);
} }
} }
@override @override
void dispose() { void dispose() {
_pauseProvider(); _unregisterWidget(widget);
super.dispose(); super.dispose();
} }
void _registerWidget(ThumbnailRasterImage widget) {
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
_initProvider();
}
void _unregisterWidget(ThumbnailRasterImage widget) {
widget.entry.imageChangeNotifier?.removeListener(_onImageChanged);
_pauseProvider();
}
void _initProvider() { void _initProvider() {
_fastThumbnailProvider = ThumbnailProvider(entry: entry); _fastThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry),
);
if (!entry.isVideo) { 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, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,
); );
if (imageCache.statusForKey(imageProvider).keepAlive) { if (imageCache.statusForKey(imageProvider).keepAlive) {
@ -153,4 +168,12 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
child: image, 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), onSubmitted: (_) => _submit(context),
), ),
actions: [ actions: [
FlatButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()), child: Text('Cancel'.toUpperCase()),
), ),
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier, valueListenable: _isValidNotifier,
builder: (context, isValid, child) { builder: (context, isValid, child) {
return FlatButton( return TextButton(
onPressed: isValid ? () => _submit(context) : null, onPressed: isValid ? () => _submit(context) : null,
child: Text('Add'.toUpperCase()), child: Text('Add'.toUpperCase()),
); );

View file

@ -92,14 +92,14 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
), ),
], ],
actions: [ actions: [
FlatButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()), child: Text('Cancel'.toUpperCase()),
), ),
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier, valueListenable: _isValidNotifier,
builder: (context, isValid, child) { builder: (context, isValid, child) {
return FlatButton( return TextButton(
onPressed: isValid ? () => _submit(context) : null, onPressed: isValid ? () => _submit(context) : null,
child: Text('Create'.toUpperCase()), 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/aves_dialog.dart';
import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@ -60,6 +60,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
case EntryAction.rotateCW: case EntryAction.rotateCW:
_rotate(context, entry, clockwise: true); _rotate(context, entry, clockwise: true);
break; break;
case EntryAction.flip:
_flip(context, entry);
break;
case EntryAction.setAs: case EntryAction.setAs:
AndroidAppService.setAs(entry.uri, entry.mimeType); AndroidAppService.setAs(entry.uri, entry.mimeType);
break; break;
@ -76,12 +79,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
final uri = entry.uri; final uri = entry.uri;
final mimeType = entry.mimeType; final mimeType = entry.mimeType;
final rotationDegrees = entry.rotationDegrees; final rotationDegrees = entry.rotationDegrees;
final isFlipped = entry.isFlipped;
final documentName = entry.bestTitle ?? 'Aves'; final documentName = entry.bestTitle ?? 'Aves';
final doc = pdf.Document(title: documentName); final doc = pdf.Document(title: documentName);
PdfImage pdfImage; PdfImage pdfImage;
if (entry.isSvg) { 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) { if (bytes != null && bytes.isNotEmpty) {
final svgRoot = await svg.fromSvgBytes(bytes, uri); final svgRoot = await svg.fromSvgBytes(bytes, uri);
final viewBox = svgRoot.viewport.viewBox; final viewBox = svgRoot.viewport.viewBox;
@ -101,6 +105,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
rotationDegrees: rotationDegrees, 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 { Future<void> _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async {
if (!await checkStoragePermission(context, [entry])) return; if (!await checkStoragePermission(context, [entry])) return;
@ -127,11 +139,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
return AvesDialog( return AvesDialog(
content: Text('Are you sure?'), content: Text('Are you sure?'),
actions: [ actions: [
FlatButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()), child: Text('Cancel'.toUpperCase()),
), ),
FlatButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: Text('Delete'.toUpperCase()), child: Text('Delete'.toUpperCase()),
), ),

View file

@ -28,11 +28,11 @@ mixin PermissionAwareMixin {
title: 'Storage Volume Access', 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.'), 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: [ actions: [
FlatButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()), child: Text('Cancel'.toUpperCase()),
), ),
FlatButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: Text('OK'.toUpperCase()), child: Text('OK'.toUpperCase()),
), ),

View file

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

View file

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

View file

@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/filters/album.dart'; 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_lens.dart';
import 'package:aves/model/source/collection_source.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/android_app_service.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/collection/collection_actions.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/entry_actions.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/albums_page.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:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final CollectionLens collection; final CollectionLens collection;
@ -58,10 +63,14 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async { Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
final source = collection.source; final source = collection.source;
final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source);
final destinationAlbum = await Navigator.push( final destinationAlbum = await Navigator.push(
context, context,
MaterialPageRoute<String>( MaterialPageRoute<String>(
builder: (context) { builder: (context) {
return Selector<Settings, ChipSortFactor>(
selector: (context, s) => s.albumSortFactor,
builder: (context, sortFactor, child) {
return FilterGridPage( return FilterGridPage(
source: source, source: source,
appBar: SliverAppBar( appBar: SliverAppBar(
@ -81,6 +90,10 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
}, },
tooltip: 'Create album', tooltip: 'Create album',
), ),
IconButton(
icon: Icon(AIcons.sort),
onPressed: () => chipSetActionDelegate.onActionSelected(context, ChipSetAction.sort),
),
], ],
floating: true, floating: true,
), ),
@ -93,6 +106,8 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album), onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
); );
}, },
);
},
), ),
); );
if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (destinationAlbum == null || destinationAlbum.isEmpty) return;
@ -148,11 +163,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
return AvesDialog( return AvesDialog(
content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'), content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'),
actions: [ actions: [
FlatButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()), child: Text('Cancel'.toUpperCase()),
), ),
FlatButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: Text('Delete'.toUpperCase()), child: Text('Delete'.toUpperCase()),
), ),

View file

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

View file

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

View file

@ -11,6 +11,7 @@ class AIcons {
static const IconData allCollection = Icons.collections_outlined; static const IconData allCollection = Icons.collections_outlined;
static const IconData image = Icons.photo_outlined; static const IconData image = Icons.photo_outlined;
static const IconData video = Icons.movie_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 vector = Icons.code_outlined;
static const IconData android = Icons.android; static const IconData android = Icons.android;
@ -34,6 +35,7 @@ class AIcons {
static const IconData debug = Icons.whatshot_outlined; static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined; static const IconData delete = Icons.delete_outlined;
static const IconData expand = Icons.expand_more_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 favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite; static const IconData favouriteActive = Icons.favorite;
static const IconData goUp = Icons.arrow_upward_outlined; 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 { Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
try { try {
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size); 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); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$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'; import 'package:flutter/material.dart';
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> { class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
ThumbnailProvider({ final ThumbnailProviderKey key;
@required this.entry,
this.extent = 0,
this.scale = 1,
}) : assert(entry != null),
assert(extent != null),
assert(scale != null) {
_cancellationKey = _buildKey(ImageConfiguration.empty);
}
final ImageEntry entry; ThumbnailProvider(this.key) : assert(key != null);
final double extent;
final double scale;
Object _cancellationKey;
@override @override
Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) { Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) {
// configuration can be empty (e.g. when obtaining key for eviction) // configuration can be empty (e.g. when obtaining key for eviction)
// so we do not compute the target width/height here // so we do not compute the target width/height here
// and pass it to the key, to use it later for image loading // 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 @override
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) { ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
informationCollector: () sync* { 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 { Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
var uri = key.uri;
var mimeType = key.mimeType;
try { try {
final bytes = await ImageFileService.getThumbnail(key.entry, extent, extent, taskKey: _cancellationKey); final bytes = await ImageFileService.getThumbnail(
if (bytes == null) return null; 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); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with path=${entry.path}, error=$error'); debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
return null; throw StateError('$mimeType decoding failed');
} }
} }
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeThumbnail(_cancellationKey); ImageFileService.resumeThumbnail(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
void pause() => ImageFileService.cancelThumbnail(_cancellationKey); void pause() => ImageFileService.cancelThumbnail(key);
} }
class ThumbnailProviderKey { class ThumbnailProviderKey {
final ImageEntry entry; final String uri, mimeType;
final double extent; final int dateModifiedSecs, rotationDegrees;
final double scale; final bool isFlipped;
final double extent, scale;
// do not access `contentId` via `entry` for hashCode and equality purposes const ThumbnailProviderKey({
// as an entry is not constant and its contentId can change @required this.uri,
final int contentId; @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({ // do not store the entry as it is, because the key should be constant
@required this.entry, // but the entry attributes may change over time
@required this.extent, factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) {
this.scale, return ThumbnailProviderKey(
}) : contentId = entry.contentId; uri: entry.uri,
mimeType: entry.mimeType,
dateModifiedSecs: entry.dateModifiedSecs ?? -1, // can happen in viewer mode
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
extent: extent,
);
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; if (other.runtimeType != runtimeType) return false;
return other is 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 @override
int get hashCode => hashValues(contentId, extent, scale); int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale);
@override @override
String toString() { 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.uri,
@required this.mimeType, @required this.mimeType,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.isFlipped,
this.expectedContentLength, this.expectedContentLength,
this.scale = 1.0, this.scale = 1.0,
}) : assert(uri != null), }) : assert(uri != null),
@ -18,6 +19,7 @@ class UriImage extends ImageProvider<UriImage> {
final String uri, mimeType; final String uri, mimeType;
final int rotationDegrees, expectedContentLength; final int rotationDegrees, expectedContentLength;
final bool isFlipped;
final double scale; final double scale;
@override @override
@ -46,7 +48,8 @@ class UriImage extends ImageProvider<UriImage> {
final bytes = await ImageFileService.getImage( final bytes = await ImageFileService.getImage(
uri, uri,
mimeType, mimeType,
rotationDegrees: rotationDegrees, rotationDegrees,
isFlipped,
expectedContentLength: expectedContentLength, expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) { onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent( 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); return await decode(bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
return null; throw StateError('$mimeType decoding failed');
} finally { } finally {
unawaited(chunkEvents.close()); unawaited(chunkEvents.close());
} }

View file

@ -27,7 +27,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async { Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
assert(key == this); assert(key == this);
final data = await ImageFileService.getImage(uri, mimeType); final data = await ImageFileService.getImage(uri, mimeType, 0, false);
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
return null; 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/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.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/aves_logo.dart';
import 'package:aves/widgets/common/icons.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/collection_tile.dart';
import 'package:aves/widgets/drawer/tile.dart'; import 'package:aves/widgets/drawer/tile.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
@ -225,7 +225,7 @@ class _AppDrawerState extends State<AppDrawer> {
icon: AIcons.debug, icon: AIcons.debug,
title: 'Debug', title: 'Debug',
topLevel: false, topLevel: false,
routeName: DebugPage.routeName, routeName: AppDebugPage.routeName,
pageBuilder: (_) => DebugPage(source: source), pageBuilder: (_) => AppDebugPage(source: source),
); );
} }

View file

@ -62,20 +62,12 @@ class AlbumListPage extends StatelessWidget {
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album); final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
final entriesByDate = source.sortedEntriesForFilterList; final entriesByDate = source.sortedEntriesForFilterList;
switch (settings.albumSortFactor) { // albums are initially sorted by name at the source level
case ChipSortFactor.date: var sortedAlbums = source.sortedAlbums;
final allAlbumMapEntries = source.sortedAlbums.map((album) => MapEntry(
album, if (settings.albumSortFactor == ChipSortFactor.name) {
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>[]; final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in source.sortedAlbums) { for (var album in sortedAlbums) {
if (pinned.contains(album)) { if (pinned.contains(album)) {
pinnedAlbums.add(album); pinnedAlbums.add(album);
} else { } else {
@ -99,5 +91,27 @@ class AlbumListPage extends StatelessWidget {
); );
})); }));
} }
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( return AvesDialog(
content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'), content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'),
actions: [ actions: [
FlatButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()), child: Text('Cancel'.toUpperCase()),
), ),
FlatButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: Text('Delete'.toUpperCase()), child: Text('Delete'.toUpperCase()),
), ),

View file

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

View file

@ -153,6 +153,11 @@ class FilterNavigationPage extends StatelessWidget {
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1; final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key); 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 { class FilterGridPage extends StatelessWidget {

View file

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

View file

@ -39,7 +39,7 @@ class TagListPage extends StatelessWidget {
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
], ],
filterEntries: _getTagEntries(), filterEntries: _getTagEntries(),
filterBuilder: (s) => TagFilter(s), filterBuilder: _buildFilter,
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.tag, icon: AIcons.tag,
text: 'No tags', text: 'No tags',
@ -50,30 +50,33 @@ class TagListPage extends StatelessWidget {
); );
} }
CollectionFilter _buildFilter(String tag) => TagFilter(tag);
Map<String, ImageEntry> _getTagEntries() { Map<String, ImageEntry> _getTagEntries() {
final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag); final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag);
final entriesByDate = source.sortedEntriesForFilterList; final entriesByDate = source.sortedEntriesForFilterList;
final allMapEntries = source.sortedTags // tags are initially sorted by name at the source level
.map((tag) => MapEntry( 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, tag,
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null), entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
)) ));
.toList();
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key)); final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
final pinnedMapEntries = (byPin[true] ?? []); final pinnedMapEntries = (byPin[true] ?? []);
final unpinnedMapEntries = (byPin[false] ?? []); final unpinnedMapEntries = (byPin[false] ?? []);
switch (settings.tagSortFactor) { if (settings.tagSortFactor == ChipSortFactor.date) {
case ChipSortFactor.date:
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
break;
case ChipSortFactor.name:
// already sorted by name at the source level
break;
} }
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]); 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 'dart:math';
import 'package:aves/model/filters/filters.dart'; 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/utils/durations.dart';
import 'package:aves/widgets/collection/collection_page.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/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/image_page.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.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) // when the entry image itself changed (e.g. after rotation)
void _onImageChanged() async { 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 // rebuild to refresh the Image inside ImagePage
setState(() {}); 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, // 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. // 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 // 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 // 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 // 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, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,
); );
child = PhotoView( child = PhotoView(
// key includes size and orientation to refresh when the image is rotated // 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, imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder` // 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 // 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; bool get isVisible => widget.visibleNotifier.value;
// directory names from metadata-extractor // special directory names
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // 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 @override
void initState() { void initState() {
@ -87,13 +87,43 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength); return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength);
} }
final dir = directoriesWithTitle[index - 1 - untitledDirectoryCount]; 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( return AvesExpansionTile(
title: dir.name, title: dir.name,
expandedNotifier: _expandedDirectoryNotifier, expandedNotifier: _expandedDirectoryNotifier,
children: [ children: [
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry), if (prefixChildren.isNotEmpty)
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry), Align(
if (dir.name == videoDirectory) MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry), alignment: AlignmentDirectional.topStart,
child: Wrap(children: prefixChildren),
),
if (thumbnail != null) thumbnail,
Container( Container(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),

View file

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

View file

@ -82,7 +82,8 @@ class FullscreenTopOverlay extends StatelessWidget {
return entry.canEdit; return entry.canEdit;
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
case EntryAction.rotateCW: case EntryAction.rotateCW:
return entry.canRotate; case EntryAction.flip:
return entry.canRotateAndFlip;
case EntryAction.print: case EntryAction.print:
return entry.canPrint; return entry.canPrint;
case EntryAction.openMap: case EntryAction.openMap:
@ -136,6 +137,7 @@ class _TopOverlayRow extends StatelessWidget {
key: Key('entry-menu-button'), key: Key('entry-menu-button'),
itemBuilder: (context) => [ itemBuilder: (context) => [
...inAppActions.map(_buildPopupMenuItem), ...inAppActions.map(_buildPopupMenuItem),
if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(),
PopupMenuDivider(), PopupMenuDivider(),
...externalAppActions.map(_buildPopupMenuItem), ...externalAppActions.map(_buildPopupMenuItem),
if (kDebugMode) ...[ if (kDebugMode) ...[
@ -166,6 +168,7 @@ class _TopOverlayRow extends StatelessWidget {
case EntryAction.rename: case EntryAction.rename:
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
case EntryAction.rotateCW: case EntryAction.rotateCW:
case EntryAction.flip:
case EntryAction.print: case EntryAction.print:
child = IconButton( child = IconButton(
icon: Icon(action.getIcon()), icon: Icon(action.getIcon()),
@ -207,6 +210,7 @@ class _TopOverlayRow extends StatelessWidget {
case EntryAction.rename: case EntryAction.rename:
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
case EntryAction.rotateCW: case EntryAction.rotateCW:
case EntryAction.flip:
case EntryAction.print: case EntryAction.print:
case EntryAction.debug: case EntryAction.debug:
child = MenuRow(text: action.getText(), icon: action.getIcon()); child = MenuRow(text: action.getText(), icon: action.getIcon());
@ -224,6 +228,40 @@ class _TopOverlayRow extends StatelessWidget {
child: child, 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 { class _FavouriteToggler extends StatefulWidget {

View file

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

View file

@ -46,13 +46,13 @@ class _GrantedDirectoriesState extends State<GrantedDirectories> {
children: [ children: [
Expanded(child: Text(path, style: textTheme.caption)), Expanded(child: Text(path, style: textTheme.caption)),
SizedBox(width: 8), SizedBox(width: 8),
OutlineButton( OutlinedButton(
onPressed: () async { onPressed: () async {
await AndroidFileService.revokeDirectoryAccess(path); await AndroidFileService.revokeDirectoryAccess(path);
_load(); _load();
setState(() {}); 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 videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/')));
final mimeDonuts = Wrap( final mimeDonuts = Wrap(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes), _buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes),
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'video', other: 'videos'), videoByMimeTypes), _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'), key: Key('continue-button'),
child: Text('Continue'), child: Text('Continue'),
onPressed: _hasAcceptedTerms 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. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 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): # video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork) # - does not support content URIs (by default, but trivial by fork)