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. -Collection screenshotImage screenshotStats screenshot +Collection screenshotImage screenshotStats screenshot ## 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> getInaccessibleDirectories(@NonNull Context context, @NonNull List dirPaths) { - Set accessibleDirs = getAccessibleDirs(context); - - // find set of inaccessible directories for each volume - Map> 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 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 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> inaccessibleDirs = new ArrayList<>(); - StorageManager sm = context.getSystemService(StorageManager.class); - if (sm != null) { - for (Map.Entry> 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 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 = 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 getGrantedDirs(Context context) { - Set grantedDirs = new HashSet<>(); - for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) { - Optional 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 getAccessibleDirs(Context context) { - Set 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; - } - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java deleted file mode 100644 index e2ec2986f..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ /dev/null @@ -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 getVolumePath(@NonNull Context context, @NonNull String anyPath) { - return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst(); - } - - @Nullable - private static Iterator 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 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) - *

- * 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 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 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 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 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 convertDirPathToTreeUri(@NonNull Context context, @NonNull String dirPath) { - Optional 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 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 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 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 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 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); - } - } - } -} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index fdb0bba50..11fdca71d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -12,15 +12,15 @@ import androidx.core.graphics.drawable.IconCompat import app.loup.streams_channel.StreamsChannel import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.streams.* +import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.PermissionManager -import deckers.thibault.aves.utils.Utils import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { companion object { - private val LOG_TAG = Utils.createLogTag(MainActivity::class.java) + private val LOG_TAG = LogUtils.createTag(MainActivity::class.java) const val INTENT_CHANNEL = "deckers.thibault/aves/intent" const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" } @@ -139,7 +139,7 @@ class MainActivity : FlutterActivity() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { - if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) { + if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { val treeUri = data.data if (resultCode != RESULT_OK || treeUri == null) { PermissionManager.onPermissionResult(requestCode, null) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index da9cf4cb1..8f158f27e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -1,8 +1,10 @@ package deckers.thibault.aves.channel.calls +import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.database.Cursor +import android.graphics.Bitmap import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build @@ -12,6 +14,7 @@ import androidx.exifinterface.media.ExifInterface import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPUtils import com.adobe.internal.xmp.properties.XMPPropertyInfo +import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageProcessingException import com.drew.lang.Rational @@ -23,28 +26,36 @@ import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.xmp.XmpDirectory -import deckers.thibault.aves.utils.* -import deckers.thibault.aves.utils.ExifInterfaceHelper.describeAll -import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis -import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt -import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDescription -import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt -import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode -import deckers.thibault.aves.utils.Metadata.isFlippedForExifCode -import deckers.thibault.aves.utils.Metadata.parseVideoMetadataDate -import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeBoolean -import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis -import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDescription -import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt -import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeRational -import deckers.thibault.aves.utils.MimeTypes.getMimeTypeForExtension +import deckers.thibault.aves.metadata.ExifInterfaceHelper +import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll +import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis +import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt +import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper +import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription +import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt +import deckers.thibault.aves.metadata.Metadata +import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode +import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode +import deckers.thibault.aves.metadata.Metadata.parseVideoMetadataDate +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString +import deckers.thibault.aves.metadata.XMP +import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText +import deckers.thibault.aves.utils.BitmapUtils +import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isVideo -import deckers.thibault.aves.utils.XMP.getSafeLocalizedText +import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import java.io.ByteArrayOutputStream import java.io.IOException import java.util.* import kotlin.math.roundToLong @@ -67,7 +78,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") - val uri = Uri.parse(call.argument("uri")) + val uri = call.argument("uri")?.let { Uri.parse(it) } if (mimeType == null || uri == null) { result.error("getAllMetadata-args", "failed because of missing arguments", null) return @@ -79,7 +90,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (isSupportedByMetadataExtractor(mimeType)) { try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) @@ -120,7 +131,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (!foundExif) { // fallback to read EXIF via ExifInterface try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val exif = ExifInterface(input) val allTags = describeAll(exif).toMutableMap() if (foundXmp) { @@ -167,14 +178,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") - val uri = Uri.parse(call.argument("uri")) - val extension = call.argument("extension") + val uri = call.argument("uri")?.let { Uri.parse(it) } if (mimeType == null || uri == null) { result.error("getCatalogMetadata-args", "failed because of missing arguments", null) return } - val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension)) + val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType)) if (isVideo(mimeType)) { metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri)) } @@ -183,14 +193,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(metadataMap) } - private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, extension: String?): Map { + private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String): Map { val metadataMap = HashMap() var foundExif = false if (isSupportedByMetadataExtractor(mimeType)) { try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) @@ -200,14 +210,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`) // `context.getContentResolver().getType()` sometimes return incorrect value // `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` - if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { - val detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) - if (detectedMimeType != null && detectedMimeType != mimeType) { - // file extension is unreliable, but we use it as a tie breaker - val extensionMimeType = extension?.toLowerCase(Locale.ROOT)?.let { getMimeTypeForExtension(it) } - if (extensionMimeType == null || detectedMimeType == extensionMimeType) { - metadataMap[KEY_MIME_TYPE] = detectedMimeType - } + // file extension is unreliable + // in the end, `metadata-extractor` is the most reliable, unless it reports `tiff` + dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { + if (it != MimeTypes.TIFF) { + metadataMap[KEY_MIME_TYPE] = it } } } @@ -278,7 +285,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (!foundExif) { // fallback to read EXIF via ExifInterface try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val exif = ExifInterface(input) exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { @@ -289,7 +296,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees } val latLong = exif.latLong - if (latLong != null && latLong.size == 2) { + if (latLong?.size == 2) { metadataMap[KEY_LATITUDE] = latLong[0] metadataMap[KEY_LONGITUDE] = latLong[1] } @@ -320,22 +327,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) if (locationString != null) { - val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString) - if (locationMatcher.find() && locationMatcher.groupCount() >= 2) { - val latitudeString = locationMatcher.group(1) - val longitudeString = locationMatcher.group(2) - if (latitudeString != null && longitudeString != null) { - try { - val latitude = latitudeString.toDoubleOrNull() ?: 0 - val longitude = longitudeString.toDoubleOrNull() ?: 0 - // keep `0.0` as `0.0`, not `0` - if (latitude != 0.0 || longitude != 0.0) { - metadataMap[KEY_LATITUDE] = latitude - metadataMap[KEY_LONGITUDE] = longitude - } - } catch (e: NumberFormatException) { - // ignore - } + val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString) + if (matcher.find() && matcher.groupCount() >= 2) { + // keep `0.0` as `0.0`, not `0` + val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0 + val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0 + if (latitude != 0.0 || longitude != 0.0) { + metadataMap[KEY_LATITUDE] = latitude + metadataMap[KEY_LONGITUDE] = longitude } } } @@ -350,7 +349,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") - val uri = Uri.parse(call.argument("uri")) + val uri = call.argument("uri")?.let { Uri.parse(it) } if (mimeType == null || uri == null) { result.error("getOverlayMetadata-args", "failed because of missing arguments", null) return @@ -362,7 +361,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { return } try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) { dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it } @@ -382,7 +381,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } } result.success(metadataMap) - } + } ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null) } catch (e: Exception) { result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message) } catch (e: NoClassDefFoundError) { @@ -392,20 +391,27 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") - val uri = Uri.parse(call.argument("uri")) + val uri = call.argument("uri")?.let { Uri.parse(it) } if (mimeType == null || uri == null) { result.error("getContentResolverMetadata-args", "failed because of missing arguments", null) return } - val id = ContentUris.parseId(uri) - var contentUri = when { - isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) - isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) - else -> uri - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - contentUri = MediaStore.setRequireOriginal(contentUri) + var contentUri: Uri = uri + if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) { + try { + val id = ContentUris.parseId(uri) + contentUri = when { + isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) + else -> uri + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentUri = MediaStore.setRequireOriginal(contentUri) + } + } catch (e: NumberFormatException) { + // ignore + } } val cursor = context.contentResolver.query(contentUri, null, null, null, null) @@ -436,21 +442,21 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) { - val uri = Uri.parse(call.argument("uri")) + val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null) return } try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val exif = ExifInterface(input) val metadataMap = HashMap() for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) { metadataMap[tag] = exif.getAttribute(tag) } result.success(metadataMap) - } + } ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null) } catch (e: Exception) { // ExifInterface initialization can fail with a RuntimeException // caused by an internal MediaMetadataRetriever failure @@ -459,7 +465,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) { - val uri = Uri.parse(call.argument("uri")) + val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null) return @@ -483,7 +489,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { - val uri = Uri.parse(call.argument("uri")) + val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { result.error("getEmbeddedPictures-args", "failed because of missing arguments", null) return @@ -505,7 +511,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) { - val uri = Uri.parse(call.argument("uri")) + val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { result.error("getExifThumbnails-args", "failed because of missing arguments", null) return @@ -513,9 +519,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val thumbnails = ArrayList() try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val exif = ExifInterface(input) - exif.thumbnailBytes?.let { thumbnails.add(it) } + val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + exif.thumbnailBitmap?.let { + val bitmap = TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), it, orientation) + if (bitmap != null) { + val stream = ByteArrayOutputStream() + // we compress the bitmap because Dart Image.memory cannot decode the raw bytes + // Bitmap.CompressFormat.PNG is slower than JPEG + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream) + thumbnails.add(stream.toByteArray()) + } + } } } catch (e: Exception) { // ExifInterface initialization can fail with a RuntimeException @@ -526,7 +542,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") - val uri = Uri.parse(call.argument("uri")) + val uri = call.argument("uri")?.let { Uri.parse(it) } if (mimeType == null || uri == null) { result.error("getXmpThumbnails-args", "failed because of missing arguments", null) return @@ -535,7 +551,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { val thumbnails = ArrayList() if (isSupportedByMetadataExtractor(mimeType)) { try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { val xmpMeta = dir.xmpMeta @@ -567,7 +583,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } companion object { - private val LOG_TAG = Utils.createLogTag(MetadataHandler::class.java) + private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java) const val CHANNEL = "deckers.thibault/aves/metadata" // catalog metadata diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt new file mode 100644 index 000000000..caa69e352 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/IntentStreamHandler.kt @@ -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) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt new file mode 100644 index 000000000..0f71b94d6 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -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? = null + + init { + if (arguments is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + knownEntries = arguments["knownEntries"] as Map? + } + } + + 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) { + 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" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt new file mode 100644 index 000000000..98d66f4e1 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -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" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt similarity index 99% rename from android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index 708cee3c6..ab8c97df1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -1,4 +1,4 @@ -package deckers.thibault.aves.utils +package deckers.thibault.aves.metadata import android.util.Log import androidx.exifinterface.media.ExifInterface @@ -8,12 +8,13 @@ import com.drew.metadata.exif.* import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory +import deckers.thibault.aves.utils.LogUtils import java.util.* import kotlin.math.floor import kotlin.math.roundToLong object ExifInterfaceHelper { - private val LOG_TAG = Utils.createLogTag(ExifInterfaceHelper::class.java) + private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java) // ExifInterface always states it has the following attributes // and returns "0" instead of "null" when they are actually missing diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt similarity index 99% rename from android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt index dd5fafbf3..7bcc503b9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt @@ -1,4 +1,4 @@ -package deckers.thibault.aves.utils +package deckers.thibault.aves.metadata import android.content.Context import android.media.MediaFormat diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt similarity index 82% rename from android/app/src/main/kotlin/deckers/thibault/aves/utils/Metadata.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 5fbefbb74..5719dbecc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -1,4 +1,4 @@ -package deckers.thibault.aves.utils +package deckers.thibault.aves.metadata import androidx.exifinterface.media.ExifInterface import java.text.ParseException @@ -34,6 +34,16 @@ object Metadata { else -> false } + @JvmStatic + fun getExifCode(rotationDegrees: Int, isFlipped: Boolean): Int { + return when (rotationDegrees) { + 90 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSVERSE else ExifInterface.ORIENTATION_ROTATE_90 + 180 -> if (isFlipped) ExifInterface.ORIENTATION_FLIP_VERTICAL else ExifInterface.ORIENTATION_ROTATE_180 + 270 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSPOSE else ExifInterface.ORIENTATION_ROTATE_270 + else -> if (isFlipped) ExifInterface.ORIENTATION_FLIP_HORIZONTAL else ExifInterface.ORIENTATION_NORMAL + } + } + // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? @JvmStatic fun parseVideoMetadataDate(metadataDate: String?): Long { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt similarity index 85% rename from android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt index 1a13862a6..ec60169ba 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MetadataExtractorHelper.kt @@ -1,4 +1,4 @@ -package deckers.thibault.aves.utils +package deckers.thibault.aves.metadata import com.drew.lang.Rational import com.drew.metadata.Directory @@ -11,6 +11,10 @@ object MetadataExtractorHelper { if (this.containsTag(tag)) save(this.getDescription(tag)) } + fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) { + if (this.containsTag(tag)) save(this.getString(tag)) + } + fun Directory.getSafeBoolean(tag: Int, save: (value: Boolean) -> Unit) { if (this.containsTag(tag)) save(this.getBoolean(tag)) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt similarity index 53% rename from android/app/src/main/kotlin/deckers/thibault/aves/utils/XMP.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 953c3832b..e68d08f22 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -1,9 +1,13 @@ -package deckers.thibault.aves.utils +package deckers.thibault.aves.metadata +import android.util.Log import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPMeta +import deckers.thibault.aves.utils.LogUtils object XMP { + private val LOG_TAG = LogUtils.createTag(XMP::class.java) + const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" @@ -15,12 +19,15 @@ object XMP { private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" - @Throws(XMPException::class) fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) { - if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) { - val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG) - // double check retrieved items as the property sometimes is reported to exist but it is actually null - if (item != null) save(item.value) + try { + if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) { + val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG) + // double check retrieved items as the property sometimes is reported to exist but it is actually null + if (item != null) save(item.value) + } + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to get text for XMP propName=$propName", e) } } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesImageEntry.kt index a9b6856e7..617fd56d2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesImageEntry.kt @@ -1,7 +1,6 @@ package deckers.thibault.aves.model import android.net.Uri -import deckers.thibault.aves.utils.MimeTypes class AvesImageEntry(map: Map) { @JvmField @@ -21,18 +20,4 @@ class AvesImageEntry(map: Map) { @JvmField val rotationDegrees = map["rotationDegrees"] as Int - - @JvmField - val dateModifiedSecs = toLong(map["dateModifiedSecs"]) - - val isVideo: Boolean - get() = MimeTypes.isVideo(mimeType) - - companion object { - // convenience method - private fun toLong(o: Any?): Long? = when (o) { - is Int -> o.toLong() - else -> o as? Long - } - } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/ExifOrientationOp.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/ExifOrientationOp.kt new file mode 100644 index 000000000..7b7737ce5 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/ExifOrientationOp.kt @@ -0,0 +1,5 @@ +package deckers.thibault.aves.model + +enum class ExifOrientationOp { + ROTATE_CW, ROTATE_CCW, FLIP +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index d517fda35..bf48ab8e3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -14,16 +14,16 @@ import com.drew.metadata.jpeg.JpegDirectory import com.drew.metadata.mp4.Mp4Directory import com.drew.metadata.mp4.media.Mp4VideoDirectory import com.drew.metadata.photoshop.PsdHeaderDirectory -import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis -import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt -import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis -import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt -import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong -import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString -import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis -import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt -import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong -import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode +import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis +import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt +import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis +import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt +import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong +import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString +import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt +import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils import java.io.IOException @@ -101,15 +101,12 @@ class SourceImageEntry { return null } - val hasSize: Boolean + val isSized: Boolean get() = width ?: 0 > 0 && height ?: 0 > 0 private val hasDuration: Boolean get() = durationMillis ?: 0 > 0 - private val isImage: Boolean - get() = MimeTypes.isImage(sourceMimeType) - private val isVideo: Boolean get() = MimeTypes.isVideo(sourceMimeType) @@ -123,15 +120,15 @@ class SourceImageEntry { if (isSvg) return this if (isVideo) { fillVideoByMediaMetadataRetriever(context) - if (hasSize && hasDuration) return this + if (isSized && hasDuration) return this } if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) { fillByMetadataExtractor(context) - if (hasSize && foundExif) return this + if (isSized && foundExif) return this } if (ExifInterface.isSupportedMimeType(sourceMimeType)) { fillByExifInterface(context) - if (hasSize) return this + if (isSized) return this } fillByBitmapDecode(context) return this @@ -158,7 +155,7 @@ class SourceImageEntry { // finds: width, height, orientation, date, duration private fun fillByMetadataExtractor(context: Context) { try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) // do not switch on specific mime types, as the reported mime type could be wrong @@ -209,7 +206,7 @@ class SourceImageEntry { // finds: width, height, orientation, date private fun fillByExifInterface(context: Context) { try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val exif = ExifInterface(input) foundExif = true exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it } @@ -226,7 +223,7 @@ class SourceImageEntry { // finds: width, height private fun fillByBitmapDecode(context: Context) { try { - StorageUtils.openInputStream(context, uri).use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val options = BitmapFactory.Options() options.inJustDecodeBounds = true BitmapFactory.decodeStream(input, null, options) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt new file mode 100644 index 000000000..8c34b6ed9 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt @@ -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 +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/Utils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt similarity index 85% rename from android/app/src/main/kotlin/deckers/thibault/aves/utils/Utils.kt rename to android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt index 3c914314d..266788e88 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/Utils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/LogUtils.kt @@ -2,12 +2,13 @@ package deckers.thibault.aves.utils import java.util.regex.Pattern -object Utils { +object LogUtils { private const val LOG_TAG_MAX_LENGTH = 23 private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.") + // create an Android logger friendly log tag for the specified class @JvmStatic - fun createLogTag(clazz: Class<*>): String { + fun createTag(clazz: Class<*>): String { // shorten class name to "a.b.CccDdd" var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.") if (logTag.length > LOG_TAG_MAX_LENGTH) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 8a9879ef4..93532cf2e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -1,7 +1,5 @@ package deckers.thibault.aves.utils -import java.util.* - object MimeTypes { private const val IMAGE = "image" @@ -12,44 +10,20 @@ object MimeTypes { private const val HEIF = "image/heif" private const val ICO = "image/x-icon" private const val JPEG = "image/jpeg" - private const val PCX = "image/x-pcx" private const val PNG = "image/png" - private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop" - private const val TIFF = "image/tiff" + const val TIFF = "image/tiff" private const val WBMP = "image/vnd.wap.wbmp" const val WEBP = "image/webp" // raw raster - private const val ARW = "image/x-sony-arw" - private const val CR2 = "image/x-canon-cr2" - private const val CRW = "image/x-canon-crw" - private const val DCR = "image/x-kodak-dcr" private const val DNG = "image/x-adobe-dng" - private const val ERF = "image/x-epson-erf" - private const val K25 = "image/x-kodak-k25" - private const val KDC = "image/x-kodak-kdc" - private const val MRW = "image/x-minolta-mrw" - private const val NEF = "image/x-nikon-nef" - private const val NRW = "image/x-nikon-nrw" - private const val ORF = "image/x-olympus-orf" - private const val PEF = "image/x-pentax-pef" - private const val RAF = "image/x-fuji-raf" - private const val RAW = "image/x-panasonic-raw" - private const val RW2 = "image/x-panasonic-rw2" - private const val SR2 = "image/x-sony-sr2" - private const val SRF = "image/x-sony-srf" - private const val SRW = "image/x-samsung-srw" - private const val X3F = "image/x-sigma-x3f" // vector const val SVG = "image/svg+xml" private const val VIDEO = "video" - private const val AVI = "video/avi" - private const val MOV = "video/quicktime" private const val MP2T = "video/mp2t" - private const val MP4 = "video/mp4" private const val WEBM = "video/webm" @JvmStatic @@ -58,11 +32,19 @@ object MimeTypes { @JvmStatic fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) + @JvmStatic + // returns whether the specified MIME type represents + // a raster image format that allows an alpha channel + fun canHaveAlpha(mimeType: String?) = when (mimeType) { + BMP, GIF, ICO, PNG, TIFF, WEBP -> true + else -> false + } + // as of Flutter v1.22.0 @JvmStatic - fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?) = when (mimeType) { + fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) { JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true - PNG -> rotationDegrees ?: 0 == 0 + PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false) else -> false } @@ -75,6 +57,8 @@ object MimeTypes { // Glide automatically applies EXIF orientation when decoding images of known formats // but we need to rotate the decoded bitmap for the other formats + // maybe related to ExifInterface version used by Glide: + // https://github.com/bumptech/glide/blob/master/gradle.properties#L21 @JvmStatic fun needRotationAfterGlide(mimeType: String) = when (mimeType) { DNG, HEIC, HEIF, PNG, WEBP -> true @@ -89,50 +73,4 @@ object MimeTypes { DNG, PNG -> true else -> false } - - @JvmStatic - fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) { - // generic raster - ".bmp" -> BMP - ".gif" -> GIF - ".heic" -> HEIC - ".heif" -> HEIF - ".ico" -> ICO - ".jpg", ".jpeg", ".jpe" -> JPEG - ".pcx" -> PCX - ".png" -> PNG - ".psd" -> PSD - ".tiff", ".tif" -> TIFF - ".wbmp" -> WBMP - ".webp" -> WEBP - // raw raster - ".arw" -> ARW - ".cr2" -> CR2 - ".crw" -> CRW - ".dcr" -> DCR - ".dng" -> DNG - ".erf" -> ERF - ".k25" -> K25 - ".kdc" -> KDC - ".mrw" -> MRW - ".nef" -> NEF - ".nrw" -> NRW - ".orf" -> ORF - ".pef" -> PEF - ".raf" -> RAF - ".raw" -> RAW - ".rw2" -> RW2 - ".sr2" -> SR2 - ".srf" -> SRF - ".srw" -> SRW - ".x3f" -> X3F - // vector - ".svg" -> SVG - // video - ".avi" -> AVI - ".m2ts" -> MP2T - ".mov", ".qt" -> MOV - ".mp4", ".m4a", ".m4p", ".m4b", ".m4r", ".m4v" -> MP4 - else -> null - } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt new file mode 100644 index 000000000..4fd2eff21 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -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() + + @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): List> { + val accessibleDirs = getAccessibleDirs(context) + + // find set of inaccessible directories for each volume + val dirsPerVolume = HashMap>() + 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>() + 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() + 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 { + val grantedDirs = HashSet() + 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 { + 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) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt new file mode 100644 index 000000000..250dd28c5 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -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? = 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 { + 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? { + 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 { + // Final set of paths + val paths = HashSet() + + // 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 + 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 + @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) + } + } + } + } +} \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index a87e9aa15..ca8d0e7ec 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,7 +8,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:3.6.3' // do not upgrade to 4+ until this is fixed: https://github.com/flutter/flutter/issues/58247 classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.google.gms:google-services:4.3.3' + classpath 'com.google.gms:google-services:4.3.4' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0' } } diff --git a/extra/play/screenshots v1.2.1/S10/1-S10-collection.png b/extra/play/screenshots v1.2.1/S10/1-S10-collection.png new file mode 100644 index 000000000..ea99bf07b Binary files /dev/null and b/extra/play/screenshots v1.2.1/S10/1-S10-collection.png differ diff --git a/extra/play/screenshots v1.2.1/S10/2-S10-image.png b/extra/play/screenshots v1.2.1/S10/2-S10-image.png new file mode 100644 index 000000000..723211466 Binary files /dev/null and b/extra/play/screenshots v1.2.1/S10/2-S10-image.png differ diff --git a/extra/play/screenshots v1.2.1/S10/3-S10-info__basic_.png b/extra/play/screenshots v1.2.1/S10/3-S10-info__basic_.png new file mode 100644 index 000000000..b664b270a Binary files /dev/null and b/extra/play/screenshots v1.2.1/S10/3-S10-info__basic_.png differ diff --git a/extra/play/screenshots v1.2.1/S10/4-S10-info__metadata_.png b/extra/play/screenshots v1.2.1/S10/4-S10-info__metadata_.png new file mode 100644 index 000000000..667aa33d6 Binary files /dev/null and b/extra/play/screenshots v1.2.1/S10/4-S10-info__metadata_.png differ diff --git a/extra/play/screenshots v1.2.1/S10/5-S10-stats.png b/extra/play/screenshots v1.2.1/S10/5-S10-stats.png new file mode 100644 index 000000000..8d5f63dbe Binary files /dev/null and b/extra/play/screenshots v1.2.1/S10/5-S10-stats.png differ diff --git a/extra/play/screenshots v1.2.1/S10/6-S10-countries.png b/extra/play/screenshots v1.2.1/S10/6-S10-countries.png new file mode 100644 index 000000000..9c810035c Binary files /dev/null and b/extra/play/screenshots v1.2.1/S10/6-S10-countries.png differ diff --git a/extra/play/screenshots v1.2.1/raw/1 Collection.png b/extra/play/screenshots v1.2.1/raw/1 Collection.png new file mode 100644 index 000000000..1aac95f0f Binary files /dev/null and b/extra/play/screenshots v1.2.1/raw/1 Collection.png differ diff --git a/extra/play/screenshots v1.2.1/raw/2 image.png b/extra/play/screenshots v1.2.1/raw/2 image.png new file mode 100644 index 000000000..ace24ec9d Binary files /dev/null and b/extra/play/screenshots v1.2.1/raw/2 image.png differ diff --git a/extra/play/screenshots v1.2.1/raw/3 Info basic.png b/extra/play/screenshots v1.2.1/raw/3 Info basic.png new file mode 100644 index 000000000..974c8267f Binary files /dev/null and b/extra/play/screenshots v1.2.1/raw/3 Info basic.png differ diff --git a/lib/main.dart b/lib/main.dart index 3d4165dd2..7ccf819e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -65,6 +65,21 @@ class _AvesAppState extends State { ), ), ), + 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(); diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart new file mode 100644 index 000000000..a00303656 --- /dev/null +++ b/lib/model/entry_cache.dart @@ -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 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( + extents, + (extent) => ThumbnailProvider(ThumbnailProviderKey( + uri: uri, + mimeType: mimeType, + dateModifiedSecs: dateModifiedSecs, + rotationDegrees: oldRotationDegrees, + isFlipped: oldIsFlipped, + extent: extent, + )).evict()); + } +} diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 8a517aa2a..0315ea393 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/image_metadata.dart'; +import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/service_policy.dart'; @@ -75,8 +77,8 @@ class ImageEntry { sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, ) - .._catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) - .._addressDetails = _addressDetails?.copyWith(contentId: copyContentId); + ..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) + ..addressDetails = _addressDetails?.copyWith(contentId: copyContentId); return copied; } @@ -154,7 +156,7 @@ class ImageEntry { // the MIME type reported by the Media Store is unreliable // so we use the one found during cataloguing if possible - String get mimeType => catalogMetadata?.mimeType ?? sourceMimeType; + String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType; String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*'); @@ -173,13 +175,11 @@ class ImageEntry { bool get isAnimated => _catalogMetadata?.isAnimated ?? false; - bool get isFlipped => _catalogMetadata?.isFlipped ?? false; - bool get canEdit => path != null; bool get canPrint => !isVideo; - bool get canRotate => canEdit && canEditExif; + bool get canRotateAndFlip => canEdit && canEditExif; // support for writing EXIF // as of androidx.exifinterface:exifinterface:1.3.0 @@ -194,7 +194,7 @@ class ImageEntry { } } - bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.rotationDegrees : rotationDegrees) % 180 == 90; + bool get portrait => rotationDegrees % 180 == 90; double get displayAspectRatio { if (width == 0 || height == 0) return 1; @@ -220,13 +220,17 @@ class ImageEntry { return _bestDate; } - int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; + int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees ?? 0; set rotationDegrees(int rotationDegrees) { sourceRotationDegrees = rotationDegrees; - catalogMetadata?.rotationDegrees = rotationDegrees; + _catalogMetadata?.rotationDegrees = rotationDegrees; } + bool get isFlipped => _catalogMetadata?.isFlipped ?? false; + + set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped; + int get dateModifiedSecs => _dateModifiedSecs; set dateModifiedSecs(int dateModifiedSecs) { @@ -276,10 +280,16 @@ class ImageEntry { } set catalogMetadata(CatalogMetadata newMetadata) { + final oldDateModifiedSecs = dateModifiedSecs; + final oldRotationDegrees = rotationDegrees; + final oldIsFlipped = isFlipped; + catalogDateMillis = newMetadata?.dateMillis; _catalogMetadata = newMetadata; _bestTitle = null; metadataChangeNotifier.notifyListeners(); + + _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); } void clearMetadata() { @@ -351,12 +361,7 @@ class ImageEntry { return false; } - Future rename(String newName) async { - if (newName == filenameWithoutExtension) return true; - - final newFields = await ImageFileService.rename(this, '$newName$extension'); - if (newFields.isEmpty) return false; - + Future _applyNewFields(Map newFields) async { final uri = newFields['uri']; if (uri is String) this.uri = uri; final path = newFields['path']; @@ -365,6 +370,24 @@ class ImageEntry { if (contentId is int) this.contentId = contentId; final sourceTitle = newFields['title']; if (sourceTitle is String) this.sourceTitle = sourceTitle; + final dateModifiedSecs = newFields['dateModifiedSecs']; + if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; + final rotationDegrees = newFields['rotationDegrees']; + if (rotationDegrees is int) this.rotationDegrees = rotationDegrees; + final isFlipped = newFields['isFlipped']; + if (isFlipped is bool) this.isFlipped = isFlipped; + + await metadataDb.saveEntries({this}); + await metadataDb.saveMetadata({catalogMetadata}); + } + + Future rename(String newName) async { + if (newName == filenameWithoutExtension) return true; + + final newFields = await ImageFileService.rename(this, '$newName$extension'); + if (newFields.isEmpty) return false; + + await _applyNewFields(newFields); _bestTitle = null; metadataChangeNotifier.notifyListeners(); return true; @@ -374,14 +397,23 @@ class ImageEntry { final newFields = await ImageFileService.rotate(this, clockwise: clockwise); if (newFields.isEmpty) return false; - final width = newFields['width']; - if (width is int) this.width = width; - final height = newFields['height']; - if (height is int) this.height = height; - final rotationDegrees = newFields['rotationDegrees']; - if (rotationDegrees is int) this.rotationDegrees = rotationDegrees; + final oldDateModifiedSecs = dateModifiedSecs; + final oldRotationDegrees = rotationDegrees; + final oldIsFlipped = isFlipped; + await _applyNewFields(newFields); + await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + return true; + } - imageChangeNotifier.notifyListeners(); + Future flip() async { + final newFields = await ImageFileService.flip(this); + if (newFields.isEmpty) return false; + + final oldDateModifiedSecs = dateModifiedSecs; + final oldRotationDegrees = rotationDegrees; + final oldIsFlipped = isFlipped; + await _applyNewFields(newFields); + await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } @@ -399,6 +431,16 @@ class ImageEntry { return completer.future; } + // when the entry image itself changed (e.g. after rotation) + void _onImageChanged(int oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { + if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { + await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + imageChangeNotifier.notifyListeners(); + } + } + + // favourites + void toggleFavourite() { if (isFavourite) { removeFromFavourites(); @@ -419,18 +461,29 @@ class ImageEntry { } } + // compare by: + // 1) title ascending + // 2) extension ascending static int compareByName(ImageEntry a, ImageEntry b) { final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle); return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension); } + // compare by: + // 1) size descending + // 2) name ascending static int compareBySize(ImageEntry a, ImageEntry b) { final c = b.sizeBytes.compareTo(a.sizeBytes); return c != 0 ? c : compareByName(a, b); } + static final _epoch = DateTime.fromMillisecondsSinceEpoch(0); + + // compare by: + // 1) date descending + // 2) name ascending static int compareByDate(ImageEntry a, ImageEntry b) { - final c = b.bestDate?.compareTo(a.bestDate) ?? -1; + final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch); return c != 0 ? c : compareByName(a, b); } } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index b4fd396da..846d3206a 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -110,7 +110,9 @@ class Settings extends ChangeNotifier { double get collectionTileExtent => _prefs.getDouble(collectionTileExtentKey) ?? 0; - set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue); + // do not notify, as `collectionTileExtent` is only used internally by `TileExtentManager` + // and should not trigger rebuilding by change notification + set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue, notify: false); bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true); @@ -189,7 +191,7 @@ class Settings extends ChangeNotifier { return _prefs.getStringList(key)?.map((s) => values.firstWhere((el) => el.toString() == s, orElse: () => null))?.where((el) => el != null)?.toList() ?? defaultValue; } - void setAndNotify(String key, dynamic newValue) { + void setAndNotify(String key, dynamic newValue, {bool notify = true}) { var oldValue = _prefs.get(key); if (newValue == null) { _prefs.remove(key); @@ -209,7 +211,7 @@ class Settings extends ChangeNotifier { oldValue = _prefs.getBool(key); _prefs.setBool(key, newValue); } - if (oldValue != newValue) { + if (oldValue != newValue && notify) { notifyListeners(); } } diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index 7a49d1fc0..65b52917b 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -1,6 +1,6 @@ enum Activity { browse, select } -enum ChipSortFactor { date, name } +enum ChipSortFactor { date, name, count } enum EntrySortFactor { date, size, name } diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart index 332cd1c13..53b6c8f0c 100644 --- a/lib/services/app_shortcut_service.dart +++ b/lib/services/app_shortcut_service.dart @@ -30,7 +30,15 @@ class AppShortcutService { Uint8List iconBytes; if (iconEntry != null) { final size = iconEntry.isVideo ? 0.0 : 256.0; - iconBytes = await ImageFileService.getThumbnail(iconEntry, size, size); + iconBytes = await ImageFileService.getThumbnail( + iconEntry.uri, + iconEntry.mimeType, + iconEntry.dateModifiedSecs, + iconEntry.rotationDegrees, + iconEntry.isFlipped, + size, + size, + ); } try { await platform.invokeMethod('pin', { diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 2b6097d01..79602b77d 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/mime_types.dart'; import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -24,6 +25,7 @@ class ImageFileService { 'width': entry.width, 'height': entry.height, 'rotationDegrees': entry.rotationDegrees, + 'isFlipped': entry.isFlipped, 'dateModifiedSecs': entry.dateModifiedSecs, }; } @@ -66,7 +68,14 @@ class ImageFileService { return null; } - static Future getImage(String uri, String mimeType, {int rotationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) { + static Future getImage( + String uri, + String mimeType, + int rotationDegrees, + bool isFlipped, { + int expectedContentLength, + BytesReceivedCallback onBytesReceived, + }) { try { final completer = Completer.sync(); final sink = _OutputBuffer(); @@ -75,6 +84,7 @@ class ImageFileService { 'uri': uri, 'mimeType': mimeType, 'rotationDegrees': rotationDegrees ?? 0, + 'isFlipped': isFlipped ?? false, }).listen( (data) { final chunk = data as Uint8List; @@ -103,15 +113,29 @@ class ImageFileService { return Future.sync(() => null); } - static Future getThumbnail(ImageEntry entry, double width, double height, {Object taskKey, int priority}) { - if (entry.isSvg) { + static Future getThumbnail( + String uri, + String mimeType, + int dateModifiedSecs, + int rotationDegrees, + bool isFlipped, + double width, + double height, { + Object taskKey, + int priority, + }) { + if (mimeType == MimeTypes.svg) { return Future.sync(() => null); } return servicePolicy.call( () async { try { final result = await platform.invokeMethod('getThumbnail', { - 'entry': _toPlatformEntryMap(entry), + 'uri': uri, + 'mimeType': mimeType, + 'dateModifiedSecs': dateModifiedSecs, + 'rotationDegrees': rotationDegrees, + 'isFlipped': isFlipped, 'widthDip': width, 'heightDip': height, 'defaultSizeDip': thumbnailDefaultSize, @@ -183,7 +207,7 @@ class ImageFileService { static Future rotate(ImageEntry entry, {@required bool clockwise}) async { try { - // return map with: 'width' 'height' 'rotationDegrees' (all optional) + // return map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('rotate', { 'entry': _toPlatformEntryMap(entry), 'clockwise': clockwise, @@ -194,6 +218,19 @@ class ImageFileService { } return {}; } + + static Future flip(ImageEntry entry) async { + try { + // return map with: 'rotationDegrees' 'isFlipped' + final result = await platform.invokeMethod('flip', { + '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 diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 366beb497..c99b1c9fe 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -43,7 +43,6 @@ class MetadataService { final result = await platform.invokeMethod('getCatalogMetadata', { 'mimeType': entry.mimeType, 'uri': entry.uri, - 'extension': entry.extension, }) as Map; result['contentId'] = entry.contentId; return CatalogMetadata.fromMap(result); diff --git a/lib/widgets/debug_page.dart b/lib/widgets/app_debug_page.dart similarity index 95% rename from lib/widgets/debug_page.dart rename to lib/widgets/app_debug_page.dart index 15399f7ce..b90f2cf65 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/app_debug_page.dart @@ -19,18 +19,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_svg/flutter_svg.dart'; -class DebugPage extends StatefulWidget { +class AppDebugPage extends StatefulWidget { static const routeName = '/debug'; final CollectionSource source; - const DebugPage({this.source}); + const AppDebugPage({this.source}); @override - State createState() => DebugPageState(); + State createState() => AppDebugPageState(); } -class DebugPageState extends State { +class AppDebugPageState extends State { Future _dbFileSizeLoader; Future> _dbEntryLoader; Future> _dbDateLoader; @@ -103,7 +103,7 @@ class DebugPageState extends State { child: Text('Crashlytics'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: FirebaseCrashlytics.instance.crash, child: Text('Crash'), ), @@ -123,7 +123,7 @@ class DebugPageState extends State { child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () { imageCache.clear(); setState(() {}); @@ -138,7 +138,7 @@ class DebugPageState extends State { child: Text('SVG cache: ${PictureProvider.cacheCount} items'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () { PictureProvider.clearCache(); setState(() {}); @@ -153,7 +153,7 @@ class DebugPageState extends State { child: Text('Glide disk cache: ?'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: ImageFileService.clearSizedThumbnailDiskCache, child: Text('Clear'), ), @@ -171,7 +171,7 @@ class DebugPageState extends State { child: Text('DB file size: ${formatFilesize(snapshot.data)}'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () => metadataDb.reset().then((_) => _startDbReport()), child: Text('Reset'), ), @@ -190,7 +190,7 @@ class DebugPageState extends State { child: Text('DB entry rows: ${snapshot.data.length}'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()), child: Text('Clear'), ), @@ -209,7 +209,7 @@ class DebugPageState extends State { child: Text('DB date rows: ${snapshot.data.length}'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()), child: Text('Clear'), ), @@ -228,7 +228,7 @@ class DebugPageState extends State { child: Text('DB metadata rows: ${snapshot.data.length}'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()), child: Text('Clear'), ), @@ -247,7 +247,7 @@ class DebugPageState extends State { child: Text('DB address rows: ${snapshot.data.length}'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()), child: Text('Clear'), ), @@ -266,7 +266,7 @@ class DebugPageState extends State { child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () => favourites.clear().then((_) => _startDbReport()), child: Text('Clear'), ), @@ -288,7 +288,7 @@ class DebugPageState extends State { child: Text('Settings'), ), SizedBox(width: 8), - RaisedButton( + ElevatedButton( onPressed: () => settings.reset().then((_) => setState(() {})), child: Text('Reset'), ), diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index ba9d30766..e9749dd9b 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -43,28 +43,42 @@ class _ThumbnailRasterImageState extends State { @override void initState() { super.initState(); - _initProvider(); + _registerWidget(widget); } @override void didUpdateWidget(ThumbnailRasterImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.entry != entry) { - _pauseProvider(); - _initProvider(); + _unregisterWidget(oldWidget); + _registerWidget(widget); } } @override void dispose() { - _pauseProvider(); + _unregisterWidget(widget); super.dispose(); } + void _registerWidget(ThumbnailRasterImage widget) { + widget.entry.imageChangeNotifier.addListener(_onImageChanged); + _initProvider(); + } + + void _unregisterWidget(ThumbnailRasterImage widget) { + widget.entry.imageChangeNotifier?.removeListener(_onImageChanged); + _pauseProvider(); + } + void _initProvider() { - _fastThumbnailProvider = ThumbnailProvider(entry: entry); + _fastThumbnailProvider = ThumbnailProvider( + ThumbnailProviderKey.fromEntry(entry), + ); if (!entry.isVideo) { - _sizedThumbnailProvider = ThumbnailProvider(entry: entry, extent: requestExtent); + _sizedThumbnailProvider = ThumbnailProvider( + ThumbnailProviderKey.fromEntry(entry, extent: requestExtent), + ); } } @@ -139,6 +153,7 @@ class _ThumbnailRasterImageState extends State { uri: entry.uri, mimeType: entry.mimeType, rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, ); if (imageCache.statusForKey(imageProvider).keepAlive) { @@ -153,4 +168,12 @@ class _ThumbnailRasterImageState extends State { child: image, ); } + + // when the entry image itself changed (e.g. after rotation) + void _onImageChanged() async { + // rebuild to refresh the thumbnails + _pauseProvider(); + _initProvider(); + setState(() {}); + } } diff --git a/lib/widgets/common/action_delegates/add_shortcut_dialog.dart b/lib/widgets/common/action_delegates/add_shortcut_dialog.dart index 7274c273e..f17316720 100644 --- a/lib/widgets/common/action_delegates/add_shortcut_dialog.dart +++ b/lib/widgets/common/action_delegates/add_shortcut_dialog.dart @@ -48,14 +48,14 @@ class _AddShortcutDialogState extends State { onSubmitted: (_) => _submit(context), ), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { - return FlatButton( + return TextButton( onPressed: isValid ? () => _submit(context) : null, child: Text('Add'.toUpperCase()), ); diff --git a/lib/widgets/common/action_delegates/create_album_dialog.dart b/lib/widgets/common/action_delegates/create_album_dialog.dart index 6d9d394c6..498bb4edf 100644 --- a/lib/widgets/common/action_delegates/create_album_dialog.dart +++ b/lib/widgets/common/action_delegates/create_album_dialog.dart @@ -92,14 +92,14 @@ class _CreateAlbumDialogState extends State { ), ], actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { - return FlatButton( + return TextButton( onPressed: isValid ? () => _submit(context) : null, child: Text('Create'.toUpperCase()), ); diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index febec6329..e62b4631e 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -8,7 +8,7 @@ import 'package:aves/widgets/common/action_delegates/rename_entry_dialog.dart'; import 'package:aves/widgets/common/aves_dialog.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; -import 'package:aves/widgets/fullscreen/debug.dart'; +import 'package:aves/widgets/fullscreen/fullscreen_debug_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -60,6 +60,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { case EntryAction.rotateCW: _rotate(context, entry, clockwise: true); break; + case EntryAction.flip: + _flip(context, entry); + break; case EntryAction.setAs: AndroidAppService.setAs(entry.uri, entry.mimeType); break; @@ -76,12 +79,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { final uri = entry.uri; final mimeType = entry.mimeType; final rotationDegrees = entry.rotationDegrees; + final isFlipped = entry.isFlipped; final documentName = entry.bestTitle ?? 'Aves'; final doc = pdf.Document(title: documentName); PdfImage pdfImage; if (entry.isSvg) { - final bytes = await ImageFileService.getImage(uri, mimeType, rotationDegrees: entry.rotationDegrees); + final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped); if (bytes != null && bytes.isNotEmpty) { final svgRoot = await svg.fromSvgBytes(bytes, uri); final viewBox = svgRoot.viewport.viewBox; @@ -101,6 +105,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { uri: uri, mimeType: mimeType, rotationDegrees: rotationDegrees, + isFlipped: isFlipped, ), ); } @@ -113,6 +118,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { } } + Future _flip(BuildContext context, ImageEntry entry) async { + if (!await checkStoragePermission(context, [entry])) return; + + final success = await entry.flip(); + if (!success) showFeedback(context, 'Failed'); + } + Future _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async { if (!await checkStoragePermission(context, [entry])) return; @@ -127,11 +139,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { return AvesDialog( content: Text('Are you sure?'), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), - FlatButton( + TextButton( onPressed: () => Navigator.pop(context, true), child: Text('Delete'.toUpperCase()), ), diff --git a/lib/widgets/common/action_delegates/permission_aware.dart b/lib/widgets/common/action_delegates/permission_aware.dart index b020c627e..18ba08630 100644 --- a/lib/widgets/common/action_delegates/permission_aware.dart +++ b/lib/widgets/common/action_delegates/permission_aware.dart @@ -28,11 +28,11 @@ mixin PermissionAwareMixin { title: 'Storage Volume Access', content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), - FlatButton( + TextButton( onPressed: () => Navigator.pop(context, true), child: Text('OK'.toUpperCase()), ), diff --git a/lib/widgets/common/action_delegates/rename_album_dialog.dart b/lib/widgets/common/action_delegates/rename_album_dialog.dart index 99bd36467..7679fb620 100644 --- a/lib/widgets/common/action_delegates/rename_album_dialog.dart +++ b/lib/widgets/common/action_delegates/rename_album_dialog.dart @@ -54,14 +54,14 @@ class _RenameAlbumDialogState extends State { ); }), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { - return FlatButton( + return TextButton( onPressed: isValid ? () => _submit(context) : null, child: Text('Apply'.toUpperCase()), ); diff --git a/lib/widgets/common/action_delegates/rename_entry_dialog.dart b/lib/widgets/common/action_delegates/rename_entry_dialog.dart index 3fbd6f52d..87d258fc5 100644 --- a/lib/widgets/common/action_delegates/rename_entry_dialog.dart +++ b/lib/widgets/common/action_delegates/rename_entry_dialog.dart @@ -48,14 +48,14 @@ class _RenameEntryDialogState extends State { onSubmitted: (_) => _submit(context), ), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { - return FlatButton( + return TextButton( onPressed: isValid ? () => _submit(context) : null, child: Text('Apply'.toUpperCase()), ); diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index 0c5788712..26ef150a1 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/widgets/collection/collection_actions.dart'; @@ -14,11 +16,14 @@ import 'package:aves/widgets/common/aves_dialog.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; +import 'package:aves/widgets/filter_grids/common/chip_actions.dart'; +import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { final CollectionLens collection; @@ -58,39 +63,49 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { Future _moveSelection(BuildContext context, {@required bool copy}) async { final source = collection.source; + final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source); final destinationAlbum = await Navigator.push( context, MaterialPageRoute( builder: (context) { - return FilterGridPage( - source: source, - appBar: SliverAppBar( - leading: BackButton(), - title: Text(copy ? 'Copy to Album' : 'Move to Album'), - actions: [ - IconButton( - icon: Icon(AIcons.createAlbum), - onPressed: () async { - final newAlbum = await showDialog( - context: context, - builder: (context) => CreateAlbumDialog(), - ); - if (newAlbum != null && newAlbum.isNotEmpty) { - Navigator.pop(context, newAlbum); - } - }, - tooltip: 'Create album', + return Selector( + selector: (context, s) => s.albumSortFactor, + builder: (context, sortFactor, child) { + return FilterGridPage( + source: source, + appBar: SliverAppBar( + leading: BackButton(), + title: Text(copy ? 'Copy to Album' : 'Move to Album'), + actions: [ + IconButton( + icon: Icon(AIcons.createAlbum), + onPressed: () async { + final newAlbum = await showDialog( + context: context, + builder: (context) => CreateAlbumDialog(), + ); + if (newAlbum != null && newAlbum.isNotEmpty) { + Navigator.pop(context, newAlbum); + } + }, + tooltip: 'Create album', + ), + IconButton( + icon: Icon(AIcons.sort), + onPressed: () => chipSetActionDelegate.onActionSelected(context, ChipSetAction.sort), + ), + ], + floating: true, ), - ], - floating: true, - ), - filterEntries: AlbumListPage.getAlbumEntries(source), - filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), - emptyBuilder: () => EmptyContent( - icon: AIcons.album, - text: 'No albums', - ), - onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter)?.album), + filterEntries: AlbumListPage.getAlbumEntries(source), + filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), + emptyBuilder: () => EmptyContent( + icon: AIcons.album, + text: 'No albums', + ), + onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter)?.album), + ); + }, ); }, ), @@ -148,11 +163,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { return AvesDialog( content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), - FlatButton( + TextButton( onPressed: () => Navigator.pop(context, true), child: Text('Delete'.toUpperCase()), ), diff --git a/lib/widgets/common/aves_selection_dialog.dart b/lib/widgets/common/aves_selection_dialog.dart index 1932866ae..5c32ca2e6 100644 --- a/lib/widgets/common/aves_selection_dialog.dart +++ b/lib/widgets/common/aves_selection_dialog.dart @@ -38,7 +38,7 @@ class _AvesSelectionDialogState extends State { title: widget.title, scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), diff --git a/lib/widgets/common/entry_actions.dart b/lib/widgets/common/entry_actions.dart index 47500e61b..2fadd1889 100644 --- a/lib/widgets/common/entry_actions.dart +++ b/lib/widgets/common/entry_actions.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; enum EntryAction { delete, edit, + flip, info, open, openMap, @@ -29,8 +30,6 @@ class EntryActions { EntryAction.share, EntryAction.delete, EntryAction.rename, - EntryAction.rotateCCW, - EntryAction.rotateCW, EntryAction.print, ]; @@ -56,9 +55,11 @@ extension ExtraEntryAction on EntryAction { case EntryAction.rename: return 'Rename'; case EntryAction.rotateCCW: - return 'Rotate left'; + return 'Rotate counterclockwise'; case EntryAction.rotateCW: - return 'Rotate right'; + return 'Rotate clockwise'; + case EntryAction.flip: + return 'Flip horizontally'; case EntryAction.print: return 'Print'; case EntryAction.share: @@ -94,6 +95,8 @@ extension ExtraEntryAction on EntryAction { return AIcons.rotateLeft; case EntryAction.rotateCW: return AIcons.rotateRight; + case EntryAction.flip: + return AIcons.flip; case EntryAction.print: return AIcons.print; case EntryAction.share: diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 40a200769..9e30990c3 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -11,6 +11,7 @@ class AIcons { static const IconData allCollection = Icons.collections_outlined; static const IconData image = Icons.photo_outlined; static const IconData video = Icons.movie_outlined; + static const IconData audio = Icons.audiotrack_outlined; static const IconData vector = Icons.code_outlined; static const IconData android = Icons.android; @@ -34,6 +35,7 @@ class AIcons { static const IconData debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; static const IconData expand = Icons.expand_more_outlined; + static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; static const IconData goUp = Icons.arrow_upward_outlined; diff --git a/lib/widgets/common/image_providers/app_icon_image_provider.dart b/lib/widgets/common/image_providers/app_icon_image_provider.dart index a1a1ff3a4..06d21092c 100644 --- a/lib/widgets/common/image_providers/app_icon_image_provider.dart +++ b/lib/widgets/common/image_providers/app_icon_image_provider.dart @@ -39,11 +39,13 @@ class AppIconImage extends ImageProvider { Future _loadAsync(AppIconImageKey key, DecoderCallback decode) async { try { final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size); - if (bytes == null) return null; + if (bytes == null) { + throw StateError('$packageName app icon loading failed'); + } return await decode(bytes); } catch (error) { debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error'); - return null; + throw StateError('$packageName app icon decoding failed'); } } } diff --git a/lib/widgets/common/image_providers/thumbnail_provider.dart b/lib/widgets/common/image_providers/thumbnail_provider.dart index 328b37f42..90e4a8416 100644 --- a/lib/widgets/common/image_providers/thumbnail_provider.dart +++ b/lib/widgets/common/image_providers/thumbnail_provider.dart @@ -6,93 +6,108 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class ThumbnailProvider extends ImageProvider { - ThumbnailProvider({ - @required this.entry, - this.extent = 0, - this.scale = 1, - }) : assert(entry != null), - assert(extent != null), - assert(scale != null) { - _cancellationKey = _buildKey(ImageConfiguration.empty); - } + final ThumbnailProviderKey key; - final ImageEntry entry; - final double extent; - final double scale; - - Object _cancellationKey; + ThumbnailProvider(this.key) : assert(key != null); @override Future obtainKey(ImageConfiguration configuration) { // configuration can be empty (e.g. when obtaining key for eviction) // so we do not compute the target width/height here // and pass it to the key, to use it later for image loading - return SynchronousFuture(_buildKey(configuration)); + return SynchronousFuture(key); } - ThumbnailProviderKey _buildKey(ImageConfiguration configuration) => ThumbnailProviderKey( - entry: entry, - extent: extent, - scale: scale, - ); - @override ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) { return MultiFrameImageStreamCompleter( codec: _loadAsync(key, decode), scale: key.scale, informationCollector: () sync* { - yield ErrorDescription('uri=${entry.uri}, extent=$extent'); + yield ErrorDescription('uri=${key.uri}, extent=${key.extent}'); }, ); } Future _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { + var uri = key.uri; + var mimeType = key.mimeType; try { - final bytes = await ImageFileService.getThumbnail(key.entry, extent, extent, taskKey: _cancellationKey); - if (bytes == null) return null; + final bytes = await ImageFileService.getThumbnail( + uri, + mimeType, + key.dateModifiedSecs, + key.rotationDegrees, + key.isFlipped, + key.extent, + key.extent, + taskKey: key, + ); + if (bytes == null) { + throw StateError('$uri ($mimeType) loading failed'); + } return await decode(bytes); } catch (error) { - debugPrint('$runtimeType _loadAsync failed with path=${entry.path}, error=$error'); - return null; + debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error'); + throw StateError('$mimeType decoding failed'); } } @override void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { - ImageFileService.resumeThumbnail(_cancellationKey); + ImageFileService.resumeThumbnail(key); super.resolveStreamForKey(configuration, stream, key, handleError); } - void pause() => ImageFileService.cancelThumbnail(_cancellationKey); + void pause() => ImageFileService.cancelThumbnail(key); } class ThumbnailProviderKey { - final ImageEntry entry; - final double extent; - final double scale; + final String uri, mimeType; + final int dateModifiedSecs, rotationDegrees; + final bool isFlipped; + final double extent, scale; - // do not access `contentId` via `entry` for hashCode and equality purposes - // as an entry is not constant and its contentId can change - final int contentId; + const ThumbnailProviderKey({ + @required this.uri, + @required this.mimeType, + @required this.dateModifiedSecs, + @required this.rotationDegrees, + @required this.isFlipped, + this.extent = 0, + this.scale = 1, + }) : assert(uri != null), + assert(mimeType != null), + assert(dateModifiedSecs != null), + assert(rotationDegrees != null), + assert(isFlipped != null), + assert(extent != null), + assert(scale != null); - ThumbnailProviderKey({ - @required this.entry, - @required this.extent, - this.scale, - }) : contentId = entry.contentId; + // do not store the entry as it is, because the key should be constant + // but the entry attributes may change over time + factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) { + return ThumbnailProviderKey( + uri: entry.uri, + mimeType: entry.mimeType, + dateModifiedSecs: entry.dateModifiedSecs ?? -1, // can happen in viewer mode + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + extent: extent, + ); + } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is ThumbnailProviderKey && other.contentId == contentId && other.extent == extent && other.scale == scale; + return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.scale == scale; } @override - int get hashCode => hashValues(contentId, extent, scale); + int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale); @override String toString() { - return 'ThumbnailProviderKey{contentId=$contentId, extent=$extent, scale=$scale}'; + return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}'; } } diff --git a/lib/widgets/common/image_providers/uri_image_provider.dart b/lib/widgets/common/image_providers/uri_image_provider.dart index afd08b802..fae29b80b 100644 --- a/lib/widgets/common/image_providers/uri_image_provider.dart +++ b/lib/widgets/common/image_providers/uri_image_provider.dart @@ -11,6 +11,7 @@ class UriImage extends ImageProvider { @required this.uri, @required this.mimeType, @required this.rotationDegrees, + @required this.isFlipped, this.expectedContentLength, this.scale = 1.0, }) : assert(uri != null), @@ -18,6 +19,7 @@ class UriImage extends ImageProvider { final String uri, mimeType; final int rotationDegrees, expectedContentLength; + final bool isFlipped; final double scale; @override @@ -46,7 +48,8 @@ class UriImage extends ImageProvider { final bytes = await ImageFileService.getImage( uri, mimeType, - rotationDegrees: rotationDegrees, + rotationDegrees, + isFlipped, expectedContentLength: expectedContentLength, onBytesReceived: (cumulative, total) { chunkEvents.add(ImageChunkEvent( @@ -55,11 +58,13 @@ class UriImage extends ImageProvider { )); }, ); - if (bytes == null) return null; + if (bytes == null) { + throw StateError('$uri ($mimeType) loading failed'); + } return await decode(bytes); } catch (error) { debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); - return null; + throw StateError('$mimeType decoding failed'); } finally { unawaited(chunkEvents.close()); } diff --git a/lib/widgets/common/image_providers/uri_picture_provider.dart b/lib/widgets/common/image_providers/uri_picture_provider.dart index 6a958406b..f2e86ddaa 100644 --- a/lib/widgets/common/image_providers/uri_picture_provider.dart +++ b/lib/widgets/common/image_providers/uri_picture_provider.dart @@ -27,7 +27,7 @@ class UriPicture extends PictureProvider { Future _loadAsync(UriPicture key, {PictureErrorListener onError}) async { assert(key == this); - final data = await ImageFileService.getImage(uri, mimeType); + final data = await ImageFileService.getImage(uri, mimeType, 0, false); if (data == null || data.isEmpty) { return null; } diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index 2f52c00a5..43cfa5f57 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -10,9 +10,9 @@ import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/about/about_page.dart'; +import 'package:aves/widgets/app_debug_page.dart'; import 'package:aves/widgets/common/aves_logo.dart'; import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/debug_page.dart'; import 'package:aves/widgets/drawer/collection_tile.dart'; import 'package:aves/widgets/drawer/tile.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; @@ -225,7 +225,7 @@ class _AppDrawerState extends State { icon: AIcons.debug, title: 'Debug', topLevel: false, - routeName: DebugPage.routeName, - pageBuilder: (_) => DebugPage(source: source), + routeName: AppDebugPage.routeName, + pageBuilder: (_) => AppDebugPage(source: source), ); } diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 6a4c104cb..b10093d0e 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -62,42 +62,56 @@ class AlbumListPage extends StatelessWidget { final pinned = settings.pinnedFilters.whereType().map((f) => f.album); final entriesByDate = source.sortedEntriesForFilterList; - switch (settings.albumSortFactor) { - case ChipSortFactor.date: - final allAlbumMapEntries = source.sortedAlbums.map((album) => MapEntry( - album, - entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null), - )); - final byPin = groupBy, 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 = [], regularAlbums = [], appAlbums = [], specialAlbums = []; - for (var album in source.sortedAlbums) { - if (pinned.contains(album)) { - pinnedAlbums.add(album); - } else { - switch (androidFileUtils.getAlbumType(album)) { - case AlbumType.regular: - regularAlbums.add(album); - break; - case AlbumType.app: - appAlbums.add(album); - break; - default: - specialAlbums.add(album); - break; - } + // albums are initially sorted by name at the source level + var sortedAlbums = source.sortedAlbums; + + if (settings.albumSortFactor == ChipSortFactor.name) { + final pinnedAlbums = [], regularAlbums = [], appAlbums = [], specialAlbums = []; + for (var album in sortedAlbums) { + if (pinned.contains(album)) { + pinnedAlbums.add(album); + } else { + switch (androidFileUtils.getAlbumType(album)) { + case AlbumType.regular: + regularAlbums.add(album); + break; + case AlbumType.app: + appAlbums.add(album); + break; + default: + specialAlbums.add(album); + break; } } - return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) { - return MapEntry( - album, - entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null), - ); - })); + } + return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) { + return MapEntry( + album, + entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null), + ); + })); } + + if (settings.albumSortFactor == ChipSortFactor.count) { + CollectionFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album)); + var filtersWithCount = List.of(sortedAlbums.map((s) => MapEntry(s, source.count(_buildFilter(s))))); + filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); + sortedAlbums = filtersWithCount.map((kv) => kv.key).toList(); + } + + final allMapEntries = sortedAlbums.map((album) => MapEntry( + album, + entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null), + )); + final byPin = groupBy, 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]); } } diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index d20e479de..135443064 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -65,11 +65,11 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per return AvesDialog( content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'), actions: [ - FlatButton( + TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.toUpperCase()), ), - FlatButton( + TextButton( onPressed: () => Navigator.pop(context, true), child: Text('Delete'.toUpperCase()), ), diff --git a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart index 5112d7396..974f18018 100644 --- a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart @@ -46,6 +46,7 @@ abstract class ChipSetActionDelegate { options: { ChipSortFactor.date: 'By date', ChipSortFactor.name: 'By name', + ChipSortFactor.count: 'By entry count', }, title: 'Sort', ), diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 89e6db5af..82bcc9084 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -153,6 +153,11 @@ class FilterNavigationPage extends StatelessWidget { final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1; return c != 0 ? c : compareAsciiUpperCase(a.key, b.key); } + + static int compareChipsByEntryCount(MapEntry a, MapEntry b) { + final c = b.value.compareTo(a.value) ?? -1; + return c != 0 ? c : compareAsciiUpperCase(a.key, b.key); + } } class FilterGridPage extends StatelessWidget { diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index fae95bd1a..170c837f0 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -39,7 +39,7 @@ class CountryListPage extends StatelessWidget { settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, ], filterEntries: _getCountryEntries(), - filterBuilder: (s) => LocationFilter(LocationLevel.country, s), + filterBuilder: _buildFilter, emptyBuilder: () => EmptyContent( icon: AIcons.location, text: 'No countries', @@ -50,12 +50,22 @@ class CountryListPage extends StatelessWidget { ); } + CollectionFilter _buildFilter(String location) => LocationFilter(LocationLevel.country, location); + Map _getCountryEntries() { final pinned = settings.pinnedFilters.whereType().map((f) => f.countryNameAndCode); final entriesByDate = source.sortedEntriesForFilterList; + // countries are initially sorted by name at the source level + var sortedCountries = source.sortedCountries; + if (settings.countrySortFactor == ChipSortFactor.count) { + var filtersWithCount = List.of(sortedCountries.map((s) => MapEntry(s, source.count(_buildFilter(s))))); + filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); + sortedCountries = filtersWithCount.map((kv) => kv.key).toList(); + } + final locatedEntries = entriesByDate.where((entry) => entry.isLocated); - final allMapEntries = source.sortedCountries.map((countryNameAndCode) { + final allMapEntries = sortedCountries.map((countryNameAndCode) { final split = countryNameAndCode.split(LocationFilter.locationSeparator); ImageEntry entry; if (split.length > 1) { @@ -63,21 +73,16 @@ class CountryListPage extends StatelessWidget { entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null); } return MapEntry(countryNameAndCode, entry); - }).toList(); - + }); final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); final pinnedMapEntries = (byPin[true] ?? []); final unpinnedMapEntries = (byPin[false] ?? []); - switch (settings.countrySortFactor) { - case ChipSortFactor.date: - pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - break; - case ChipSortFactor.name: - // already sorted by name at the source level - break; + if (settings.countrySortFactor == ChipSortFactor.date) { + pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); + unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); } + return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]); } } diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 0efce6eca..97ebc9468 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -39,7 +39,7 @@ class TagListPage extends StatelessWidget { settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, ], filterEntries: _getTagEntries(), - filterBuilder: (s) => TagFilter(s), + filterBuilder: _buildFilter, emptyBuilder: () => EmptyContent( icon: AIcons.tag, text: 'No tags', @@ -50,30 +50,33 @@ class TagListPage extends StatelessWidget { ); } + CollectionFilter _buildFilter(String tag) => TagFilter(tag); + Map _getTagEntries() { final pinned = settings.pinnedFilters.whereType().map((f) => f.tag); final entriesByDate = source.sortedEntriesForFilterList; - final allMapEntries = source.sortedTags - .map((tag) => MapEntry( - tag, - entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null), - )) - .toList(); + // tags are initially sorted by name at the source level + var sortedTags = source.sortedTags; + if (settings.tagSortFactor == ChipSortFactor.count) { + var filtersWithCount = List.of(sortedTags.map((s) => MapEntry(s, source.count(_buildFilter(s))))); + filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount); + sortedTags = filtersWithCount.map((kv) => kv.key).toList(); + } + final allMapEntries = sortedTags.map((tag) => MapEntry( + tag, + entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null), + )); final byPin = groupBy, bool>(allMapEntries, (e) => pinned.contains(e.key)); final pinnedMapEntries = (byPin[true] ?? []); final unpinnedMapEntries = (byPin[false] ?? []); - switch (settings.tagSortFactor) { - case ChipSortFactor.date: - pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); - break; - case ChipSortFactor.name: - // already sorted by name at the source level - break; + if (settings.tagSortFactor == ChipSortFactor.date) { + pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); + unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate); } + return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]); } } diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart deleted file mode 100644 index 03d7ebebc..000000000 --- a/lib/widgets/fullscreen/debug.dart +++ /dev/null @@ -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 { - Future _dbDateLoader; - Future _dbMetadataLoader; - Future _dbAddressLoader; - Future _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(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( - 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( - 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( - 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 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( - future: _contentResolverMetadataLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'), - ), - FutureBuilder( - future: _exifInterfaceMetadataLoader, - builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'), - ), - FutureBuilder( - 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(() {}); - } -} diff --git a/lib/widgets/fullscreen/debug/db.dart b/lib/widgets/fullscreen/debug/db.dart new file mode 100644 index 000000000..3b085e300 --- /dev/null +++ b/lib/widgets/fullscreen/debug/db.dart @@ -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 { + Future _dbDateLoader; + Future _dbEntryLoader; + Future _dbMetadataLoader; + Future _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( + 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( + 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( + 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( + 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}', + }), + ], + ); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/fullscreen/debug/metadata.dart b/lib/widgets/fullscreen/debug/metadata.dart new file mode 100644 index 000000000..fe097617c --- /dev/null +++ b/lib/widgets/fullscreen/debug/metadata.dart @@ -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 { + Future _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 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( + future: _contentResolverMetadataLoader, + builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'), + ), + FutureBuilder( + future: _exifInterfaceMetadataLoader, + builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'), + ), + FutureBuilder( + future: _mediaMetadataLoader, + builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'), + ), + ], + ); + } +} diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 436030570..840fb8e71 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:math'; import 'package:aves/model/filters/filters.dart'; @@ -10,8 +9,6 @@ import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart'; -import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; -import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/fullscreen/image_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/overlay/bottom.dart'; @@ -541,19 +538,6 @@ class _FullscreenVerticalPageViewState extends State // when the entry image itself changed (e.g. after rotation) void _onImageChanged() async { - await UriImage( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, - ).evict(); - // evict low quality thumbnail (without specified extents) - await ThumbnailProvider(entry: entry).evict(); - // evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents) - final extents = List.generate(6, (index) => pow(2, index + 5).toDouble()); - await Future.forEach(extents, (extent) => ThumbnailProvider(entry: entry, extent: extent).evict()); - - await ThumbnailProvider(entry: entry).evict(); - if (entry.path != null) await FileImage(File(entry.path)).evict(); // rebuild to refresh the Image inside ImagePage setState(() {}); } diff --git a/lib/widgets/fullscreen/fullscreen_debug_page.dart b/lib/widgets/fullscreen/fullscreen_debug_page.dart new file mode 100644 index 000000000..dc4ce5786 --- /dev/null +++ b/lib/widgets/fullscreen/fullscreen_debug_page.dart @@ -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(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), + ), + ), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index a77dca114..e92c2b61a 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -57,7 +57,7 @@ class ImageView extends StatelessWidget { // if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided, // there's a black frame between the hero animation and the final image, even when it's cached. - final fastThumbnailProvider = ThumbnailProvider(entry: entry); + final fastThumbnailProvider = ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry)); // this loading builder shows a transition image until the final image is ready // if the image is already in the cache it will show the final image, otherwise the thumbnail // in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image @@ -98,11 +98,12 @@ class ImageView extends StatelessWidget { uri: entry.uri, mimeType: entry.mimeType, rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, ); child = PhotoView( // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.rotationDegrees}_${entry.width}_${entry.height}_${entry.path}'), + key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), imageProvider: uriImage, // when the full image is ready, we use it in the `loadingBuilder` // we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart index 4096c050e..6c89cad41 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -33,10 +33,10 @@ class _MetadataSectionSliverState extends State with Auto bool get isVisible => widget.visibleNotifier.value; - // directory names from metadata-extractor + // special directory names static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor static const xmpDirectory = 'XMP'; // from metadata-extractor - static const videoDirectory = 'Video'; // additional generic video directory + static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory @override void initState() { @@ -87,13 +87,43 @@ class _MetadataSectionSliverState extends State with Auto return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength); } final dir = directoriesWithTitle[index - 1 - untitledDirectoryCount]; + Widget thumbnail; + final prefixChildren = []; + switch (dir.name) { + case exifThumbnailDirectory: + thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); + break; + case xmpDirectory: + thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); + break; + case mediaDirectory: + thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry); + Widget builder(IconData data) => Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Icon(data), + ); + if (dir.tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video)); + if (dir.tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio)); + if (dir.tags['Has Image'] == 'yes') { + int count; + if (dir.tags.containsKey('Image Count')) { + count = int.tryParse(dir.tags['Image Count']); + } + prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image))); + } + break; + } + return AvesExpansionTile( title: dir.name, expandedNotifier: _expandedDirectoryNotifier, children: [ - if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry), - if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry), - if (dir.name == videoDirectory) MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry), + if (prefixChildren.isNotEmpty) + Align( + alignment: AlignmentDirectional.topStart, + child: Wrap(children: prefixChildren), + ), + if (thumbnail != null) thumbnail, Container( alignment: Alignment.topLeft, padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), diff --git a/lib/widgets/fullscreen/info/metadata_thumbnail.dart b/lib/widgets/fullscreen/info/metadata_thumbnail.dart index f7475e0c2..5a13d63dd 100644 --- a/lib/widgets/fullscreen/info/metadata_thumbnail.dart +++ b/lib/widgets/fullscreen/info/metadata_thumbnail.dart @@ -50,19 +50,15 @@ class _MetadataThumbnailsState extends State { future: _loader, builder: (context, snapshot) { if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) { - final turns = (entry.rotationDegrees / 90).round(); final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; return Container( alignment: AlignmentDirectional.topStart, padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4), child: Wrap( children: snapshot.data.map((bytes) { - return RotatedBox( - quarterTurns: turns, - child: Image.memory( - bytes, - scale: devicePixelRatio, - ), + return Image.memory( + bytes, + scale: devicePixelRatio, ); }).toList(), ), diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index ab648cc10..a67555a03 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -82,7 +82,8 @@ class FullscreenTopOverlay extends StatelessWidget { return entry.canEdit; case EntryAction.rotateCCW: case EntryAction.rotateCW: - return entry.canRotate; + case EntryAction.flip: + return entry.canRotateAndFlip; case EntryAction.print: return entry.canPrint; case EntryAction.openMap: @@ -136,6 +137,7 @@ class _TopOverlayRow extends StatelessWidget { key: Key('entry-menu-button'), itemBuilder: (context) => [ ...inAppActions.map(_buildPopupMenuItem), + if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(), PopupMenuDivider(), ...externalAppActions.map(_buildPopupMenuItem), if (kDebugMode) ...[ @@ -166,6 +168,7 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.rename: case EntryAction.rotateCCW: case EntryAction.rotateCW: + case EntryAction.flip: case EntryAction.print: child = IconButton( icon: Icon(action.getIcon()), @@ -207,6 +210,7 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.rename: case EntryAction.rotateCCW: case EntryAction.rotateCW: + case EntryAction.flip: case EntryAction.print: case EntryAction.debug: child = MenuRow(text: action.getText(), icon: action.getIcon()); @@ -224,6 +228,40 @@ class _TopOverlayRow extends StatelessWidget { child: child, ); } + + PopupMenuItem _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 { diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index e28bec1fc..d98aa2073 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -80,7 +80,7 @@ class AvesVideoState extends State { color: Colors.black, ); - final degree = entry.catalogMetadata?.rotationDegrees ?? 0; + final degree = entry.rotationDegrees ?? 0; if (degree != 0) { child = RotatedBox( quarterTurns: degree ~/ 90, @@ -102,6 +102,7 @@ class AvesVideoState extends State { uri: entry.uri, mimeType: entry.mimeType, rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, expectedContentLength: entry.sizeBytes, ), width: entry.width.toDouble(), diff --git a/lib/widgets/settings/access_grants.dart b/lib/widgets/settings/access_grants.dart index 69e03ade7..a58a588c7 100644 --- a/lib/widgets/settings/access_grants.dart +++ b/lib/widgets/settings/access_grants.dart @@ -46,13 +46,13 @@ class _GrantedDirectoriesState extends State { children: [ Expanded(child: Text(path, style: textTheme.caption)), SizedBox(width: 8), - OutlineButton( + OutlinedButton( onPressed: () async { await AndroidFileService.revokeDirectoryAccess(path); _load(); setState(() {}); }, - child: Text('Revoke'), + child: Text('Revoke'.toUpperCase()), ), ], )), diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 3ac7bcf85..f2a7dbaa3 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -64,6 +64,7 @@ class StatsPage extends StatelessWidget { final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/'))); final mimeDonuts = Wrap( alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, children: [ _buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes), _buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'video', other: 'videos'), videoByMimeTypes), diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index bbe6be436..31a6921c1 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -110,7 +110,7 @@ class _WelcomePageState extends State { ], ); - final button = RaisedButton( + final button = ElevatedButton( key: Key('continue-button'), child: Text('Continue'), onPressed: _hasAcceptedTerms diff --git a/pubspec.yaml b/pubspec.yaml index 86436b480..7efa6e8f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.2.1+27 +version: 1.2.2+28 # video_player (as of v0.10.8+2, backed by ExoPlayer): # - does not support content URIs (by default, but trivial by fork)