diff --git a/README.md b/README.md
index c937d3a4e..78890487a 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
-

+

## Features
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 5f667681f..3d0d01350 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -109,10 +109,10 @@ dependencies {
// enable support for Java 8 language APIs (stream, optional, etc.)
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
- implementation 'androidx.core:core:1.5.0-alpha03' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
+ implementation 'androidx.core:core:1.5.0-alpha04' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
implementation "androidx.exifinterface:exifinterface:1.3.0"
implementation 'com.commonsware.cwac:document:0.4.1'
- implementation 'com.drewnoakes:metadata-extractor:2.14.0'
+ implementation 'com.drewnoakes:metadata-extractor:2.15.0'
implementation 'com.github.bumptech.glide:glide:4.11.0'
implementation 'com.google.guava:guava:29.0-android'
diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppAdapterHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppAdapterHandler.java
index 2e0ee78f6..0c586276a 100644
--- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppAdapterHandler.java
+++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppAdapterHandler.java
@@ -18,10 +18,9 @@ import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import com.bumptech.glide.Glide;
-import com.bumptech.glide.load.Key;
+import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
-import com.bumptech.glide.signature.ObjectKey;
import java.io.ByteArrayOutputStream;
import java.io.File;
@@ -34,12 +33,12 @@ import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
-import deckers.thibault.aves.utils.Utils;
+import deckers.thibault.aves.utils.LogUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
- private static final String LOG_TAG = Utils.createLogTag(AppAdapterHandler.class);
+ private static final String LOG_TAG = LogUtils.createTag(AppAdapterHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/app";
@@ -173,25 +172,21 @@ public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
.path(String.valueOf(iconResourceId))
.build();
- // add signature to ignore cache for images which got modified but kept the same URI
- Key signature = new ObjectKey(packageName + size);
RequestOptions options = new RequestOptions()
- .signature(signature)
+ .format(DecodeFormat.PREFER_RGB_565)
+ .centerCrop()
.override(size, size);
-
FutureTarget target = Glide.with(context)
.asBitmap()
.apply(options)
- .centerCrop()
.load(uri)
- .signature(signature)
.submit(size, size);
try {
- Bitmap bmp = target.get();
- if (bmp != null) {
+ Bitmap bitmap = target.get();
+ if (bitmap != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
- bmp.compress(Bitmap.CompressFormat.PNG, 100, stream);
+ bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream);
data = stream.toByteArray();
}
} catch (Exception e) {
diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java
index 08c7071e0..9d6e3c64c 100644
--- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java
+++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java
@@ -12,13 +12,11 @@ import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
-import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool;
-import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
-
import java.util.List;
import deckers.thibault.aves.MainActivity;
import deckers.thibault.aves.R;
+import deckers.thibault.aves.utils.BitmapUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
@@ -57,12 +55,15 @@ public class AppShortcutHandler implements MethodChannel.MethodCallHandler {
return;
}
- IconCompat icon;
+ IconCompat icon = null;
if (iconBytes != null && iconBytes.length > 0) {
Bitmap bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length);
- bitmap = TransformationUtils.centerCrop(new LruBitmapPool(2 << 24), bitmap, 256, 256);
- icon = IconCompat.createWithBitmap(bitmap);
- } else {
+ bitmap = BitmapUtils.centerSquareCrop(context, bitmap, 256);
+ if (bitmap != null) {
+ icon = IconCompat.createWithBitmap(bitmap);
+ }
+ }
+ if (icon == null) {
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection);
}
diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java
index 871c73e95..17c021b8d 100644
--- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java
+++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java
@@ -5,19 +5,20 @@ import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.graphics.Bitmap;
+import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.provider.MediaStore;
import android.util.Log;
import android.util.Size;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.bumptech.glide.Glide;
-import com.bumptech.glide.load.Key;
+import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
-import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey;
@@ -27,21 +28,28 @@ import java.io.IOException;
import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.decoder.VideoThumbnail;
-import deckers.thibault.aves.model.AvesImageEntry;
+import deckers.thibault.aves.utils.BitmapUtils;
+import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
-import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodChannel;
public class ImageDecodeTask extends AsyncTask {
- private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class);
+ private static final String LOG_TAG = LogUtils.createTag(ImageDecodeTask.class);
static class Params {
- AvesImageEntry entry;
- Integer width, height, defaultSize;
+ Uri uri;
+ String mimeType;
+ Long dateModifiedSecs;
+ Integer rotationDegrees, width, height, defaultSize;
+ Boolean isFlipped;
MethodChannel.Result result;
- Params(AvesImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
- this.entry = entry;
+ Params(@NonNull String uri, @NonNull String mimeType, @NonNull Long dateModifiedSecs, @NonNull Integer rotationDegrees, @NonNull Boolean isFlipped, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
+ this.uri = Uri.parse(uri);
+ this.mimeType = mimeType;
+ this.dateModifiedSecs = dateModifiedSecs;
+ this.rotationDegrees = rotationDegrees;
+ this.isFlipped = isFlipped;
this.width = width;
this.height = height;
this.result = result;
@@ -80,14 +88,19 @@ public class ImageDecodeTask extends AsyncTask= Build.VERSION_CODES.Q) {
- bitmap = getThumbnailBytesByResolver(p);
- } else {
- bitmap = getThumbnailBytesByMediaStore(p);
+ // EXIF orientations with flipping are not well supported by the Media Store:
+ // the content resolver may return a thumbnail that is automatically rotated
+ // according to EXIF orientation, but not flip it when necessary
+ if (!p.isFlipped) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ bitmap = getThumbnailBytesByResolver(p);
+ } else {
+ bitmap = getThumbnailBytesByMediaStore(p);
+ }
+ } catch (Exception e) {
+ exception = e;
}
- } catch (Exception e) {
- exception = e;
}
}
@@ -100,7 +113,7 @@ public class ImageDecodeTask extends AsyncTask target;
- if (entry.isVideo()) {
+ if (MimeTypes.isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
target = Glide.with(activity)
.asBitmap()
.apply(options)
- .load(new VideoThumbnail(activity, entry.uri))
- .signature(signature)
+ .load(new VideoThumbnail(activity, uri))
.submit(width, height);
} else {
target = Glide.with(activity)
.asBitmap()
.apply(options)
- .load(entry.uri)
- .signature(signature)
+ .load(uri)
.submit(width, height);
}
try {
Bitmap bitmap = target.get();
- String mimeType = entry.mimeType;
if (MimeTypes.needRotationAfterGlide(mimeType)) {
- bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
+ bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
}
return bitmap;
} finally {
@@ -195,24 +204,15 @@ public class ImageDecodeTask extends AsyncTask rotate(call, new MethodResultWrapper(result))).start();
break;
+ case "flip":
+ new Thread(() -> flip(call, new MethodResultWrapper(result))).start();
+ break;
default:
result.notImplemented();
break;
@@ -65,12 +68,16 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
- Map entryMap = call.argument("entry");
+ String uri = call.argument("uri");
+ String mimeType = call.argument("mimeType");
+ Number dateModifiedSecs = (Number)call.argument("dateModifiedSecs");
+ Integer rotationDegrees = call.argument("rotationDegrees");
+ Boolean isFlipped = call.argument("isFlipped");
Double widthDip = call.argument("widthDip");
Double heightDip = call.argument("heightDip");
Double defaultSizeDip = call.argument("defaultSizeDip");
- if (entryMap == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
+ if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
result.error("getThumbnail-args", "failed because of missing arguments", null);
return;
}
@@ -80,8 +87,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
int height = (int) Math.round(heightDip * density);
int defaultSize = (int) Math.round(defaultSizeDip * density);
- AvesImageEntry entry = new AvesImageEntry(entryMap);
- new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result));
+ new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(uri, mimeType, dateModifiedSecs.longValue(), rotationDegrees, isFlipped, width, height, defaultSize, result));
}
private void getObsoleteEntries(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
@@ -167,7 +173,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
result.error("rotate-provider", "failed to find provider for uri=" + uri, null);
return;
}
- provider.rotate(activity, path, uri, mimeType, clockwise, new ImageProvider.ImageOpCallback() {
+ ExifOrientationOp op = clockwise ? ExifOrientationOp.ROTATE_CW : ExifOrientationOp.ROTATE_CCW;
+ provider.changeOrientation(activity, path, uri, mimeType, op, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map 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 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 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()));
+ }
+ });
+ }
}
\ No newline at end of file
diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java
index f257e91bd..ce401086f 100644
--- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java
+++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java
@@ -8,8 +8,8 @@ import android.os.Handler;
import android.os.Looper;
import com.bumptech.glide.Glide;
+import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
-import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
@@ -19,6 +19,7 @@ import java.io.InputStream;
import java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail;
+import deckers.thibault.aves.utils.BitmapUtils;
import deckers.thibault.aves.utils.MimeTypes;
import io.flutter.plugin.common.EventChannel;
@@ -29,6 +30,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private Uri uri;
private String mimeType;
private int rotationDegrees;
+ private boolean isFlipped;
private EventChannel.EventSink eventSink;
private Handler handler;
@@ -40,6 +42,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri"));
this.rotationDegrees = (int) argMap.get("rotationDegrees");
+ this.isFlipped = (boolean) argMap.get("isFlipped");
}
}
@@ -71,9 +74,13 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private void getImage() {
+ // request a fresh image with the highest quality format
+ RequestOptions options = new RequestOptions()
+ .format(DecodeFormat.PREFER_ARGB_8888)
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
+ .skipMemoryCache(true);
+
if (MimeTypes.isVideo(mimeType)) {
- RequestOptions options = new RequestOptions()
- .diskCacheStrategy(DiskCacheStrategy.RESOURCE);
FutureTarget target = Glide.with(activity)
.asBitmap()
.apply(options)
@@ -95,21 +102,27 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally {
Glide.with(activity).clear(target);
}
- } else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees)) {
+ } else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// we convert the image on platform side first, when Dart Image.memory does not support it
FutureTarget target = Glide.with(activity)
.asBitmap()
+ .apply(options)
.load(uri)
.submit();
try {
Bitmap bitmap = target.get();
+ if (MimeTypes.needRotationAfterGlide(mimeType)) {
+ bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
+ }
if (bitmap != null) {
- // TODO TLAD use exif orientation to rotate & flip?
- bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
- // Bitmap.CompressFormat.PNG is slower than JPEG
- bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
+ // Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
+ if (MimeTypes.canHaveAlpha(mimeType)) {
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
+ } else {
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
+ }
success(stream.toByteArray());
} else {
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java
index cdf7fa5c4..b942f556c 100644
--- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java
+++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java
@@ -17,11 +17,11 @@ import java.util.stream.Collectors;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory;
-import deckers.thibault.aves.utils.Utils;
+import deckers.thibault.aves.utils.LogUtils;
import io.flutter.plugin.common.EventChannel;
public class ImageOpStreamHandler implements EventChannel.StreamHandler {
- private static final String LOG_TAG = Utils.createLogTag(ImageOpStreamHandler.class);
+ private static final String LOG_TAG = LogUtils.createTag(ImageOpStreamHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/IntentStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/IntentStreamHandler.java
deleted file mode 100644
index a02446d29..000000000
--- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/IntentStreamHandler.java
+++ /dev/null
@@ -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);
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java
deleted file mode 100644
index f837895d5..000000000
--- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java
+++ /dev/null
@@ -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 knownEntries;
-
- @SuppressWarnings("unchecked")
- public MediaStoreStreamHandler(Context context, Object arguments) {
- this.context = context;
- if (arguments instanceof Map) {
- Map argMap = (Map) arguments;
- this.knownEntries = (Map) 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 result) {
- handler.post(() -> eventSink.success(result));
- }
-
- private void endOfStream() {
- handler.post(() -> eventSink.endOfStream());
- }
-
- void fetchAll() {
- new MediaStoreImageProvider().fetchAll(context, knownEntries, this::success); // 350ms
- endOfStream();
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java
deleted file mode 100644
index 88c630c2d..000000000
--- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java
+++ /dev/null
@@ -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 argMap = (Map) 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());
- }
-}
diff --git a/android/app/src/main/java/deckers/thibault/aves/decoder/AvesAppGlideModule.java b/android/app/src/main/java/deckers/thibault/aves/decoder/AvesAppGlideModule.java
index d6c2c160b..55dac0d89 100644
--- a/android/app/src/main/java/deckers/thibault/aves/decoder/AvesAppGlideModule.java
+++ b/android/app/src/main/java/deckers/thibault/aves/decoder/AvesAppGlideModule.java
@@ -9,19 +9,13 @@ import com.bumptech.glide.Glide;
import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
-import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser;
import com.bumptech.glide.module.AppGlideModule;
-import com.bumptech.glide.request.RequestOptions;
-
-import org.jetbrains.annotations.NotNull;
@GlideModule
public class AvesAppGlideModule extends AppGlideModule {
@Override
- public void applyOptions(@NotNull Context context, @NonNull GlideBuilder builder) {
- builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
-
+ public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
// hide noisy warning (e.g. for images that can't be decoded)
builder.setLogLevel(Log.ERROR);
}
diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java
index b7912001b..68e328d9d 100644
--- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java
+++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java
@@ -1,18 +1,44 @@
package deckers.thibault.aves.model.provider;
import android.content.Context;
+import android.database.Cursor;
import android.net.Uri;
+import android.provider.MediaStore;
import androidx.annotation.NonNull;
+import java.util.HashMap;
+import java.util.Map;
+
import deckers.thibault.aves.model.SourceImageEntry;
class ContentImageProvider extends ImageProvider {
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
- SourceImageEntry entry = new SourceImageEntry(uri, mimeType).fillPreCatalogMetadata(context);
+ Map map = new HashMap<>();
+ map.put("uri", uri.toString());
+ map.put("sourceMimeType", mimeType);
- if (entry.getHasSize() || entry.isSvg()) {
+ String[] projection = {
+ MediaStore.MediaColumns.SIZE,
+ MediaStore.MediaColumns.DISPLAY_NAME,
+ };
+ try {
+ Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
+ if (cursor != null) {
+ if (cursor.moveToNext()) {
+ map.put("sizeBytes", cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)));
+ map.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)));
+ }
+ cursor.close();
+ }
+ } catch (Exception e) {
+ callback.onFailure(e);
+ return;
+ }
+
+ SourceImageEntry entry = new SourceImageEntry(map).fillPreCatalogMetadata(context);
+ if (entry.isSized() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure(new Exception("entry has no size"));
diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java
index a00e780dc..43e00d7c1 100644
--- a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java
+++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java
@@ -8,14 +8,13 @@ import androidx.annotation.NonNull;
import java.io.File;
import deckers.thibault.aves.model.SourceImageEntry;
-import deckers.thibault.aves.utils.FileUtils;
class FileImageProvider extends ImageProvider {
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
SourceImageEntry entry = new SourceImageEntry(uri, mimeType);
- String path = FileUtils.getPathFromUri(context, uri);
+ String path = uri.getPath();
if (path != null) {
try {
File file = new File(path);
@@ -28,7 +27,7 @@ class FileImageProvider extends ImageProvider {
}
entry.fillPreCatalogMetadata(context);
- if (entry.getHasSize() || entry.isSvg()) {
+ if (entry.isSized() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure(new Exception("entry has no size"));
diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java
index d303e7863..d8f60d198 100644
--- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java
+++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java
@@ -23,9 +23,10 @@ import java.util.List;
import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry;
+import deckers.thibault.aves.model.ExifOrientationOp;
+import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
-import deckers.thibault.aves.utils.Utils;
// *** about file access to write/rename/delete
// * primary volume
@@ -37,7 +38,7 @@ import deckers.thibault.aves.utils.Utils;
// from 21/Lollipop, use `DocumentFile` (not `File`) after getting permission to the volume root
public abstract class ImageProvider {
- private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
+ private static final String LOG_TAG = LogUtils.createTag(ImageProvider.class);
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException());
@@ -94,7 +95,7 @@ public abstract class ImageProvider {
}
}
- public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
+ public void changeOrientation(final Context context, final String path, final Uri uri, final String mimeType, final ExifOrientationOp op, final ImageOpCallback callback) {
if (!canEditExif(mimeType)) {
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
return;
@@ -124,7 +125,17 @@ public abstract class ImageProvider {
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL));
}
- exif.rotate(clockwise ? 90 : -90);
+ switch (op) {
+ case ROTATE_CW:
+ exif.rotate(90);
+ break;
+ case ROTATE_CCW:
+ exif.rotate(-90);
+ break;
+ case FLIP:
+ exif.flipHorizontally();
+ break;
+ }
exif.saveAttributes();
// copy the edited temporary file back to the original
@@ -137,26 +148,22 @@ public abstract class ImageProvider {
return;
}
-// ContentResolver contentResolver = context.getContentResolver();
-// ContentValues values = new ContentValues();
-// // from Android Q, media store update needs to be flagged IS_PENDING first
-// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
-// // TODO TLAD catch RecoverableSecurityException
-// contentResolver.update(uri, values, null, null);
-// values.clear();
-// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
-// }
-// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
-// values.put(MediaStore.Images.Media.ORIENTATION, rotationDegrees);
-// // TODO TLAD catch RecoverableSecurityException
-// int updatedRowCount = contentResolver.update(uri, values, null, null);
-// if (updatedRowCount > 0) {
- MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
-// } else {
-// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
-// callback.onSuccess(newFields);
-// }
+ MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> {
+ String[] projection = {MediaStore.MediaColumns.DATE_MODIFIED};
+ try {
+ Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
+ if (cursor != null) {
+ if (cursor.moveToNext()) {
+ newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
+ }
+ cursor.close();
+ }
+ } catch (Exception e) {
+ callback.onFailure(e);
+ return;
+ }
+ callback.onSuccess(newFields);
+ });
}
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java
index 156f28f81..98ea4c551 100644
--- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java
+++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java
@@ -31,12 +31,12 @@ import java.util.stream.Stream;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry;
+import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
-import deckers.thibault.aves.utils.Utils;
public class MediaStoreImageProvider extends ImageProvider {
- private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class);
+ private static final String LOG_TAG = LogUtils.createTag(MediaStoreImageProvider.class);
private static final String[] BASE_PROJECTION = {
MediaStore.MediaColumns._ID,
@@ -437,7 +437,7 @@ public class MediaStoreImageProvider extends ImageProvider {
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
fullPath = destinationDir;
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
- volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
+ volumePath = StorageUtils.getVolumePath(context, destinationDir);
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
}
}
diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java
deleted file mode 100644
index 174f88624..000000000
--- a/android/app/src/main/java/deckers/thibault/aves/utils/FileUtils.java
+++ /dev/null
@@ -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());
- }
-}
diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java
deleted file mode 100644
index 4692a2add..000000000
--- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java
+++ /dev/null
@@ -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 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 getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) {
- return getAccessibleDirs(context).stream().filter(anyPath::startsWith).findFirst();
- }
-
- public static List