This commit is contained in:
Thibault Deckers 2020-10-13 16:20:58 +09:00
parent a4db8dddee
commit 80d95608a1
40 changed files with 441 additions and 226 deletions

View file

@ -34,12 +34,12 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.LogUtils;
import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
public class AppAdapterHandler implements MethodChannel.MethodCallHandler { public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
private static final String LOG_TAG = Utils.createLogTag(AppAdapterHandler.class); private static final String LOG_TAG = LogUtils.createTag(AppAdapterHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/app"; public static final String CHANNEL = "deckers.thibault/aves/app";

View file

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

View file

@ -12,13 +12,13 @@ import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
import android.util.Size; import android.util.Size;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.bumptech.glide.load.Key; import com.bumptech.glide.load.Key;
import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey; import com.bumptech.glide.signature.ObjectKey;
@ -28,23 +28,28 @@ import java.io.IOException;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.decoder.VideoThumbnail; import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.utils.BitmapUtils;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, ImageDecodeTask.Result> { public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, ImageDecodeTask.Result> {
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class); private static final String LOG_TAG = LogUtils.createTag(ImageDecodeTask.class);
static class Params { static class Params {
Uri uri; Uri uri;
String mimeType; String mimeType;
Long dateModifiedSecs;
Integer rotationDegrees, width, height, defaultSize; Integer rotationDegrees, width, height, defaultSize;
Boolean isFlipped;
MethodChannel.Result result; 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.uri = Uri.parse(uri);
this.mimeType = mimeType; this.mimeType = mimeType;
this.dateModifiedSecs = dateModifiedSecs;
this.rotationDegrees = rotationDegrees; this.rotationDegrees = rotationDegrees;
this.isFlipped = isFlipped;
this.width = width; this.width = width;
this.height = height; this.height = height;
this.result = result; this.result = result;
@ -131,7 +136,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
Bitmap bitmap = resolver.loadThumbnail(params.uri, new Size(params.width, params.height), null); Bitmap bitmap = resolver.loadThumbnail(params.uri, new Size(params.width, params.height), null);
String mimeType = params.mimeType; String mimeType = params.mimeType;
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) { if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = rotateBitmap(bitmap, params.rotationDegrees); bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
} }
return bitmap; return bitmap;
} }
@ -146,7 +151,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null); Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null);
// from Android Q, returned thumbnail is already rotated according to EXIF orientation // from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = rotateBitmap(bitmap, params.rotationDegrees); bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
} }
return bitmap; return bitmap;
} }
@ -155,12 +160,14 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException { private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
Uri uri = params.uri; Uri uri = params.uri;
String mimeType = params.mimeType; String mimeType = params.mimeType;
Long dateModifiedSecs = params.dateModifiedSecs;
Integer rotationDegrees = params.rotationDegrees; Integer rotationDegrees = params.rotationDegrees;
Boolean isFlipped = params.isFlipped;
int width = params.width; int width = params.width;
int height = params.height; int height = params.height;
// add signature to ignore cache for images which got modified but kept the same URI // add signature to ignore cache for images which got modified but kept the same URI
Key signature = new ObjectKey("" + rotationDegrees + width); Key signature = new ObjectKey("" + dateModifiedSecs + rotationDegrees + isFlipped + width);
RequestOptions options = new RequestOptions() RequestOptions options = new RequestOptions()
.signature(signature) .signature(signature)
.override(width, height); .override(width, height);
@ -186,7 +193,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
try { try {
Bitmap bitmap = target.get(); Bitmap bitmap = target.get();
if (MimeTypes.needRotationAfterGlide(mimeType)) { if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = rotateBitmap(bitmap, rotationDegrees); bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
} }
return bitmap; return bitmap;
} finally { } finally {
@ -194,14 +201,6 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
} }
} }
private Bitmap rotateBitmap(Bitmap bitmap, Integer rotationDegrees) {
if (bitmap != null && rotationDegrees != null) {
// TODO TLAD use exif orientation to rotate & flip?
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
}
return bitmap;
}
@Override @Override
protected void onPostExecute(Result result) { protected void onPostExecute(Result result) {
Params params = result.params; Params params = result.params;

View file

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

View file

@ -9,7 +9,6 @@ import android.os.Looper;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.RequestOptions;
@ -19,6 +18,7 @@ import java.io.InputStream;
import java.util.Map; import java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail; import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.utils.BitmapUtils;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.EventChannel;
@ -29,6 +29,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private Uri uri; private Uri uri;
private String mimeType; private String mimeType;
private int rotationDegrees; private int rotationDegrees;
private boolean isFlipped;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
@ -40,6 +41,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
this.mimeType = (String) argMap.get("mimeType"); this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri")); this.uri = Uri.parse((String) argMap.get("uri"));
this.rotationDegrees = (int) argMap.get("rotationDegrees"); this.rotationDegrees = (int) argMap.get("rotationDegrees");
this.isFlipped = (boolean) argMap.get("isFlipped");
} }
} }
@ -95,7 +97,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally { } finally {
Glide.with(activity).clear(target); Glide.with(activity).clear(target);
} }
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees)) { } else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// we convert the image on platform side first, when Dart Image.memory does not support it // we convert the image on platform side first, when Dart Image.memory does not support it
FutureTarget<Bitmap> target = Glide.with(activity) FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap() .asBitmap()
@ -103,9 +105,10 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
.submit(); .submit();
try { try {
Bitmap bitmap = target.get(); Bitmap bitmap = target.get();
if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
}
if (bitmap != null) { if (bitmap != null) {
// TODO TLAD use exif orientation to rotate & flip?
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream(); ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes // we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG // Bitmap.CompressFormat.PNG is slower than JPEG

View file

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

View file

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

View file

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

View file

@ -31,12 +31,12 @@ import java.util.stream.Stream;
import deckers.thibault.aves.model.AvesImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry; import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
public class MediaStoreImageProvider extends ImageProvider { public class MediaStoreImageProvider extends ImageProvider {
private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class); private static final String LOG_TAG = LogUtils.createTag(MediaStoreImageProvider.class);
private static final String[] BASE_PROJECTION = { private static final String[] BASE_PROJECTION = {
MediaStore.MediaColumns._ID, MediaStore.MediaColumns._ID,

View file

@ -12,15 +12,15 @@ import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.Utils
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
companion object { companion object {
private val LOG_TAG = Utils.createLogTag(MainActivity::class.java) private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
const val INTENT_CHANNEL = "deckers.thibault/aves/intent" const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
} }

View file

@ -23,25 +23,31 @@ import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.utils.* import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.utils.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDescription import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.Metadata.isFlippedForExifCode import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.utils.Metadata.parseVideoMetadataDate import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.Metadata.parseVideoMetadataDate
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDescription import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeString 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.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.XMP.getSafeLocalizedText import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -67,7 +73,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null) result.error("getAllMetadata-args", "failed because of missing arguments", null)
return return
@ -167,7 +173,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val extension = call.argument<String>("extension") val extension = call.argument<String>("extension")
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null) result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
@ -339,7 +345,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null) result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
return return
@ -381,7 +387,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null) result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
return return
@ -425,7 +431,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null) result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
return return
@ -448,7 +454,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null) result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
return return
@ -472,7 +478,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null) result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
return return
@ -494,7 +500,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) { private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null) result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return return
@ -515,7 +521,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) { private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri")) val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null) result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
return return
@ -556,7 +562,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
companion object { companion object {
private val LOG_TAG = Utils.createLogTag(MetadataHandler::class.java) private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata" const val CHANNEL = "deckers.thibault/aves/metadata"
// catalog metadata // catalog metadata

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.metadata
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.metadata.Directory import com.drew.metadata.Directory

View file

@ -1,11 +1,12 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.metadata
import android.util.Log import android.util.Log
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta import com.adobe.internal.xmp.XMPMeta
import deckers.thibault.aves.utils.LogUtils
object XMP { object XMP {
private val LOG_TAG = 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 DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"

View file

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

View file

@ -14,16 +14,16 @@ import com.drew.metadata.jpeg.JpegDirectory
import com.drew.metadata.mp4.Mp4Directory import com.drew.metadata.mp4.Mp4Directory
import com.drew.metadata.mp4.media.Mp4VideoDirectory import com.drew.metadata.mp4.media.Mp4VideoDirectory
import com.drew.metadata.photoshop.PsdHeaderDirectory import com.drew.metadata.photoshop.PsdHeaderDirectory
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException import java.io.IOException

View file

@ -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
}

View file

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

View file

@ -34,9 +34,9 @@ object MimeTypes {
// as of Flutter v1.22.0 // as of Flutter v1.22.0
@JvmStatic @JvmStatic
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?) = when (mimeType) { fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0 PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
else -> false else -> false
} }
@ -49,6 +49,8 @@ object MimeTypes {
// Glide automatically applies EXIF orientation when decoding images of known formats // Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats // but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide:
// https://github.com/bumptech/glide/blob/master/gradle.properties#L21
@JvmStatic @JvmStatic
fun needRotationAfterGlide(mimeType: String) = when (mimeType) { fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
DNG, HEIC, HEIF, PNG, WEBP -> true DNG, HEIC, HEIF, PNG, WEBP -> true

View file

@ -8,14 +8,14 @@ import android.os.Build
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.util.Log import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import deckers.thibault.aves.utils.LogUtils.createTag
import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.StorageUtils.PathSegments
import deckers.thibault.aves.utils.Utils.createLogTag
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
object PermissionManager { 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 const val VOLUME_ACCESS_REQUEST_CODE = 1

View file

@ -14,8 +14,8 @@ import android.text.TextUtils
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import com.commonsware.cwac.document.DocumentFileCompat 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.PermissionManager.getGrantedDirForPath
import deckers.thibault.aves.utils.Utils.createLogTag
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
@ -24,7 +24,7 @@ import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
object StorageUtils { object StorageUtils {
private val LOG_TAG = createLogTag(StorageUtils::class.java) private val LOG_TAG = createTag(StorageUtils::class.java)
/** /**
* Volume paths * Volume paths

View file

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

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
@ -174,13 +175,11 @@ class ImageEntry {
bool get isAnimated => _catalogMetadata?.isAnimated ?? false; bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
bool get canEdit => path != null; bool get canEdit => path != null;
bool get canPrint => !isVideo; bool get canPrint => !isVideo;
bool get canRotate => canEdit && canEditExif; bool get canRotateAndFlip => canEdit && canEditExif;
// support for writing EXIF // support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.0 // as of androidx.exifinterface:exifinterface:1.3.0
@ -228,6 +227,10 @@ class ImageEntry {
_catalogMetadata?.rotationDegrees = rotationDegrees; _catalogMetadata?.rotationDegrees = rotationDegrees;
} }
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
int get dateModifiedSecs => _dateModifiedSecs; int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) { set dateModifiedSecs(int dateModifiedSecs) {
@ -277,16 +280,16 @@ class ImageEntry {
} }
set catalogMetadata(CatalogMetadata newMetadata) { set catalogMetadata(CatalogMetadata newMetadata) {
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees; final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
catalogDateMillis = newMetadata?.dateMillis; catalogDateMillis = newMetadata?.dateMillis;
_catalogMetadata = newMetadata; _catalogMetadata = newMetadata;
_bestTitle = null; _bestTitle = null;
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
if (oldRotationDegrees != rotationDegrees) { _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
_onImageChanged(oldRotationDegrees);
}
} }
void clearMetadata() { void clearMetadata() {
@ -358,12 +361,7 @@ class ImageEntry {
return false; return false;
} }
Future<bool> rename(String newName) async { Future<void> _applyNewFields(Map newFields) async {
if (newName == filenameWithoutExtension) return true;
final newFields = await ImageFileService.rename(this, '$newName$extension');
if (newFields.isEmpty) return false;
final uri = newFields['uri']; final uri = newFields['uri'];
if (uri is String) this.uri = uri; if (uri is String) this.uri = uri;
final path = newFields['path']; final path = newFields['path'];
@ -372,6 +370,24 @@ class ImageEntry {
if (contentId is int) this.contentId = contentId; if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title']; final sourceTitle = newFields['title'];
if (sourceTitle is String) this.sourceTitle = sourceTitle; if (sourceTitle is String) this.sourceTitle = sourceTitle;
final dateModifiedSecs = newFields['dateModifiedSecs'];
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
final rotationDegrees = newFields['rotationDegrees'];
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
final isFlipped = newFields['isFlipped'];
if (isFlipped is bool) this.isFlipped = isFlipped;
await metadataDb.saveEntries({this});
await metadataDb.saveMetadata({catalogMetadata});
}
Future<bool> rename(String newName) async {
if (newName == filenameWithoutExtension) return true;
final newFields = await ImageFileService.rename(this, '$newName$extension');
if (newFields.isEmpty) return false;
_applyNewFields(newFields);
_bestTitle = null; _bestTitle = null;
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
return true; return true;
@ -381,18 +397,23 @@ class ImageEntry {
final newFields = await ImageFileService.rotate(this, clockwise: clockwise); final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final 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']; Future<bool> flip() async {
if (width is int) this.width = width; final newFields = await ImageFileService.flip(this);
final height = newFields['height']; if (newFields.isEmpty) return false;
if (height is int) this.height = height;
final rotationDegrees = newFields['rotationDegrees'];
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
if (oldRotationDegrees != rotationDegrees) { final oldDateModifiedSecs = dateModifiedSecs;
_onImageChanged(oldRotationDegrees); final oldRotationDegrees = rotationDegrees;
} final oldIsFlipped = isFlipped;
_applyNewFields(newFields);
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true; return true;
} }
@ -411,9 +432,11 @@ class ImageEntry {
} }
// when the entry image itself changed (e.g. after rotation) // when the entry image itself changed (e.g. after rotation)
void _onImageChanged(int oldRotationDegrees) async { void _onImageChanged(int oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
await EntryCache.evict(uri, mimeType, oldRotationDegrees); if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
imageChangeNotifier.notifyListeners(); await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notifyListeners();
}
} }
// favourites // favourites

View file

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

View file

@ -25,6 +25,7 @@ class ImageFileService {
'width': entry.width, 'width': entry.width,
'height': entry.height, 'height': entry.height,
'rotationDegrees': entry.rotationDegrees, 'rotationDegrees': entry.rotationDegrees,
'isFlipped': entry.isFlipped,
'dateModifiedSecs': entry.dateModifiedSecs, 'dateModifiedSecs': entry.dateModifiedSecs,
}; };
} }
@ -67,7 +68,14 @@ class ImageFileService {
return null; return null;
} }
static Future<Uint8List> getImage(String uri, String mimeType, {int rotationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) { static Future<Uint8List> getImage(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped, {
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
}) {
try { try {
final completer = Completer<Uint8List>.sync(); final completer = Completer<Uint8List>.sync();
final sink = _OutputBuffer(); final sink = _OutputBuffer();
@ -76,6 +84,7 @@ class ImageFileService {
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
'rotationDegrees': rotationDegrees ?? 0, 'rotationDegrees': rotationDegrees ?? 0,
'isFlipped': isFlipped ?? false,
}).listen( }).listen(
(data) { (data) {
final chunk = data as Uint8List; final chunk = data as Uint8List;
@ -107,7 +116,9 @@ class ImageFileService {
static Future<Uint8List> getThumbnail( static Future<Uint8List> getThumbnail(
String uri, String uri,
String mimeType, String mimeType,
int dateModifiedSecs,
int rotationDegrees, int rotationDegrees,
bool isFlipped,
double width, double width,
double height, { double height, {
Object taskKey, Object taskKey,
@ -122,7 +133,9 @@ class ImageFileService {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{ final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
'dateModifiedSecs': dateModifiedSecs,
'rotationDegrees': rotationDegrees, 'rotationDegrees': rotationDegrees,
'isFlipped': isFlipped,
'widthDip': width, 'widthDip': width,
'heightDip': height, 'heightDip': height,
'defaultSizeDip': thumbnailDefaultSize, 'defaultSizeDip': thumbnailDefaultSize,
@ -194,7 +207,7 @@ class ImageFileService {
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async { static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
try { try {
// return map with: 'width' 'height' 'rotationDegrees' (all optional) // return map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{ final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry), 'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise, 'clockwise': clockwise,
@ -205,6 +218,19 @@ class ImageFileService {
} }
return {}; return {};
} }
static Future<Map> flip(ImageEntry entry) async {
try {
// return map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
} }
@immutable @immutable

View file

@ -19,18 +19,18 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
class DebugPage extends StatefulWidget { class AppDebugPage extends StatefulWidget {
static const routeName = '/debug'; static const routeName = '/debug';
final CollectionSource source; final CollectionSource source;
const DebugPage({this.source}); const AppDebugPage({this.source});
@override @override
State<StatefulWidget> createState() => DebugPageState(); State<StatefulWidget> createState() => AppDebugPageState();
} }
class DebugPageState extends State<DebugPage> { class AppDebugPageState extends State<AppDebugPage> {
Future<int> _dbFileSizeLoader; Future<int> _dbFileSizeLoader;
Future<List<ImageEntry>> _dbEntryLoader; Future<List<ImageEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader; Future<List<DateMetadata>> _dbDateLoader;

View file

@ -73,16 +73,11 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
void _initProvider() { void _initProvider() {
_fastThumbnailProvider = ThumbnailProvider( _fastThumbnailProvider = ThumbnailProvider(
uri: entry.uri, ThumbnailProviderKey.fromEntry(entry),
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
); );
if (!entry.isVideo) { if (!entry.isVideo) {
_sizedThumbnailProvider = ThumbnailProvider( _sizedThumbnailProvider = ThumbnailProvider(
uri: entry.uri, ThumbnailProviderKey.fromEntry(entry, extent: requestExtent),
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
extent: requestExtent,
); );
} }
} }
@ -158,6 +153,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,
); );
if (imageCache.statusForKey(imageProvider).keepAlive) { if (imageCache.statusForKey(imageProvider).keepAlive) {

View file

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

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
enum EntryAction { enum EntryAction {
delete, delete,
edit, edit,
flip,
info, info,
open, open,
openMap, openMap,
@ -31,6 +32,7 @@ class EntryActions {
EntryAction.rename, EntryAction.rename,
EntryAction.rotateCCW, EntryAction.rotateCCW,
EntryAction.rotateCW, EntryAction.rotateCW,
EntryAction.flip,
EntryAction.print, EntryAction.print,
]; ];
@ -59,6 +61,8 @@ extension ExtraEntryAction on EntryAction {
return 'Rotate left'; return 'Rotate left';
case EntryAction.rotateCW: case EntryAction.rotateCW:
return 'Rotate right'; return 'Rotate right';
case EntryAction.flip:
return 'Flip horizontally';
case EntryAction.print: case EntryAction.print:
return 'Print'; return 'Print';
case EntryAction.share: case EntryAction.share:
@ -94,6 +98,8 @@ extension ExtraEntryAction on EntryAction {
return AIcons.rotateLeft; return AIcons.rotateLeft;
case EntryAction.rotateCW: case EntryAction.rotateCW:
return AIcons.rotateRight; return AIcons.rotateRight;
case EntryAction.flip:
return AIcons.flip;
case EntryAction.print: case EntryAction.print:
return AIcons.print; return AIcons.print;
case EntryAction.share: case EntryAction.share:

View file

@ -34,6 +34,7 @@ class AIcons {
static const IconData debug = Icons.whatshot_outlined; static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined; static const IconData delete = Icons.delete_outlined;
static const IconData expand = Icons.expand_more_outlined; static const IconData expand = Icons.expand_more_outlined;
static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border; static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite; static const IconData favouriteActive = Icons.favorite;
static const IconData goUp = Icons.arrow_upward_outlined; static const IconData goUp = Icons.arrow_upward_outlined;

View file

@ -1,62 +1,48 @@
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> { class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
ThumbnailProvider({ final ThumbnailProviderKey key;
@required this.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 String uri; ThumbnailProvider(this.key) : assert(key != null);
final String mimeType;
final int rotationDegrees;
final double extent;
final double scale;
Object _cancellationKey;
@override @override
Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) { Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) {
// configuration can be empty (e.g. when obtaining key for eviction) // configuration can be empty (e.g. when obtaining key for eviction)
// so we do not compute the target width/height here // so we do not compute the target width/height here
// and pass it to the key, to use it later for image loading // and pass it to the key, to use it later for image loading
return SynchronousFuture<ThumbnailProviderKey>(_buildKey(configuration)); return SynchronousFuture<ThumbnailProviderKey>(key);
} }
ThumbnailProviderKey _buildKey(ImageConfiguration configuration) => ThumbnailProviderKey(
uri: uri,
mimeType: mimeType,
rotationDegrees: rotationDegrees,
extent: extent,
scale: scale,
);
@override @override
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) { ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
return MultiFrameImageStreamCompleter( return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode), codec: _loadAsync(key, decode),
scale: key.scale, scale: key.scale,
informationCollector: () sync* { informationCollector: () sync* {
yield ErrorDescription('uri=$uri, extent=$extent'); yield ErrorDescription('uri=${key.uri}, extent=${key.extent}');
}, },
); );
} }
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
var uri = key.uri;
var mimeType = key.mimeType;
try { try {
final bytes = await ImageFileService.getThumbnail(key.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) { if (bytes == null) {
throw StateError('$uri ($mimeType) loading failed'); throw StateError('$uri ($mimeType) loading failed');
} }
@ -69,39 +55,59 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeThumbnail(_cancellationKey); ImageFileService.resumeThumbnail(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
void pause() => ImageFileService.cancelThumbnail(_cancellationKey); void pause() => ImageFileService.cancelThumbnail(key);
} }
class ThumbnailProviderKey { class ThumbnailProviderKey {
final String uri; final String uri, mimeType;
final String mimeType; final int dateModifiedSecs, rotationDegrees;
final int rotationDegrees; final bool isFlipped;
final double extent; final double extent, scale;
final double scale;
ThumbnailProviderKey({ const ThumbnailProviderKey({
@required this.uri, @required this.uri,
@required this.mimeType, @required this.mimeType,
@required this.dateModifiedSecs,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.extent, @required this.isFlipped,
this.scale, 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 @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false; if (other.runtimeType != runtimeType) return false;
return other is ThumbnailProviderKey && other.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 @override
int get hashCode => hashValues(uri, mimeType, rotationDegrees, extent, scale); int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale);
@override @override
String toString() { 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}';
} }
} }

View file

@ -11,6 +11,7 @@ class UriImage extends ImageProvider<UriImage> {
@required this.uri, @required this.uri,
@required this.mimeType, @required this.mimeType,
@required this.rotationDegrees, @required this.rotationDegrees,
@required this.isFlipped,
this.expectedContentLength, this.expectedContentLength,
this.scale = 1.0, this.scale = 1.0,
}) : assert(uri != null), }) : assert(uri != null),
@ -18,6 +19,7 @@ class UriImage extends ImageProvider<UriImage> {
final String uri, mimeType; final String uri, mimeType;
final int rotationDegrees, expectedContentLength; final int rotationDegrees, expectedContentLength;
final bool isFlipped;
final double scale; final double scale;
@override @override
@ -46,7 +48,8 @@ class UriImage extends ImageProvider<UriImage> {
final bytes = await ImageFileService.getImage( final bytes = await ImageFileService.getImage(
uri, uri,
mimeType, mimeType,
rotationDegrees: rotationDegrees, rotationDegrees,
isFlipped,
expectedContentLength: expectedContentLength, expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) { onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(

View file

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

View file

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

View file

@ -28,6 +28,7 @@ class FullscreenDebugPage extends StatefulWidget {
class _FullscreenDebugPageState extends State<FullscreenDebugPage> { class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Future<DateMetadata> _dbDateLoader; Future<DateMetadata> _dbDateLoader;
Future<ImageEntry> _dbEntryLoader;
Future<CatalogMetadata> _dbMetadataLoader; Future<CatalogMetadata> _dbMetadataLoader;
Future<AddressDetails> _dbAddressLoader; Future<AddressDetails> _dbAddressLoader;
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader; Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
@ -103,6 +104,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'height': '${entry.height}', 'height': '${entry.height}',
'sourceRotationDegrees': '${entry.sourceRotationDegrees}', 'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
'rotationDegrees': '${entry.rotationDegrees}', 'rotationDegrees': '${entry.rotationDegrees}',
'isFlipped': '${entry.isFlipped}',
'portrait': '${entry.portrait}', 'portrait': '${entry.portrait}',
'displayAspectRatio': '${entry.displayAspectRatio}', 'displayAspectRatio': '${entry.displayAspectRatio}',
'displaySize': '${entry.displaySize}', 'displaySize': '${entry.displaySize}',
@ -121,11 +123,10 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'isVideo': '${entry.isVideo}', 'isVideo': '${entry.isVideo}',
'isCatalogued': '${entry.isCatalogued}', 'isCatalogued': '${entry.isCatalogued}',
'isAnimated': '${entry.isAnimated}', 'isAnimated': '${entry.isAnimated}',
'isFlipped': '${entry.isFlipped}',
'canEdit': '${entry.canEdit}', 'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}', 'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}', 'canPrint': '${entry.canPrint}',
'canRotate': '${entry.canRotate}', 'canRotateAndFlip': '${entry.canRotateAndFlip}',
'xmpSubjects': '${entry.xmpSubjects}', 'xmpSubjects': '${entry.xmpSubjects}',
}), }),
Divider(), Divider(),
@ -160,9 +161,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Center( Center(
child: Image( child: Image(
image: ThumbnailProvider( image: ThumbnailProvider(
uri: entry.uri, ThumbnailProviderKey.fromEntry(entry),
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
), ),
), ),
), ),
@ -171,10 +170,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Center( Center(
child: Image( child: Image(
image: ThumbnailProvider( image: ThumbnailProvider(
uri: entry.uri, ThumbnailProviderKey.fromEntry(entry, extent: extent),
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
extent: extent,
), ),
), ),
), ),
@ -221,6 +217,35 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
}, },
), ),
SizedBox(height: 16), SizedBox(height: 16),
FutureBuilder<ImageEntry>(
future: _dbEntryLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = snapshot.data;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('DB entry:${data == null ? ' no row' : ''}'),
if (data != null)
InfoRowGroup({
'uri': '${data.uri}',
'path': '${data.path}',
'sourceMimeType': '${data.sourceMimeType}',
'width': '${data.width}',
'height': '${data.height}',
'sourceRotationDegrees': '${data.sourceRotationDegrees}',
'sizeBytes': '${data.sizeBytes}',
'sourceTitle': '${data.sourceTitle}',
'dateModifiedSecs': '${data.dateModifiedSecs}',
'sourceDateTakenMillis': '${data.sourceDateTakenMillis}',
'durationMillis': '${data.durationMillis}',
}),
],
);
},
),
SizedBox(height: 16),
FutureBuilder<CatalogMetadata>( FutureBuilder<CatalogMetadata>(
future: _dbMetadataLoader, future: _dbMetadataLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
@ -332,6 +357,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
void _loadDatabase() { void _loadDatabase() {
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _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)); _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)); _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
setState(() {}); setState(() {});

View file

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

View file

@ -82,7 +82,8 @@ class FullscreenTopOverlay extends StatelessWidget {
return entry.canEdit; return entry.canEdit;
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
case EntryAction.rotateCW: case EntryAction.rotateCW:
return entry.canRotate; case EntryAction.flip:
return entry.canRotateAndFlip;
case EntryAction.print: case EntryAction.print:
return entry.canPrint; return entry.canPrint;
case EntryAction.openMap: case EntryAction.openMap:
@ -166,6 +167,7 @@ class _TopOverlayRow extends StatelessWidget {
case EntryAction.rename: case EntryAction.rename:
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
case EntryAction.rotateCW: case EntryAction.rotateCW:
case EntryAction.flip:
case EntryAction.print: case EntryAction.print:
child = IconButton( child = IconButton(
icon: Icon(action.getIcon()), icon: Icon(action.getIcon()),
@ -207,6 +209,7 @@ class _TopOverlayRow extends StatelessWidget {
case EntryAction.rename: case EntryAction.rename:
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
case EntryAction.rotateCW: case EntryAction.rotateCW:
case EntryAction.flip:
case EntryAction.print: case EntryAction.print:
case EntryAction.debug: case EntryAction.debug:
child = MenuRow(text: action.getText(), icon: action.getIcon()); child = MenuRow(text: action.getText(), icon: action.getIcon());

View file

@ -102,6 +102,7 @@ class AvesVideoState extends State<AvesVideo> {
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees, rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes, expectedContentLength: entry.sizeBytes,
), ),
width: entry.width.toDouble(), width: entry.width.toDouble(),