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.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";

View file

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

View file

@ -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<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 {
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<ImageDecodeTask.Params, Void, Ima
Bitmap bitmap = resolver.loadThumbnail(params.uri, new Size(params.width, params.height), null);
String mimeType = params.mimeType;
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = rotateBitmap(bitmap, params.rotationDegrees);
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
}
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);
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
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;
}
@ -155,12 +160,14 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
Uri uri = params.uri;
String mimeType = params.mimeType;
Long dateModifiedSecs = params.dateModifiedSecs;
Integer rotationDegrees = params.rotationDegrees;
Boolean isFlipped = params.isFlipped;
int width = params.width;
int height = params.height;
// 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()
.signature(signature)
.override(width, height);
@ -186,7 +193,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
try {
Bitmap bitmap = target.get();
if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = rotateBitmap(bitmap, rotationDegrees);
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
}
return bitmap;
} 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
protected void onPostExecute(Result result) {
Params params = result.params;

View file

@ -12,6 +12,7 @@ import com.bumptech.glide.Glide;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.model.ExifOrientationOp;
import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
@ -57,6 +58,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "rotate":
new Thread(() -> 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<String, Object> 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.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<Bitmap> 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

View file

@ -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";

View file

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

View file

@ -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) {

View file

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

View file

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

View file

@ -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<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("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<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val extension = call.argument<String>("extension")
if (mimeType == null || uri == null) {
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<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("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<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("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<String>("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<String>("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<String>("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<String>("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<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val uri = call.argument<String>("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

View file

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

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.utils
package deckers.thibault.aves.metadata
import android.content.Context
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 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 {

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.utils
package deckers.thibault.aves.metadata
import com.drew.lang.Rational
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 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/"

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

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
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) {

View file

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

View file

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

View file

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

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';
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
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<double>(
extents,
(extent) => ThumbnailProvider(
(extent) => ThumbnailProvider(ThumbnailProviderKey(
uri: uri,
mimeType: mimeType,
dateModifiedSecs: dateModifiedSecs,
rotationDegrees: oldRotationDegrees,
isFlipped: oldIsFlipped,
extent: extent,
).evict());
)).evict());
}
}

View file

@ -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<bool> rename(String newName) async {
if (newName == filenameWithoutExtension) return true;
final newFields = await ImageFileService.rename(this, '$newName$extension');
if (newFields.isEmpty) return false;
Future<void> _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<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;
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<bool> 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

View file

@ -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', <String, dynamic>{

View file

@ -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<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 {
final completer = Completer<Uint8List>.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<Uint8List> 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', <String, dynamic>{
'uri': uri,
'mimeType': mimeType,
'dateModifiedSecs': dateModifiedSecs,
'rotationDegrees': rotationDegrees,
'isFlipped': isFlipped,
'widthDip': width,
'heightDip': height,
'defaultSizeDip': thumbnailDefaultSize,
@ -194,7 +207,7 @@ class ImageFileService {
static Future<Map> 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', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise,
@ -205,6 +218,19 @@ class ImageFileService {
}
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

View file

@ -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<StatefulWidget> createState() => DebugPageState();
State<StatefulWidget> createState() => AppDebugPageState();
}
class DebugPageState extends State<DebugPage> {
class AppDebugPageState extends State<AppDebugPage> {
Future<int> _dbFileSizeLoader;
Future<List<ImageEntry>> _dbEntryLoader;
Future<List<DateMetadata>> _dbDateLoader;

View file

@ -73,16 +73,11 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
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<ThumbnailRasterImage> {
uri: entry.uri,
mimeType: entry.mimeType,
rotationDegrees: entry.rotationDegrees,
isFlipped: entry.isFlipped,
expectedContentLength: entry.sizeBytes,
);
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/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<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 {
if (!await checkStoragePermission(context, [entry])) return;

View file

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

View file

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

View file

@ -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<ThumbnailProviderKey> {
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<ThumbnailProviderKey> 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<ThumbnailProviderKey>(_buildKey(configuration));
return SynchronousFuture<ThumbnailProviderKey>(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<ui.Codec> _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<ThumbnailProviderKey> {
@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}';
}
}

View file

@ -11,6 +11,7 @@ class UriImage extends ImageProvider<UriImage> {
@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<UriImage> {
final String uri, mimeType;
final int rotationDegrees, expectedContentLength;
final bool isFlipped;
final double scale;
@override
@ -46,7 +48,8 @@ class UriImage extends ImageProvider<UriImage> {
final bytes = await ImageFileService.getImage(
uri,
mimeType,
rotationDegrees: rotationDegrees,
rotationDegrees,
isFlipped,
expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent(

View file

@ -27,7 +27,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _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;
}

View file

@ -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<AppDrawer> {
icon: AIcons.debug,
title: 'Debug',
topLevel: false,
routeName: DebugPage.routeName,
pageBuilder: (_) => DebugPage(source: source),
routeName: AppDebugPage.routeName,
pageBuilder: (_) => AppDebugPage(source: source),
);
}

View file

@ -28,6 +28,7 @@ class FullscreenDebugPage extends StatefulWidget {
class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Future<DateMetadata> _dbDateLoader;
Future<ImageEntry> _dbEntryLoader;
Future<CatalogMetadata> _dbMetadataLoader;
Future<AddressDetails> _dbAddressLoader;
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
@ -103,6 +104,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'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<FullscreenDebugPage> {
'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<FullscreenDebugPage> {
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<FullscreenDebugPage> {
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<FullscreenDebugPage> {
},
),
SizedBox(height: 16),
FutureBuilder<ImageEntry>(
future: _dbEntryLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = snapshot.data;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('DB entry:${data == null ? ' no row' : ''}'),
if (data != null)
InfoRowGroup({
'uri': '${data.uri}',
'path': '${data.path}',
'sourceMimeType': '${data.sourceMimeType}',
'width': '${data.width}',
'height': '${data.height}',
'sourceRotationDegrees': '${data.sourceRotationDegrees}',
'sizeBytes': '${data.sizeBytes}',
'sourceTitle': '${data.sourceTitle}',
'dateModifiedSecs': '${data.dateModifiedSecs}',
'sourceDateTakenMillis': '${data.sourceDateTakenMillis}',
'durationMillis': '${data.durationMillis}',
}),
],
);
},
),
SizedBox(height: 16),
FutureBuilder<CatalogMetadata>(
future: _dbMetadataLoader,
builder: (context, snapshot) {
@ -332,6 +357,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
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(() {});

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

View file

@ -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());

View file

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