From 80d95608a1ac9f2829256f6aa52e04a9dd7346aa Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 13 Oct 2020 16:20:58 +0900 Subject: [PATCH] flip --- .../aves/channel/calls/AppAdapterHandler.java | 4 +- .../channel/calls/AppShortcutHandler.java | 15 +-- .../aves/channel/calls/ImageDecodeTask.java | 31 +++--- .../aves/channel/calls/ImageFileHandler.java | 41 +++++++- .../streams/ImageByteStreamHandler.java | 11 ++- .../channel/streams/ImageOpStreamHandler.java | 4 +- .../aves/decoder/AvesAppGlideModule.java | 4 +- .../aves/model/provider/ImageProvider.java | 55 ++++++----- .../provider/MediaStoreImageProvider.java | 4 +- .../deckers/thibault/aves/MainActivity.kt | 4 +- .../aves/channel/calls/MetadataHandler.kt | 58 ++++++----- .../ExifInterfaceHelper.kt | 5 +- .../MediaMetadataRetrieverHelper.kt | 2 +- .../aves/{utils => metadata}/Metadata.kt | 12 ++- .../MetadataExtractorHelper.kt | 2 +- .../thibault/aves/{utils => metadata}/XMP.kt | 5 +- .../thibault/aves/model/ExifOrientationOp.kt | 5 + .../thibault/aves/model/SourceImageEntry.kt | 20 ++-- .../thibault/aves/utils/BitmapUtils.kt | 25 +++++ .../aves/utils/{Utils.kt => LogUtils.kt} | 5 +- .../deckers/thibault/aves/utils/MimeTypes.kt | 6 +- .../thibault/aves/utils/PermissionManager.kt | 4 +- .../thibault/aves/utils/StorageUtils.kt | 4 +- lib/model/entry_cache.dart | 21 +++- lib/model/image_entry.dart | 73 +++++++++----- lib/services/app_shortcut_service.dart | 10 +- lib/services/image_file_service.dart | 30 +++++- .../{debug_page.dart => app_debug_page.dart} | 8 +- lib/widgets/collection/thumbnail/raster.dart | 10 +- .../entry_action_delegate.dart | 16 +++- lib/widgets/common/entry_actions.dart | 6 ++ lib/widgets/common/icons.dart | 1 + .../image_providers/thumbnail_provider.dart | 96 ++++++++++--------- .../image_providers/uri_image_provider.dart | 5 +- .../image_providers/uri_picture_provider.dart | 2 +- lib/widgets/drawer/app_drawer.dart | 6 +- ...{debug.dart => fullscreen_debug_page.dart} | 44 +++++++-- lib/widgets/fullscreen/image_view.dart | 7 +- lib/widgets/fullscreen/overlay/top.dart | 5 +- lib/widgets/fullscreen/video_view.dart | 1 + 40 files changed, 441 insertions(+), 226 deletions(-) rename android/app/src/main/kotlin/deckers/thibault/aves/{utils => metadata}/ExifInterfaceHelper.kt (99%) rename android/app/src/main/kotlin/deckers/thibault/aves/{utils => metadata}/MediaMetadataRetrieverHelper.kt (99%) rename android/app/src/main/kotlin/deckers/thibault/aves/{utils => metadata}/Metadata.kt (82%) rename android/app/src/main/kotlin/deckers/thibault/aves/{utils => metadata}/MetadataExtractorHelper.kt (96%) rename android/app/src/main/kotlin/deckers/thibault/aves/{utils => metadata}/XMP.kt (89%) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/model/ExifOrientationOp.kt create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt rename android/app/src/main/kotlin/deckers/thibault/aves/utils/{Utils.kt => LogUtils.kt} (85%) rename lib/widgets/{debug_page.dart => app_debug_page.dart} (98%) rename lib/widgets/fullscreen/{debug.dart => fullscreen_debug_page.dart} (87%) 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..2ca2aa107 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 @@ -34,12 +34,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"; 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 ba685a437..51c6ff97f 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 @@ -12,13 +12,13 @@ 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.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; @@ -28,23 +28,28 @@ import java.io.IOException; import java.util.concurrent.ExecutionException; import deckers.thibault.aves.decoder.VideoThumbnail; +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 { Uri uri; String mimeType; + Long dateModifiedSecs; Integer rotationDegrees, width, height, defaultSize; + Boolean isFlipped; MethodChannel.Result result; - Params(String uri, String mimeType, Integer rotationDegrees, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) { + Params(@NonNull String uri, @NonNull String mimeType, @NonNull Long dateModifiedSecs, @NonNull Integer rotationDegrees, @NonNull Boolean isFlipped, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) { this.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; @@ -131,7 +136,7 @@ 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; @@ -66,12 +70,14 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { 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 (uri == null || mimeType == null || rotationDegrees == 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; } @@ -81,7 +87,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { int height = (int) Math.round(heightDip * density); int defaultSize = (int) Math.round(defaultSizeDip * density); - new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(uri, mimeType, rotationDegrees, 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..2d2fc4d39 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 @@ -9,7 +9,6 @@ import android.os.Looper; import com.bumptech.glide.Glide; 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 +18,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 +29,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 +41,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"); } } @@ -95,7 +97,7 @@ 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() @@ -103,9 +105,10 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler { .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 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/decoder/AvesAppGlideModule.java b/android/app/src/main/java/deckers/thibault/aves/decoder/AvesAppGlideModule.java index d6c2c160b..07b804415 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 @@ -14,12 +14,10 @@ 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) { + public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) { builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565)); // hide noisy warning (e.g. for images that can't be decoded) 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 41f212cb2..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, 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 b9b387ceb..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" } 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 e6fec9f49..abe700af7 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 @@ -23,25 +23,31 @@ 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.MetadataExtractorHelper.getSafeString +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.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 @@ -67,7 +73,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 @@ -167,7 +173,7 @@ 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 uri = call.argument("uri")?.let { Uri.parse(it) } val extension = call.argument("extension") if (mimeType == null || uri == null) { result.error("getCatalogMetadata-args", "failed because of missing arguments", null) @@ -339,7 +345,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 @@ -381,7 +387,7 @@ 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 @@ -425,7 +431,7 @@ 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 @@ -448,7 +454,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 @@ -472,7 +478,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 @@ -494,7 +500,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 @@ -515,7 +521,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 @@ -556,7 +562,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/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 96% 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 a9dc9ceb3..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 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 89% 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 2072c44d1..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,11 +1,12 @@ -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 = Utils.createLogTag(XMP::class.java) + 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/" 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 226739b6a..115425807 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.Metadata.getRotationDegreesForExifCode -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.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 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..c1e4434f4 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/BitmapUtils.kt @@ -0,0 +1,25 @@ +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) + } + + private 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 21cf91eba..146eb27a9 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 @@ -34,9 +34,9 @@ object MimeTypes { // 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 } @@ -49,6 +49,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 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 index 17f820bc0..4fd2eff21 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -8,14 +8,14 @@ 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 deckers.thibault.aves.utils.Utils.createLogTag import java.io.File import java.util.* import java.util.concurrent.ConcurrentHashMap object PermissionManager { - private val LOG_TAG = createLogTag(PermissionManager::class.java) + private val LOG_TAG = createTag(PermissionManager::class.java) const val VOLUME_ACCESS_REQUEST_CODE = 1 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 index 9b7eac1e6..250dd28c5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -14,8 +14,8 @@ 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 deckers.thibault.aves.utils.Utils.createLogTag import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -24,7 +24,7 @@ import java.util.* import java.util.regex.Pattern object StorageUtils { - private val LOG_TAG = createLogTag(StorageUtils::class.java) + private val LOG_TAG = createTag(StorageUtils::class.java) /** * Volume paths diff --git a/lib/model/entry_cache.dart b/lib/model/entry_cache.dart index 3f51e7864..a00303656 100644 --- a/lib/model/entry_cache.dart +++ b/lib/model/entry_cache.dart @@ -5,30 +5,41 @@ 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 oldRotationDegrees) async { + 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( + await ThumbnailProvider(ThumbnailProviderKey( uri: uri, mimeType: mimeType, + dateModifiedSecs: dateModifiedSecs, rotationDegrees: oldRotationDegrees, - ).evict(); + 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( + (extent) => ThumbnailProvider(ThumbnailProviderKey( uri: uri, mimeType: mimeType, + dateModifiedSecs: dateModifiedSecs, rotationDegrees: oldRotationDegrees, + isFlipped: oldIsFlipped, extent: extent, - ).evict()); + )).evict()); } } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index bb9e65544..bb0db6c5d 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -3,6 +3,7 @@ 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'; @@ -174,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 @@ -228,6 +227,10 @@ class ImageEntry { _catalogMetadata?.rotationDegrees = rotationDegrees; } + bool get isFlipped => _catalogMetadata?.isFlipped ?? false; + + set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped; + int get dateModifiedSecs => _dateModifiedSecs; set dateModifiedSecs(int dateModifiedSecs) { @@ -277,16 +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(); - if (oldRotationDegrees != rotationDegrees) { - _onImageChanged(oldRotationDegrees); - } + _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); } void clearMetadata() { @@ -358,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']; @@ -372,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; + + _applyNewFields(newFields); _bestTitle = null; metadataChangeNotifier.notifyListeners(); return true; @@ -381,18 +397,23 @@ class ImageEntry { final newFields = await ImageFileService.rotate(this, clockwise: clockwise); if (newFields.isEmpty) return false; - final oldRotationDegrees = this.rotationDegrees; + final oldDateModifiedSecs = dateModifiedSecs; + final oldRotationDegrees = rotationDegrees; + final oldIsFlipped = isFlipped; + _applyNewFields(newFields); + await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); + return true; + } - 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; + Future flip() async { + final newFields = await ImageFileService.flip(this); + if (newFields.isEmpty) return false; - if (oldRotationDegrees != rotationDegrees) { - _onImageChanged(oldRotationDegrees); - } + final oldDateModifiedSecs = dateModifiedSecs; + final oldRotationDegrees = rotationDegrees; + final oldIsFlipped = isFlipped; + _applyNewFields(newFields); + await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); return true; } @@ -411,9 +432,11 @@ class ImageEntry { } // when the entry image itself changed (e.g. after rotation) - void _onImageChanged(int oldRotationDegrees) async { - await EntryCache.evict(uri, mimeType, oldRotationDegrees); - imageChangeNotifier.notifyListeners(); + 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 diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart index d454b773a..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.uri, iconEntry.mimeType, iconEntry.rotationDegrees, 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 0531e3cab..79602b77d 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -25,6 +25,7 @@ class ImageFileService { 'width': entry.width, 'height': entry.height, 'rotationDegrees': entry.rotationDegrees, + 'isFlipped': entry.isFlipped, 'dateModifiedSecs': entry.dateModifiedSecs, }; } @@ -67,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(); @@ -76,6 +84,7 @@ class ImageFileService { 'uri': uri, 'mimeType': mimeType, 'rotationDegrees': rotationDegrees ?? 0, + 'isFlipped': isFlipped ?? false, }).listen( (data) { final chunk = data as Uint8List; @@ -107,7 +116,9 @@ class ImageFileService { static Future getThumbnail( String uri, String mimeType, + int dateModifiedSecs, int rotationDegrees, + bool isFlipped, double width, double height, { Object taskKey, @@ -122,7 +133,9 @@ class ImageFileService { final result = await platform.invokeMethod('getThumbnail', { 'uri': uri, 'mimeType': mimeType, + 'dateModifiedSecs': dateModifiedSecs, 'rotationDegrees': rotationDegrees, + 'isFlipped': isFlipped, 'widthDip': width, 'heightDip': height, 'defaultSizeDip': thumbnailDefaultSize, @@ -194,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, @@ -205,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/widgets/debug_page.dart b/lib/widgets/app_debug_page.dart similarity index 98% rename from lib/widgets/debug_page.dart rename to lib/widgets/app_debug_page.dart index e9f1ab721..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; diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index 7e4d4decf..e9749dd9b 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -73,16 +73,11 @@ class _ThumbnailRasterImageState extends State { void _initProvider() { _fastThumbnailProvider = ThumbnailProvider( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, + ThumbnailProviderKey.fromEntry(entry), ); if (!entry.isVideo) { _sizedThumbnailProvider = ThumbnailProvider( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, - extent: requestExtent, + ThumbnailProviderKey.fromEntry(entry, extent: requestExtent), ); } } @@ -158,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) { diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index 8fc2fdb71..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; diff --git a/lib/widgets/common/entry_actions.dart b/lib/widgets/common/entry_actions.dart index 47500e61b..5376728b9 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, @@ -31,6 +32,7 @@ class EntryActions { EntryAction.rename, EntryAction.rotateCCW, EntryAction.rotateCW, + EntryAction.flip, EntryAction.print, ]; @@ -59,6 +61,8 @@ extension ExtraEntryAction on EntryAction { return 'Rotate left'; case EntryAction.rotateCW: return 'Rotate right'; + case EntryAction.flip: + return 'Flip horizontally'; case EntryAction.print: return 'Print'; case EntryAction.share: @@ -94,6 +98,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..046f0d0fe 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -34,6 +34,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/thumbnail_provider.dart b/lib/widgets/common/image_providers/thumbnail_provider.dart index e3c8fe60d..dab60e514 100644 --- a/lib/widgets/common/image_providers/thumbnail_provider.dart +++ b/lib/widgets/common/image_providers/thumbnail_provider.dart @@ -1,62 +1,48 @@ import 'dart:ui' as ui show Codec; +import 'package:aves/model/image_entry.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class ThumbnailProvider extends ImageProvider { - ThumbnailProvider({ - @required this.uri, - @required this.mimeType, - @required this.rotationDegrees, - this.extent = 0, - this.scale = 1, - }) : assert(uri != null), - assert(mimeType != null), - assert(rotationDegrees != null), - assert(extent != null), - assert(scale != null) { - _cancellationKey = _buildKey(ImageConfiguration.empty); - } + final ThumbnailProviderKey key; - final String uri; - final String mimeType; - final int rotationDegrees; - 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( - uri: uri, - mimeType: mimeType, - rotationDegrees: rotationDegrees, - 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=$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.uri, key.mimeType, key.rotationDegrees, extent, extent, taskKey: _cancellationKey); + 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'); } @@ -69,39 +55,59 @@ class ThumbnailProvider extends ImageProvider { @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 String uri; - final String mimeType; - final int rotationDegrees; - final double extent; - final double scale; + final String uri, mimeType; + final int dateModifiedSecs, rotationDegrees; + final bool isFlipped; + final double extent, scale; - ThumbnailProviderKey({ + const ThumbnailProviderKey({ @required this.uri, @required this.mimeType, + @required this.dateModifiedSecs, @required this.rotationDegrees, - @required this.extent, - this.scale, - }); + @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); + + // 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, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + extent: extent, + ); + } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; - return other is ThumbnailProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && 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(uri, mimeType, rotationDegrees, extent, scale); + int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale); @override String toString() { - return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, 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 79e02276b..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( 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/fullscreen/debug.dart b/lib/widgets/fullscreen/fullscreen_debug_page.dart similarity index 87% rename from lib/widgets/fullscreen/debug.dart rename to lib/widgets/fullscreen/fullscreen_debug_page.dart index 61e18b66a..e2cb97f52 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/fullscreen_debug_page.dart @@ -28,6 +28,7 @@ class FullscreenDebugPage extends StatefulWidget { class _FullscreenDebugPageState extends State { Future _dbDateLoader; + Future _dbEntryLoader; Future _dbMetadataLoader; Future _dbAddressLoader; Future _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader; @@ -103,6 +104,7 @@ class _FullscreenDebugPageState extends State { 'height': '${entry.height}', 'sourceRotationDegrees': '${entry.sourceRotationDegrees}', 'rotationDegrees': '${entry.rotationDegrees}', + 'isFlipped': '${entry.isFlipped}', 'portrait': '${entry.portrait}', 'displayAspectRatio': '${entry.displayAspectRatio}', 'displaySize': '${entry.displaySize}', @@ -121,11 +123,10 @@ class _FullscreenDebugPageState extends State { 'isVideo': '${entry.isVideo}', 'isCatalogued': '${entry.isCatalogued}', 'isAnimated': '${entry.isAnimated}', - 'isFlipped': '${entry.isFlipped}', 'canEdit': '${entry.canEdit}', 'canEditExif': '${entry.canEditExif}', 'canPrint': '${entry.canPrint}', - 'canRotate': '${entry.canRotate}', + 'canRotateAndFlip': '${entry.canRotateAndFlip}', 'xmpSubjects': '${entry.xmpSubjects}', }), Divider(), @@ -160,9 +161,7 @@ class _FullscreenDebugPageState extends State { Center( child: Image( image: ThumbnailProvider( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, + ThumbnailProviderKey.fromEntry(entry), ), ), ), @@ -171,10 +170,7 @@ class _FullscreenDebugPageState extends State { Center( child: Image( image: ThumbnailProvider( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, - extent: extent, + ThumbnailProviderKey.fromEntry(entry, extent: extent), ), ), ), @@ -221,6 +217,35 @@ class _FullscreenDebugPageState extends State { }, ), 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) { @@ -332,6 +357,7 @@ class _FullscreenDebugPageState extends State { void _loadDatabase() { _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(() {}); diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index b26bc4364..fb3563d90 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -58,9 +58,7 @@ class ImageView extends StatelessWidget { // there's a black frame between the hero animation and the final image, even when it's cached. final fastThumbnailProvider = ThumbnailProvider( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, + 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 @@ -102,11 +100,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/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index ab648cc10..cb7579d2f 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: @@ -166,6 +167,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 +209,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()); diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index 3390111ba..d98aa2073 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -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(),