flip
This commit is contained in:
parent
a4db8dddee
commit
80d95608a1
40 changed files with 441 additions and 226 deletions
|
@ -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";
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -1,4 +1,4 @@
|
|||
package deckers.thibault.aves.utils
|
||||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaFormat
|
|
@ -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 {
|
|
@ -1,4 +1,4 @@
|
|||
package deckers.thibault.aves.utils
|
||||
package deckers.thibault.aves.metadata
|
||||
|
||||
import com.drew.lang.Rational
|
||||
import com.drew.metadata.Directory
|
|
@ -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/"
|
|
@ -0,0 +1,5 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
enum class ExifOrientationOp {
|
||||
ROTATE_CW, ROTATE_CCW, FLIP
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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) {
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(() {});
|
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in a new issue