Merge branch 'develop'
|
@ -8,7 +8,7 @@
|
|||
|
||||
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/1-S10-collection.jpg" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/2-S10-image.jpg" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/5-S10-stats.jpg" alt='Stats screenshot' height="400" />
|
||||
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/1-S10-collection.png" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/2-S10-image.png" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/5-S10-stats.png" alt='Stats screenshot' height="400" />
|
||||
|
||||
## Features
|
||||
|
||||
|
|
|
@ -109,10 +109,10 @@ dependencies {
|
|||
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
|
||||
|
||||
implementation 'androidx.core:core:1.5.0-alpha03' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||
implementation 'androidx.core:core:1.5.0-alpha04' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||
implementation "androidx.exifinterface:exifinterface:1.3.0"
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.14.0'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
||||
implementation 'com.google.guava:guava:29.0-android'
|
||||
|
||||
|
|
|
@ -18,10 +18,9 @@ import androidx.annotation.Nullable;
|
|||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.Key;
|
||||
import com.bumptech.glide.load.DecodeFormat;
|
||||
import com.bumptech.glide.request.FutureTarget;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
|
@ -34,12 +33,12 @@ import java.util.Locale;
|
|||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
import deckers.thibault.aves.utils.LogUtils;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
|
||||
private static final String LOG_TAG = Utils.createLogTag(AppAdapterHandler.class);
|
||||
private static final String LOG_TAG = LogUtils.createTag(AppAdapterHandler.class);
|
||||
|
||||
public static final String CHANNEL = "deckers.thibault/aves/app";
|
||||
|
||||
|
@ -173,25 +172,21 @@ public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
|
|||
.path(String.valueOf(iconResourceId))
|
||||
.build();
|
||||
|
||||
// add signature to ignore cache for images which got modified but kept the same URI
|
||||
Key signature = new ObjectKey(packageName + size);
|
||||
RequestOptions options = new RequestOptions()
|
||||
.signature(signature)
|
||||
.format(DecodeFormat.PREFER_RGB_565)
|
||||
.centerCrop()
|
||||
.override(size, size);
|
||||
|
||||
FutureTarget<Bitmap> target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.centerCrop()
|
||||
.load(uri)
|
||||
.signature(signature)
|
||||
.submit(size, size);
|
||||
|
||||
try {
|
||||
Bitmap bmp = target.get();
|
||||
if (bmp != null) {
|
||||
Bitmap bitmap = target.get();
|
||||
if (bitmap != null) {
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, stream);
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream);
|
||||
data = stream.toByteArray();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -5,19 +5,20 @@ import android.app.Activity;
|
|||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.provider.MediaStore;
|
||||
import android.util.Log;
|
||||
import android.util.Size;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.Key;
|
||||
import com.bumptech.glide.load.DecodeFormat;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
|
||||
import com.bumptech.glide.request.FutureTarget;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
|
@ -27,21 +28,28 @@ import java.io.IOException;
|
|||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail;
|
||||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.utils.BitmapUtils;
|
||||
import deckers.thibault.aves.utils.LogUtils;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
public class ImageDecodeTask extends AsyncTask<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 {
|
||||
AvesImageEntry entry;
|
||||
Integer width, height, defaultSize;
|
||||
Uri uri;
|
||||
String mimeType;
|
||||
Long dateModifiedSecs;
|
||||
Integer rotationDegrees, width, height, defaultSize;
|
||||
Boolean isFlipped;
|
||||
MethodChannel.Result result;
|
||||
|
||||
Params(AvesImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
|
||||
this.entry = entry;
|
||||
Params(@NonNull String uri, @NonNull String mimeType, @NonNull Long dateModifiedSecs, @NonNull Integer rotationDegrees, @NonNull Boolean isFlipped, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
|
||||
this.uri = Uri.parse(uri);
|
||||
this.mimeType = mimeType;
|
||||
this.dateModifiedSecs = dateModifiedSecs;
|
||||
this.rotationDegrees = rotationDegrees;
|
||||
this.isFlipped = isFlipped;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.result = result;
|
||||
|
@ -80,14 +88,19 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
if (w == null || h == null || w == 0 || h == 0) {
|
||||
p.width = p.defaultSize;
|
||||
p.height = p.defaultSize;
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
bitmap = getThumbnailBytesByResolver(p);
|
||||
} else {
|
||||
bitmap = getThumbnailBytesByMediaStore(p);
|
||||
// EXIF orientations with flipping are not well supported by the Media Store:
|
||||
// the content resolver may return a thumbnail that is automatically rotated
|
||||
// according to EXIF orientation, but not flip it when necessary
|
||||
if (!p.isFlipped) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
bitmap = getThumbnailBytesByResolver(p);
|
||||
} else {
|
||||
bitmap = getThumbnailBytesByMediaStore(p);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
exception = e;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
exception = e;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,7 +113,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
exception = e;
|
||||
}
|
||||
} else {
|
||||
Log.d(LOG_TAG, "getThumbnail with uri=" + p.entry.uri + " cancelled");
|
||||
Log.d(LOG_TAG, "getThumbnail with uri=" + p.uri + " cancelled");
|
||||
}
|
||||
|
||||
byte[] data = null;
|
||||
|
@ -124,70 +137,66 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
|
||||
AvesImageEntry entry = params.entry;
|
||||
Integer width = params.width;
|
||||
Integer height = params.height;
|
||||
|
||||
ContentResolver resolver = activity.getContentResolver();
|
||||
Bitmap bitmap = resolver.loadThumbnail(entry.uri, new Size(width, height), null);
|
||||
String mimeType = entry.mimeType;
|
||||
Bitmap bitmap = resolver.loadThumbnail(params.uri, new Size(params.width, params.height), null);
|
||||
String mimeType = params.mimeType;
|
||||
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
|
||||
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
|
||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private Bitmap getThumbnailBytesByMediaStore(Params params) {
|
||||
AvesImageEntry entry = params.entry;
|
||||
long contentId = ContentUris.parseId(entry.uri);
|
||||
long contentId = ContentUris.parseId(params.uri);
|
||||
|
||||
ContentResolver resolver = activity.getContentResolver();
|
||||
if (entry.isVideo()) {
|
||||
if (MimeTypes.isVideo(params.mimeType)) {
|
||||
return MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null);
|
||||
} else {
|
||||
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, entry.rotationDegrees);
|
||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
|
||||
AvesImageEntry entry = params.entry;
|
||||
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("" + entry.dateModifiedSecs + entry.width + entry.rotationDegrees);
|
||||
RequestOptions options = new RequestOptions()
|
||||
.signature(signature)
|
||||
.format(DecodeFormat.PREFER_RGB_565)
|
||||
// add signature to ignore cache for images which got modified but kept the same URI
|
||||
.signature(new ObjectKey("" + dateModifiedSecs + rotationDegrees + isFlipped + width))
|
||||
.override(width, height);
|
||||
|
||||
FutureTarget<Bitmap> target;
|
||||
if (entry.isVideo()) {
|
||||
if (MimeTypes.isVideo(mimeType)) {
|
||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
|
||||
target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(new VideoThumbnail(activity, entry.uri))
|
||||
.signature(signature)
|
||||
.load(new VideoThumbnail(activity, uri))
|
||||
.submit(width, height);
|
||||
} else {
|
||||
target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(entry.uri)
|
||||
.signature(signature)
|
||||
.load(uri)
|
||||
.submit(width, height);
|
||||
}
|
||||
|
||||
try {
|
||||
Bitmap bitmap = target.get();
|
||||
String mimeType = entry.mimeType;
|
||||
if (MimeTypes.needRotationAfterGlide(mimeType)) {
|
||||
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
|
||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
|
||||
}
|
||||
return bitmap;
|
||||
} finally {
|
||||
|
@ -195,24 +204,15 @@ public class ImageDecodeTask extends AsyncTask<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;
|
||||
MethodChannel.Result r = params.result;
|
||||
AvesImageEntry entry = params.entry;
|
||||
String uri = entry.uri.toString();
|
||||
String uri = params.uri.toString();
|
||||
if (result.data != null) {
|
||||
r.success(result.data);
|
||||
} else {
|
||||
r.error("getThumbnail-null", "failed to get thumbnail for uri=" + uri + ", path=" + entry.path, result.errorDetails);
|
||||
r.error("getThumbnail-null", "failed to get thumbnail for uri=" + uri, result.errorDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import com.bumptech.glide.Glide;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
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;
|
||||
|
@ -58,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;
|
||||
|
@ -65,12 +68,16 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
|
||||
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
Map<String, Object> entryMap = call.argument("entry");
|
||||
String uri = call.argument("uri");
|
||||
String mimeType = call.argument("mimeType");
|
||||
Number dateModifiedSecs = (Number)call.argument("dateModifiedSecs");
|
||||
Integer rotationDegrees = call.argument("rotationDegrees");
|
||||
Boolean isFlipped = call.argument("isFlipped");
|
||||
Double widthDip = call.argument("widthDip");
|
||||
Double heightDip = call.argument("heightDip");
|
||||
Double defaultSizeDip = call.argument("defaultSizeDip");
|
||||
|
||||
if (entryMap == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
|
||||
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
|
||||
result.error("getThumbnail-args", "failed because of missing arguments", null);
|
||||
return;
|
||||
}
|
||||
|
@ -80,8 +87,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
int height = (int) Math.round(heightDip * density);
|
||||
int defaultSize = (int) Math.round(defaultSizeDip * density);
|
||||
|
||||
AvesImageEntry entry = new AvesImageEntry(entryMap);
|
||||
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result));
|
||||
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(uri, mimeType, dateModifiedSecs.longValue(), rotationDegrees, isFlipped, width, height, defaultSize, result));
|
||||
}
|
||||
|
||||
private void getObsoleteEntries(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
|
@ -167,7 +173,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
result.error("rotate-provider", "failed to find provider for uri=" + uri, null);
|
||||
return;
|
||||
}
|
||||
provider.rotate(activity, path, uri, mimeType, clockwise, new ImageProvider.ImageOpCallback() {
|
||||
ExifOrientationOp op = clockwise ? ExifOrientationOp.ROTATE_CW : ExifOrientationOp.ROTATE_CCW;
|
||||
provider.changeOrientation(activity, path, uri, mimeType, op, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -8,8 +8,8 @@ import android.os.Handler;
|
|||
import android.os.Looper;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.DecodeFormat;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
|
||||
import com.bumptech.glide.request.FutureTarget;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
|
||||
|
@ -19,6 +19,7 @@ import java.io.InputStream;
|
|||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail;
|
||||
import deckers.thibault.aves.utils.BitmapUtils;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
|
||||
|
@ -29,6 +30,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
|||
private Uri uri;
|
||||
private String mimeType;
|
||||
private int rotationDegrees;
|
||||
private boolean isFlipped;
|
||||
private EventChannel.EventSink eventSink;
|
||||
private Handler handler;
|
||||
|
||||
|
@ -40,6 +42,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
|||
this.mimeType = (String) argMap.get("mimeType");
|
||||
this.uri = Uri.parse((String) argMap.get("uri"));
|
||||
this.rotationDegrees = (int) argMap.get("rotationDegrees");
|
||||
this.isFlipped = (boolean) argMap.get("isFlipped");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,9 +74,13 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
|||
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
|
||||
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
|
||||
private void getImage() {
|
||||
// request a fresh image with the highest quality format
|
||||
RequestOptions options = new RequestOptions()
|
||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true);
|
||||
|
||||
if (MimeTypes.isVideo(mimeType)) {
|
||||
RequestOptions options = new RequestOptions()
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
|
||||
FutureTarget<Bitmap> target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
|
@ -95,21 +102,27 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
|||
} finally {
|
||||
Glide.with(activity).clear(target);
|
||||
}
|
||||
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees)) {
|
||||
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
// we convert the image on platform side first, when Dart Image.memory does not support it
|
||||
FutureTarget<Bitmap> target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(uri)
|
||||
.submit();
|
||||
try {
|
||||
Bitmap bitmap = target.get();
|
||||
if (MimeTypes.needRotationAfterGlide(mimeType)) {
|
||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
|
||||
}
|
||||
if (bitmap != null) {
|
||||
// TODO TLAD use exif orientation to rotate & flip?
|
||||
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
|
||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
|
||||
// Bitmap.CompressFormat.PNG is slower than JPEG
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
|
||||
// Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
|
||||
if (MimeTypes.canHaveAlpha(mimeType)) {
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
|
||||
} else {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
|
||||
}
|
||||
success(stream.toByteArray());
|
||||
} else {
|
||||
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
package deckers.thibault.aves.channel.streams;
|
||||
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
|
||||
public class IntentStreamHandler implements EventChannel.StreamHandler {
|
||||
private EventChannel.EventSink eventSink;
|
||||
|
||||
@Override
|
||||
public void onListen(Object args, EventChannel.EventSink eventSink) {
|
||||
this.eventSink = eventSink;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(Object arguments) {
|
||||
}
|
||||
|
||||
public void notifyNewIntent() {
|
||||
eventSink.success(true);
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
package deckers.thibault.aves.channel.streams;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
|
||||
public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
|
||||
public static final String CHANNEL = "deckers.thibault/aves/mediastorestream";
|
||||
|
||||
private Context context;
|
||||
private EventChannel.EventSink eventSink;
|
||||
private Handler handler;
|
||||
private Map<Integer, Integer> knownEntries;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public MediaStoreStreamHandler(Context context, Object arguments) {
|
||||
this.context = context;
|
||||
if (arguments instanceof Map) {
|
||||
Map<String, Object> argMap = (Map<String, Object>) arguments;
|
||||
this.knownEntries = (Map<Integer, Integer>) argMap.get("knownEntries");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onListen(Object args, EventChannel.EventSink eventSink) {
|
||||
this.eventSink = eventSink;
|
||||
this.handler = new Handler(Looper.getMainLooper());
|
||||
new Thread(this::fetchAll).start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(Object args) {
|
||||
// nothing
|
||||
}
|
||||
|
||||
private void success(final Map<String, Object> result) {
|
||||
handler.post(() -> eventSink.success(result));
|
||||
}
|
||||
|
||||
private void endOfStream() {
|
||||
handler.post(() -> eventSink.endOfStream());
|
||||
}
|
||||
|
||||
void fetchAll() {
|
||||
new MediaStoreImageProvider().fetchAll(context, knownEntries, this::success); // 350ms
|
||||
endOfStream();
|
||||
}
|
||||
}
|
|
@ -1,52 +0,0 @@
|
|||
package deckers.thibault.aves.channel.streams;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
|
||||
// starting activity to give access with the native dialog
|
||||
// breaks the regular `MethodChannel` so we use a stream channel instead
|
||||
public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
|
||||
public static final String CHANNEL = "deckers.thibault/aves/storageaccessstream";
|
||||
|
||||
private Activity activity;
|
||||
private EventChannel.EventSink eventSink;
|
||||
private Handler handler;
|
||||
private String path;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public StorageAccessStreamHandler(Activity activity, Object arguments) {
|
||||
this.activity = activity;
|
||||
if (arguments instanceof Map) {
|
||||
Map<String, Object> argMap = (Map<String, Object>) arguments;
|
||||
this.path = (String) argMap.get("path");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onListen(Object args, EventChannel.EventSink eventSink) {
|
||||
this.eventSink = eventSink;
|
||||
this.handler = new Handler(Looper.getMainLooper());
|
||||
Runnable onGranted = () -> success(true); // user gave access to a directory, with no guarantee that it matches the specified `path`
|
||||
Runnable onDenied = () -> success(false); // user cancelled
|
||||
PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancel(Object o) {
|
||||
}
|
||||
|
||||
private void success(final boolean result) {
|
||||
handler.post(() -> eventSink.success(result));
|
||||
endOfStream();
|
||||
}
|
||||
|
||||
private void endOfStream() {
|
||||
handler.post(() -> eventSink.endOfStream());
|
||||
}
|
||||
}
|
|
@ -9,19 +9,13 @@ import com.bumptech.glide.Glide;
|
|||
import com.bumptech.glide.GlideBuilder;
|
||||
import com.bumptech.glide.Registry;
|
||||
import com.bumptech.glide.annotation.GlideModule;
|
||||
import com.bumptech.glide.load.DecodeFormat;
|
||||
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser;
|
||||
import com.bumptech.glide.module.AppGlideModule;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@GlideModule
|
||||
public class AvesAppGlideModule extends AppGlideModule {
|
||||
@Override
|
||||
public void applyOptions(@NotNull Context context, @NonNull GlideBuilder builder) {
|
||||
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
|
||||
|
||||
public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
|
||||
// hide noisy warning (e.g. for images that can't be decoded)
|
||||
builder.setLogLevel(Log.ERROR);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,44 @@
|
|||
package deckers.thibault.aves.model.provider;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.MediaStore;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.model.SourceImageEntry;
|
||||
|
||||
class ContentImageProvider extends ImageProvider {
|
||||
@Override
|
||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||
SourceImageEntry entry = new SourceImageEntry(uri, mimeType).fillPreCatalogMetadata(context);
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("uri", uri.toString());
|
||||
map.put("sourceMimeType", mimeType);
|
||||
|
||||
if (entry.getHasSize() || entry.isSvg()) {
|
||||
String[] projection = {
|
||||
MediaStore.MediaColumns.SIZE,
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
};
|
||||
try {
|
||||
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
|
||||
if (cursor != null) {
|
||||
if (cursor.moveToNext()) {
|
||||
map.put("sizeBytes", cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)));
|
||||
map.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)));
|
||||
}
|
||||
cursor.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
callback.onFailure(e);
|
||||
return;
|
||||
}
|
||||
|
||||
SourceImageEntry entry = new SourceImageEntry(map).fillPreCatalogMetadata(context);
|
||||
if (entry.isSized() || entry.isSvg()) {
|
||||
callback.onSuccess(entry.toMap());
|
||||
} else {
|
||||
callback.onFailure(new Exception("entry has no size"));
|
||||
|
|
|
@ -8,14 +8,13 @@ import androidx.annotation.NonNull;
|
|||
import java.io.File;
|
||||
|
||||
import deckers.thibault.aves.model.SourceImageEntry;
|
||||
import deckers.thibault.aves.utils.FileUtils;
|
||||
|
||||
class FileImageProvider extends ImageProvider {
|
||||
@Override
|
||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||
SourceImageEntry entry = new SourceImageEntry(uri, mimeType);
|
||||
|
||||
String path = FileUtils.getPathFromUri(context, uri);
|
||||
String path = uri.getPath();
|
||||
if (path != null) {
|
||||
try {
|
||||
File file = new File(path);
|
||||
|
@ -28,7 +27,7 @@ class FileImageProvider extends ImageProvider {
|
|||
}
|
||||
entry.fillPreCatalogMetadata(context);
|
||||
|
||||
if (entry.getHasSize() || entry.isSvg()) {
|
||||
if (entry.isSized() || entry.isSvg()) {
|
||||
callback.onSuccess(entry.toMap());
|
||||
} else {
|
||||
callback.onFailure(new Exception("entry has no size"));
|
||||
|
|
|
@ -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,
|
||||
|
@ -437,7 +437,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
|
||||
fullPath = destinationDir;
|
||||
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
|
||||
volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
|
||||
volumePath = StorageUtils.getVolumePath(context, destinationDir);
|
||||
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,218 +0,0 @@
|
|||
/*
|
||||
* Copyright (C) 2007-2008 OpenIntents.org
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* This file was modified by the Flutter authors from the following original file:
|
||||
* https://raw.githubusercontent.com/iPaulPro/aFileChooser/master/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java
|
||||
*/
|
||||
|
||||
// TLAD: formatted code copied from:
|
||||
// https://raw.githubusercontent.com/flutter/plugins/master/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java
|
||||
// do not add code to this file!
|
||||
|
||||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
public class FileUtils {
|
||||
// useful (for Download, File, etc.) but slower
|
||||
// than directly using `MediaStore.MediaColumns.DATA` from the MediaStore query
|
||||
public static String getPathFromUri(final Context context, final Uri uri) {
|
||||
String path = getPathFromLocalUri(context, uri);
|
||||
if (path == null) {
|
||||
path = getPathFromRemoteUri(context, uri);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private static String getPathFromLocalUri(final Context context, final Uri uri) {
|
||||
final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
|
||||
|
||||
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
|
||||
if (isExternalStorageDocument(uri)) {
|
||||
final String docId = DocumentsContract.getDocumentId(uri);
|
||||
final String[] split = docId.split(":");
|
||||
final String type = split[0];
|
||||
|
||||
if ("primary".equalsIgnoreCase(type)) {
|
||||
return Environment.getExternalStorageDirectory() + "/" + split[1];
|
||||
}
|
||||
} else if (isDownloadsDocument(uri)) {
|
||||
final String id = DocumentsContract.getDocumentId(uri);
|
||||
|
||||
if (!TextUtils.isEmpty(id)) {
|
||||
try {
|
||||
final Uri contentUri =
|
||||
ContentUris.withAppendedId(
|
||||
Uri.parse(Environment.DIRECTORY_DOWNLOADS), Long.valueOf(id));
|
||||
return getDataColumn(context, contentUri, null, null);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (isMediaDocument(uri)) {
|
||||
final String docId = DocumentsContract.getDocumentId(uri);
|
||||
final String[] split = docId.split(":");
|
||||
final String type = split[0];
|
||||
|
||||
Uri contentUri = null;
|
||||
if ("image".equals(type)) {
|
||||
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
|
||||
} else if ("video".equals(type)) {
|
||||
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
|
||||
} else if ("audio".equals(type)) {
|
||||
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
|
||||
}
|
||||
|
||||
final String selection = "_id=?";
|
||||
final String[] selectionArgs = new String[]{split[1]};
|
||||
|
||||
return getDataColumn(context, contentUri, selection, selectionArgs);
|
||||
}
|
||||
} else if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
|
||||
|
||||
// Return the remote address
|
||||
if (isGooglePhotosUri(uri)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getDataColumn(context, uri, null, null);
|
||||
} else if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme())) {
|
||||
return uri.getPath();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getDataColumn(
|
||||
Context context, Uri uri, String selection, String[] selectionArgs) {
|
||||
|
||||
final String column = "_data";
|
||||
final String[] projection = {column};
|
||||
try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
final int column_index = cursor.getColumnIndex(column);
|
||||
|
||||
//yandex.disk and dropbox do not have _data column
|
||||
if (column_index == -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cursor.getString(column_index);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getPathFromRemoteUri(final Context context, final Uri uri) {
|
||||
// The code below is why Java now has try-with-resources and the Files utility.
|
||||
File file = null;
|
||||
InputStream inputStream = null;
|
||||
OutputStream outputStream = null;
|
||||
boolean success = false;
|
||||
try {
|
||||
String extension = getImageExtension(context, uri);
|
||||
inputStream = context.getContentResolver().openInputStream(uri);
|
||||
file = File.createTempFile("image_picker", extension, context.getCacheDir());
|
||||
outputStream = new FileOutputStream(file);
|
||||
if (inputStream != null) {
|
||||
copy(inputStream, outputStream);
|
||||
success = true;
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
} finally {
|
||||
try {
|
||||
if (inputStream != null) inputStream.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
try {
|
||||
if (outputStream != null) outputStream.close();
|
||||
} catch (IOException ignored) {
|
||||
// If closing the output stream fails, we cannot be sure that the
|
||||
// target file was written in full. Flushing the stream merely moves
|
||||
// the bytes into the OS, not necessarily to the file.
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success ? file.getPath() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return extension of image with dot, or default .jpg if it none.
|
||||
*/
|
||||
private static String getImageExtension(Context context, Uri uriImage) {
|
||||
String extension = null;
|
||||
|
||||
try (Cursor cursor = context
|
||||
.getContentResolver()
|
||||
.query(uriImage, new String[]{MediaStore.MediaColumns.MIME_TYPE}, null, null, null)) {
|
||||
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
String mimeType = cursor.getString(0);
|
||||
|
||||
extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
if (extension == null) {
|
||||
//default extension for matches the previous behavior of the plugin
|
||||
extension = "jpg";
|
||||
}
|
||||
return "." + extension;
|
||||
}
|
||||
|
||||
private static void copy(InputStream in, OutputStream out) throws IOException {
|
||||
final byte[] buffer = new byte[4 * 1024];
|
||||
int bytesRead;
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
|
||||
private static boolean isExternalStorageDocument(Uri uri) {
|
||||
return "com.android.externalstorage.documents".equals(uri.getAuthority());
|
||||
}
|
||||
|
||||
private static boolean isDownloadsDocument(Uri uri) {
|
||||
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
|
||||
}
|
||||
|
||||
private static boolean isMediaDocument(Uri uri) {
|
||||
return "com.android.providers.media.documents".equals(uri.getAuthority());
|
||||
}
|
||||
|
||||
private static boolean isGooglePhotosUri(Uri uri) {
|
||||
return "com.google.android.apps.photos.contentprovider".equals(uri.getAuthority());
|
||||
}
|
||||
}
|
|
@ -1,180 +0,0 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.UriPermission;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.storage.StorageManager;
|
||||
import android.os.storage.StorageVolume;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
|
||||
import com.google.common.base.Splitter;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class PermissionManager {
|
||||
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
|
||||
|
||||
public static final int VOLUME_ROOT_PERMISSION_REQUEST_CODE = 1;
|
||||
|
||||
// permission request code to pending runnable
|
||||
private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>();
|
||||
|
||||
public static void requestVolumeAccess(@NonNull Activity activity, @NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) {
|
||||
Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + path);
|
||||
pendingPermissionMap.put(VOLUME_ROOT_PERMISSION_REQUEST_CODE, new PendingPermissionHandler(path, onGranted, onDenied));
|
||||
|
||||
Intent intent = null;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
StorageManager sm = activity.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(path));
|
||||
if (volume != null) {
|
||||
intent = volume.createOpenDocumentTreeIntent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to basic open document tree intent
|
||||
if (intent == null) {
|
||||
intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
}
|
||||
|
||||
ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null);
|
||||
}
|
||||
|
||||
public static void onPermissionResult(int requestCode, @Nullable Uri treeUri) {
|
||||
Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", treeUri=" + treeUri);
|
||||
boolean granted = treeUri != null;
|
||||
|
||||
PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode);
|
||||
if (handler == null) return;
|
||||
|
||||
Runnable runnable = granted ? handler.onGranted : handler.onDenied;
|
||||
if (runnable == null) return;
|
||||
runnable.run();
|
||||
}
|
||||
|
||||
public static Optional<String> getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) {
|
||||
return getAccessibleDirs(context).stream().filter(anyPath::startsWith).findFirst();
|
||||
}
|
||||
|
||||
public static List<Map<String, String>> getInaccessibleDirectories(@NonNull Context context, @NonNull List<String> dirPaths) {
|
||||
Set<String> accessibleDirs = getAccessibleDirs(context);
|
||||
|
||||
// find set of inaccessible directories for each volume
|
||||
Map<String, Set<String>> dirsPerVolume = new HashMap<>();
|
||||
for (String dirPath : dirPaths) {
|
||||
if (!dirPath.endsWith(File.separator)) {
|
||||
dirPath += File.separator;
|
||||
}
|
||||
if (accessibleDirs.stream().noneMatch(dirPath::startsWith)) {
|
||||
// inaccessible dirs
|
||||
StorageUtils.PathSegments segments = new StorageUtils.PathSegments(context, dirPath);
|
||||
Set<String> dirSet = dirsPerVolume.getOrDefault(segments.volumePath, new HashSet<>());
|
||||
if (dirSet != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// request primary directory on volume from Android R
|
||||
String relativeDir = segments.relativeDir;
|
||||
if (relativeDir != null) {
|
||||
Iterator<String> iterator = Splitter.on(File.separatorChar).omitEmptyStrings().split(relativeDir).iterator();
|
||||
if (iterator.hasNext()) {
|
||||
// primary dir
|
||||
dirSet.add(iterator.next());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// request volume root until Android Q
|
||||
dirSet.add("");
|
||||
}
|
||||
}
|
||||
dirsPerVolume.put(segments.volumePath, dirSet);
|
||||
}
|
||||
}
|
||||
|
||||
// format for easier handling on Flutter
|
||||
List<Map<String, String>> inaccessibleDirs = new ArrayList<>();
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
for (Map.Entry<String, Set<String>> volumeEntry : dirsPerVolume.entrySet()) {
|
||||
String volumePath = volumeEntry.getKey();
|
||||
String volumeDescription = "";
|
||||
try {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
|
||||
if (volume != null) {
|
||||
volumeDescription = volume.getDescription(context);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// ignore
|
||||
}
|
||||
for (String relativeDir : volumeEntry.getValue()) {
|
||||
HashMap<String, String> dirMap = new HashMap<>();
|
||||
dirMap.put("volumePath", volumePath);
|
||||
dirMap.put("volumeDescription", volumeDescription);
|
||||
dirMap.put("relativeDir", relativeDir);
|
||||
inaccessibleDirs.add(dirMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=" + dirPaths + " -> inaccessibleDirs=" + inaccessibleDirs);
|
||||
return inaccessibleDirs;
|
||||
}
|
||||
|
||||
|
||||
public static void revokeDirectoryAccess(Context context, String path) {
|
||||
Optional<Uri> uri = StorageUtils.convertDirPathToTreeUri(context, path);
|
||||
if (uri.isPresent()) {
|
||||
int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
|
||||
context.getContentResolver().releasePersistableUriPermission(uri.get(), flags);
|
||||
}
|
||||
}
|
||||
|
||||
// returns paths matching URIs granted by the user
|
||||
public static Set<String> getGrantedDirs(Context context) {
|
||||
Set<String> grantedDirs = new HashSet<>();
|
||||
for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) {
|
||||
Optional<String> dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.getUri());
|
||||
dirPath.ifPresent(grantedDirs::add);
|
||||
}
|
||||
return grantedDirs;
|
||||
}
|
||||
|
||||
// returns paths accessible to the app (granted by the user or by default)
|
||||
private static Set<String> getAccessibleDirs(Context context) {
|
||||
Set<String> accessibleDirs = new HashSet<>(getGrantedDirs(context));
|
||||
// from Android R, we no longer have access permission by default on primary volume
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||
String primaryPath = StorageUtils.getPrimaryVolumePath();
|
||||
accessibleDirs.add(primaryPath);
|
||||
}
|
||||
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=" + accessibleDirs);
|
||||
return accessibleDirs;
|
||||
}
|
||||
|
||||
static class PendingPermissionHandler {
|
||||
final String path;
|
||||
final Runnable onGranted; // user gave access to a directory, with no guarantee that it matches the specified `path`
|
||||
final Runnable onDenied; // user cancelled
|
||||
|
||||
PendingPermissionHandler(@NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) {
|
||||
this.path = path;
|
||||
this.onGranted = onGranted;
|
||||
this.onDenied = onDenied;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,488 +0,0 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.os.storage.StorageManager;
|
||||
import android.os.storage.StorageVolume;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.commonsware.cwac.document.DocumentFileCompat;
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.collect.Lists;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class StorageUtils {
|
||||
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
|
||||
|
||||
/**
|
||||
* Volume paths
|
||||
*/
|
||||
|
||||
// volume paths, with trailing "/"
|
||||
private static String[] mStorageVolumePaths;
|
||||
|
||||
// primary volume path, with trailing "/"
|
||||
private static String mPrimaryVolumePath;
|
||||
|
||||
public static String getPrimaryVolumePath() {
|
||||
if (mPrimaryVolumePath == null) {
|
||||
mPrimaryVolumePath = findPrimaryVolumePath();
|
||||
}
|
||||
return mPrimaryVolumePath;
|
||||
}
|
||||
|
||||
public static String[] getVolumePaths(@NonNull Context context) {
|
||||
if (mStorageVolumePaths == null) {
|
||||
mStorageVolumePaths = findVolumePaths(context);
|
||||
}
|
||||
return mStorageVolumePaths;
|
||||
}
|
||||
|
||||
public static Optional<String> getVolumePath(@NonNull Context context, @NonNull String anyPath) {
|
||||
return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Iterator<String> getPathStepIterator(@NonNull Context context, @NonNull String anyPath, @Nullable String root) {
|
||||
if (root == null) {
|
||||
root = getVolumePath(context, anyPath).orElse(null);
|
||||
if (root == null) return null;
|
||||
}
|
||||
|
||||
String relativePath = null, filename = null;
|
||||
int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1;
|
||||
int rootLength = root.length();
|
||||
if (lastSeparatorIndex > rootLength) {
|
||||
filename = anyPath.substring(lastSeparatorIndex);
|
||||
relativePath = anyPath.substring(rootLength, lastSeparatorIndex);
|
||||
}
|
||||
if (relativePath == null) return null;
|
||||
|
||||
ArrayList<String> pathSteps = Lists.newArrayList(Splitter.on(File.separatorChar)
|
||||
.trimResults().omitEmptyStrings().split(relativePath));
|
||||
if (filename.length() > 0) {
|
||||
pathSteps.add(filename);
|
||||
}
|
||||
return pathSteps.iterator();
|
||||
}
|
||||
|
||||
private static String findPrimaryVolumePath() {
|
||||
String primaryVolumePath = Environment.getExternalStorageDirectory().getAbsolutePath();
|
||||
if (!primaryVolumePath.endsWith(File.separator)) {
|
||||
primaryVolumePath += File.separator;
|
||||
}
|
||||
return primaryVolumePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all available SD-Cards in the system (include emulated)
|
||||
* <p/>
|
||||
* Warning: Hack! Based on Android source code of version 4.3 (API 18)
|
||||
* Because there is no standard way to get it.
|
||||
* Edited by hendrawd
|
||||
*
|
||||
* @return paths to all available SD-Cards in the system (include emulated)
|
||||
*/
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
private static String[] findVolumePaths(Context context) {
|
||||
// Final set of paths
|
||||
final Set<String> rv = new HashSet<>();
|
||||
|
||||
// Primary emulated SD-CARD
|
||||
final String rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET");
|
||||
|
||||
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
|
||||
// fix of empty raw emulated storage on marshmallow
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
List<File> files;
|
||||
boolean validFiles;
|
||||
do {
|
||||
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
|
||||
// (e.g. on API 30 emulator) so we retry until the file system is ready
|
||||
files = Arrays.asList(context.getExternalFilesDirs(null));
|
||||
validFiles = !files.contains(null);
|
||||
if (!validFiles) {
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Log.e(LOG_TAG, "insomnia", e);
|
||||
}
|
||||
}
|
||||
} while (!validFiles);
|
||||
for (File file : files) {
|
||||
String applicationSpecificAbsolutePath = file.getAbsolutePath();
|
||||
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
|
||||
rv.add(emulatedRootPath);
|
||||
}
|
||||
} else {
|
||||
// Primary physical SD-CARD (not emulated)
|
||||
final String rawExternalStorage = System.getenv("EXTERNAL_STORAGE");
|
||||
|
||||
// Device has physical external storage; use plain paths.
|
||||
if (TextUtils.isEmpty(rawExternalStorage)) {
|
||||
// EXTERNAL_STORAGE undefined; falling back to default.
|
||||
rv.addAll(Arrays.asList(getPhysicalPaths()));
|
||||
} else {
|
||||
rv.add(rawExternalStorage);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||
final String rawUserId;
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
rawUserId = "";
|
||||
} else {
|
||||
final String path = Environment.getExternalStorageDirectory().getAbsolutePath();
|
||||
final String[] folders = path.split(File.separator);
|
||||
final String lastFolder = folders[folders.length - 1];
|
||||
boolean isDigit = TextUtils.isDigitsOnly(lastFolder);
|
||||
rawUserId = isDigit ? lastFolder : "";
|
||||
}
|
||||
// /storage/emulated/0[1,2,...]
|
||||
if (TextUtils.isEmpty(rawUserId)) {
|
||||
rv.add(rawEmulatedStorageTarget);
|
||||
} else {
|
||||
rv.add(rawEmulatedStorageTarget + File.separator + rawUserId);
|
||||
}
|
||||
}
|
||||
|
||||
// All Secondary SD-CARDs (all exclude primary) separated by ":"
|
||||
final String rawSecondaryStoragesStr = System.getenv("SECONDARY_STORAGE");
|
||||
|
||||
// Add all secondary storages
|
||||
if (!TextUtils.isEmpty(rawSecondaryStoragesStr)) {
|
||||
// All Secondary SD-CARDs split into array
|
||||
final String[] rawSecondaryStorages = rawSecondaryStoragesStr.split(File.pathSeparator);
|
||||
Collections.addAll(rv, rawSecondaryStorages);
|
||||
}
|
||||
|
||||
String[] paths = rv.toArray(new String[0]);
|
||||
for (int i = 0; i < paths.length; i++) {
|
||||
String path = paths[i];
|
||||
if (!path.endsWith(File.separator)) {
|
||||
paths[i] = path + File.separator;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return physicalPaths based on phone model
|
||||
*/
|
||||
@SuppressLint("SdCardPath")
|
||||
private static String[] getPhysicalPaths() {
|
||||
return new String[]{
|
||||
"/storage/sdcard0",
|
||||
"/storage/sdcard1", //Motorola Xoom
|
||||
"/storage/extsdcard", //Samsung SGS3
|
||||
"/storage/sdcard0/external_sdcard", //User request
|
||||
"/mnt/extsdcard",
|
||||
"/mnt/sdcard/external_sd", //Samsung galaxy family
|
||||
"/mnt/external_sd",
|
||||
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
|
||||
"/removable/microsd", //Asus transformer prime
|
||||
"/mnt/emmc",
|
||||
"/storage/external_SD", //LG
|
||||
"/storage/ext_sd", //HTC One Max
|
||||
"/storage/removable/sdcard1", //Sony Xperia Z1
|
||||
"/data/sdext",
|
||||
"/data/sdext2",
|
||||
"/data/sdext3",
|
||||
"/data/sdext4",
|
||||
"/sdcard1", //Sony Xperia Z
|
||||
"/sdcard2", //HTC One M8s
|
||||
"/storage/microsd" //ASUS ZenFone 2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
private static Optional<String> getVolumeUuidForTreeUri(@NonNull Context context, @NonNull String anyPath) {
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(anyPath));
|
||||
if (volume != null) {
|
||||
if (volume.isPrimary()) {
|
||||
return Optional.of("primary");
|
||||
}
|
||||
String uuid = volume.getUuid();
|
||||
if (uuid != null) {
|
||||
return Optional.of(uuid.toUpperCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Optional<String> getVolumePathFromTreeUriUuid(@NonNull Context context, @NonNull String uuid) {
|
||||
if (uuid.equals("primary")) {
|
||||
return Optional.of(getPrimaryVolumePath());
|
||||
}
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
for (String volumePath : StorageUtils.getVolumePaths(context)) {
|
||||
try {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
|
||||
if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) {
|
||||
return Optional.of(volumePath);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume path for UUID=" + uuid);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
|
||||
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
|
||||
static Optional<Uri> convertDirPathToTreeUri(@NonNull Context context, @NonNull String dirPath) {
|
||||
Optional<String> uuid = getVolumeUuidForTreeUri(context, dirPath);
|
||||
if (uuid.isPresent()) {
|
||||
String relativeDir = new PathSegments(context, dirPath).relativeDir;
|
||||
if (relativeDir == null) {
|
||||
relativeDir = "";
|
||||
} else if (relativeDir.endsWith(File.separator)) {
|
||||
relativeDir = relativeDir.substring(0, relativeDir.length() - 1);
|
||||
}
|
||||
Uri treeUri = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", uuid.get() + ":" + relativeDir);
|
||||
return Optional.of(treeUri);
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert dirPath=" + dirPath + " to tree URI");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
|
||||
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
|
||||
static Optional<String> convertTreeUriToDirPath(@NonNull Context context, @NonNull Uri treeUri) {
|
||||
String encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length());
|
||||
Matcher matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded));
|
||||
if (matcher.find()) {
|
||||
String uuid = matcher.group(1);
|
||||
String relativePath = matcher.group(2);
|
||||
if (uuid != null && relativePath != null) {
|
||||
Optional<String> volumePath = getVolumePathFromTreeUriUuid(context, uuid);
|
||||
if (volumePath.isPresent()) {
|
||||
String dirPath = volumePath.get() + relativePath;
|
||||
if (!dirPath.endsWith(File.separator)) {
|
||||
dirPath += File.separator;
|
||||
}
|
||||
return Optional.of(dirPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert treeUri=" + treeUri + " to path");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Document files
|
||||
*/
|
||||
|
||||
@Nullable
|
||||
public static DocumentFileCompat getDocumentFile(@NonNull Context context, @NonNull String anyPath, @NonNull Uri mediaUri) {
|
||||
if (requireAccessPermission(anyPath)) {
|
||||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// cleanest API to get it
|
||||
Uri docUri = MediaStore.getDocumentUri(context, mediaUri);
|
||||
if (docUri != null) {
|
||||
return DocumentFileCompat.fromSingleUri(context, docUri);
|
||||
}
|
||||
}
|
||||
// fallback for older APIs
|
||||
return getVolumePath(context, anyPath)
|
||||
.flatMap(volumePath -> convertDirPathToTreeUri(context, volumePath)
|
||||
.flatMap(treeUri -> getDocumentFileFromVolumeTree(context, treeUri, anyPath)))
|
||||
.orElse(null);
|
||||
|
||||
}
|
||||
// good old `File`
|
||||
return DocumentFileCompat.fromFile(new File(anyPath));
|
||||
}
|
||||
|
||||
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
|
||||
// returns null if directory does not exist and could not be created
|
||||
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Context context, @NonNull String dirPath) {
|
||||
if (!dirPath.endsWith(File.separator)) {
|
||||
dirPath += File.separator;
|
||||
}
|
||||
if (requireAccessPermission(dirPath)) {
|
||||
String grantedDir = PermissionManager.getGrantedDirForPath(context, dirPath).orElse(null);
|
||||
if (grantedDir == null) return null;
|
||||
|
||||
Uri rootTreeUri = convertDirPathToTreeUri(context, grantedDir).orElse(null);
|
||||
if (rootTreeUri == null) return null;
|
||||
|
||||
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
|
||||
if (parentFile == null) return null;
|
||||
|
||||
Iterator<String> pathIterator = getPathStepIterator(context, dirPath, grantedDir);
|
||||
while (pathIterator != null && pathIterator.hasNext()) {
|
||||
String dirName = pathIterator.next();
|
||||
DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName);
|
||||
if (dirFile == null || !dirFile.exists()) {
|
||||
try {
|
||||
dirFile = parentFile.createDirectory(dirName);
|
||||
if (dirFile == null) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile);
|
||||
return null;
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
parentFile = dirFile;
|
||||
}
|
||||
return parentFile;
|
||||
} else {
|
||||
File directory = new File(dirPath);
|
||||
if (!directory.exists()) {
|
||||
if (!directory.mkdirs()) {
|
||||
Log.e(LOG_TAG, "failed to create directories at path=" + dirPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return DocumentFileCompat.fromFile(directory);
|
||||
}
|
||||
}
|
||||
|
||||
public static String copyFileToTemp(@NonNull DocumentFileCompat documentFile, @NonNull String path) {
|
||||
String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(path)).toString());
|
||||
try {
|
||||
File temp = File.createTempFile("aves", '.' + extension);
|
||||
documentFile.copyTo(DocumentFileCompat.fromFile(temp));
|
||||
temp.deleteOnExit();
|
||||
return temp.getPath();
|
||||
} catch (IOException e) {
|
||||
Log.e(LOG_TAG, "failed to copy file from path=" + path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, @NonNull Uri rootTreeUri, @NonNull String anyPath) {
|
||||
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
|
||||
if (documentFile == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// follow the entry path down the document tree
|
||||
Iterator<String> pathIterator = getPathStepIterator(context, anyPath, null);
|
||||
while (pathIterator != null && pathIterator.hasNext()) {
|
||||
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next());
|
||||
if (documentFile == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
return Optional.of(documentFile);
|
||||
}
|
||||
|
||||
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
|
||||
private static DocumentFileCompat findDocumentFileIgnoreCase(DocumentFileCompat documentFile, String displayName) {
|
||||
for (DocumentFileCompat doc : documentFile.listFiles()) {
|
||||
if (displayName.equalsIgnoreCase(doc.getName())) {
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Misc
|
||||
*/
|
||||
|
||||
public static boolean requireAccessPermission(@NonNull String anyPath) {
|
||||
// on Android R, we should always require access permission, even on primary volume
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||
return true;
|
||||
}
|
||||
boolean onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath());
|
||||
return !onPrimaryVolume;
|
||||
}
|
||||
|
||||
private static boolean isMediaStoreContentUri(Uri uri) {
|
||||
// a URI's authority is [userinfo@]host[:port]
|
||||
// but we only want the host when comparing to Media Store's "authority"
|
||||
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost());
|
||||
}
|
||||
|
||||
public static InputStream openInputStream(@NonNull Context context, @NonNull Uri uri) throws FileNotFoundException {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (isMediaStoreContentUri(uri)) {
|
||||
uri = MediaStore.setRequireOriginal(uri);
|
||||
}
|
||||
}
|
||||
return context.getContentResolver().openInputStream(uri);
|
||||
}
|
||||
|
||||
public static MediaMetadataRetriever openMetadataRetriever(@NonNull Context context, @NonNull Uri uri) {
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (isMediaStoreContentUri(uri)) {
|
||||
uri = MediaStore.setRequireOriginal(uri);
|
||||
}
|
||||
}
|
||||
retriever.setDataSource(context, uri);
|
||||
} catch (Exception e) {
|
||||
// unsupported format
|
||||
return null;
|
||||
}
|
||||
return retriever;
|
||||
}
|
||||
|
||||
public static class PathSegments {
|
||||
String fullPath; // should match "volumePath + relativeDir + filename"
|
||||
String volumePath; // with trailing "/"
|
||||
String relativeDir; // with trailing "/"
|
||||
String filename; // null for directories
|
||||
|
||||
PathSegments(@NonNull Context context, @NonNull String fullPath) {
|
||||
this.fullPath = fullPath;
|
||||
volumePath = StorageUtils.getVolumePath(context, fullPath).orElse(null);
|
||||
if (volumePath == null) return;
|
||||
|
||||
int lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1;
|
||||
int volumePathLength = volumePath.length();
|
||||
if (lastSeparatorIndex > volumePathLength) {
|
||||
filename = fullPath.substring(lastSeparatorIndex);
|
||||
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,15 +12,15 @@ import androidx.core.graphics.drawable.IconCompat
|
|||
import app.loup.streams_channel.StreamsChannel
|
||||
import deckers.thibault.aves.channel.calls.*
|
||||
import deckers.thibault.aves.channel.streams.*
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import deckers.thibault.aves.utils.Utils
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
companion object {
|
||||
private val LOG_TAG = Utils.createLogTag(MainActivity::class.java)
|
||||
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
|
||||
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
|
||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||
}
|
||||
|
@ -139,7 +139,7 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
|
||||
if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) {
|
||||
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
|
||||
val treeUri = data.data
|
||||
if (resultCode != RESULT_OK || treeUri == null) {
|
||||
PermissionManager.onPermissionResult(requestCode, null)
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
|
@ -12,6 +14,7 @@ import androidx.exifinterface.media.ExifInterface
|
|||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPUtils
|
||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.imaging.ImageProcessingException
|
||||
import com.drew.lang.Rational
|
||||
|
@ -23,28 +26,36 @@ import com.drew.metadata.file.FileTypeDirectory
|
|||
import com.drew.metadata.gif.GifAnimationDirectory
|
||||
import com.drew.metadata.webp.WebpDirectory
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.utils.*
|
||||
import deckers.thibault.aves.utils.ExifInterfaceHelper.describeAll
|
||||
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
|
||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDescription
|
||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
|
||||
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
|
||||
import deckers.thibault.aves.utils.Metadata.isFlippedForExifCode
|
||||
import deckers.thibault.aves.utils.Metadata.parseVideoMetadataDate
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeBoolean
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDescription
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeRational
|
||||
import deckers.thibault.aves.utils.MimeTypes.getMimeTypeForExtension
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
|
||||
import deckers.thibault.aves.metadata.Metadata.parseVideoMetadataDate
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.XMP.getSafeLocalizedText
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.math.roundToLong
|
||||
|
@ -67,7 +78,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<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
|
||||
|
@ -79,7 +90,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
||||
|
@ -120,7 +131,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (!foundExif) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val allTags = describeAll(exif).toMutableMap()
|
||||
if (foundXmp) {
|
||||
|
@ -167,14 +178,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = Uri.parse(call.argument("uri"))
|
||||
val extension = call.argument<String>("extension")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension))
|
||||
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType))
|
||||
if (isVideo(mimeType)) {
|
||||
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
|
||||
}
|
||||
|
@ -183,14 +193,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, extension: String?): Map<String, Any> {
|
||||
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String): Map<String, Any> {
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
|
||||
var foundExif = false
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||
|
||||
|
@ -200,14 +210,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
// the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`)
|
||||
// `context.getContentResolver().getType()` sometimes return incorrect value
|
||||
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
|
||||
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
|
||||
val detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
|
||||
if (detectedMimeType != null && detectedMimeType != mimeType) {
|
||||
// file extension is unreliable, but we use it as a tie breaker
|
||||
val extensionMimeType = extension?.toLowerCase(Locale.ROOT)?.let { getMimeTypeForExtension(it) }
|
||||
if (extensionMimeType == null || detectedMimeType == extensionMimeType) {
|
||||
metadataMap[KEY_MIME_TYPE] = detectedMimeType
|
||||
}
|
||||
// file extension is unreliable
|
||||
// in the end, `metadata-extractor` is the most reliable, unless it reports `tiff`
|
||||
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
|
||||
if (it != MimeTypes.TIFF) {
|
||||
metadataMap[KEY_MIME_TYPE] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -278,7 +285,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (!foundExif) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
|
@ -289,7 +296,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
|
||||
}
|
||||
val latLong = exif.latLong
|
||||
if (latLong != null && latLong.size == 2) {
|
||||
if (latLong?.size == 2) {
|
||||
metadataMap[KEY_LATITUDE] = latLong[0]
|
||||
metadataMap[KEY_LONGITUDE] = latLong[1]
|
||||
}
|
||||
|
@ -320,22 +327,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||
if (locationString != null) {
|
||||
val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
|
||||
val latitudeString = locationMatcher.group(1)
|
||||
val longitudeString = locationMatcher.group(2)
|
||||
if (latitudeString != null && longitudeString != null) {
|
||||
try {
|
||||
val latitude = latitudeString.toDoubleOrNull() ?: 0
|
||||
val longitude = longitudeString.toDoubleOrNull() ?: 0
|
||||
// keep `0.0` as `0.0`, not `0`
|
||||
if (latitude != 0.0 || longitude != 0.0) {
|
||||
metadataMap[KEY_LATITUDE] = latitude
|
||||
metadataMap[KEY_LONGITUDE] = longitude
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
// ignore
|
||||
}
|
||||
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||
if (matcher.find() && matcher.groupCount() >= 2) {
|
||||
// keep `0.0` as `0.0`, not `0`
|
||||
val latitude = matcher.group(1)?.toDoubleOrNull() ?: 0.0
|
||||
val longitude = matcher.group(2)?.toDoubleOrNull() ?: 0.0
|
||||
if (latitude != 0.0 || longitude != 0.0) {
|
||||
metadataMap[KEY_LATITUDE] = latitude
|
||||
metadataMap[KEY_LONGITUDE] = longitude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -350,7 +349,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<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
|
||||
|
@ -362,7 +361,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
|
||||
|
@ -382,7 +381,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
result.success(metadataMap)
|
||||
}
|
||||
} ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null)
|
||||
} catch (e: Exception) {
|
||||
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
|
||||
} catch (e: NoClassDefFoundError) {
|
||||
|
@ -392,20 +391,27 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<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
|
||||
}
|
||||
|
||||
val id = ContentUris.parseId(uri)
|
||||
var contentUri = when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
contentUri = MediaStore.setRequireOriginal(contentUri)
|
||||
var contentUri: Uri = uri
|
||||
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
|
||||
try {
|
||||
val id = ContentUris.parseId(uri)
|
||||
contentUri = when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
contentUri = MediaStore.setRequireOriginal(contentUri)
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
|
||||
|
@ -436,21 +442,21 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = Uri.parse(call.argument("uri"))
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
val metadataMap = HashMap<String, String?>()
|
||||
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
|
||||
metadataMap[tag] = exif.getAttribute(tag)
|
||||
}
|
||||
result.success(metadataMap)
|
||||
}
|
||||
} ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
|
||||
} catch (e: Exception) {
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
// caused by an internal MediaMetadataRetriever failure
|
||||
|
@ -459,7 +465,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = Uri.parse(call.argument("uri"))
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
|
@ -483,7 +489,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = Uri.parse(call.argument("uri"))
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
|
||||
return
|
||||
|
@ -505,7 +511,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = Uri.parse(call.argument("uri"))
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
|
||||
return
|
||||
|
@ -513,9 +519,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
val thumbnails = ArrayList<ByteArray>()
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
exif.thumbnailBytes?.let { thumbnails.add(it) }
|
||||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
exif.thumbnailBitmap?.let {
|
||||
val bitmap = TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), it, orientation)
|
||||
if (bitmap != null) {
|
||||
val stream = ByteArrayOutputStream()
|
||||
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
|
||||
// Bitmap.CompressFormat.PNG is slower than JPEG
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
|
||||
thumbnails.add(stream.toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ExifInterface initialization can fail with a RuntimeException
|
||||
|
@ -526,7 +542,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<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
|
||||
|
@ -535,7 +551,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
val thumbnails = ArrayList<ByteArray>()
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
val xmpMeta = dir.xmpMeta
|
||||
|
@ -567,7 +583,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = Utils.createLogTag(MetadataHandler::class.java)
|
||||
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||
|
||||
// catalog metadata
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
|
||||
class IntentStreamHandler : EventChannel.StreamHandler {
|
||||
private lateinit var eventSink: EventSink
|
||||
|
||||
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||
this.eventSink = eventSink
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
fun notifyNewIntent() {
|
||||
eventSink.success(true)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
|
||||
class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler {
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
private var knownEntries: Map<Int, Int>? = null
|
||||
|
||||
init {
|
||||
if (arguments is Map<*, *>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
knownEntries = arguments["knownEntries"] as Map<Int, Int>?
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
Thread { fetchAll() }.start()
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
private fun success(result: Map<String, Any>) {
|
||||
handler.post { eventSink.success(result) }
|
||||
}
|
||||
|
||||
private fun endOfStream() {
|
||||
handler.post { eventSink.endOfStream() }
|
||||
}
|
||||
|
||||
private fun fetchAll() {
|
||||
MediaStoreImageProvider().fetchAll(context, knownEntries) { success(it) }
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/mediastorestream"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import deckers.thibault.aves.utils.PermissionManager.requestVolumeAccess
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
|
||||
// starting activity to give access with the native dialog
|
||||
// breaks the regular `MethodChannel` so we use a stream channel instead
|
||||
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
private var path: String? = null
|
||||
|
||||
init {
|
||||
if (arguments is Map<*, *>) {
|
||||
path = arguments["path"] as String?
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
requestVolumeAccess(activity, path!!, { success(true) }, { success(false) })
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
private fun success(result: Boolean) {
|
||||
handler.post { eventSink.success(result) }
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
private fun endOfStream() {
|
||||
handler.post { eventSink.endOfStream() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/storageaccessstream"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
@ -11,6 +11,10 @@ object MetadataExtractorHelper {
|
|||
if (this.containsTag(tag)) save(this.getDescription(tag))
|
||||
}
|
||||
|
||||
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
|
||||
if (this.containsTag(tag)) save(this.getString(tag))
|
||||
}
|
||||
|
||||
fun Directory.getSafeBoolean(tag: Int, save: (value: Boolean) -> Unit) {
|
||||
if (this.containsTag(tag)) save(this.getBoolean(tag))
|
||||
}
|
|
@ -1,9 +1,13 @@
|
|||
package deckers.thibault.aves.utils
|
||||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.util.Log
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
||||
object XMP {
|
||||
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
|
||||
|
||||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
||||
const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
|
||||
|
@ -15,12 +19,15 @@ object XMP {
|
|||
private const val GENERIC_LANG = ""
|
||||
private const val SPECIFIC_LANG = "en-US"
|
||||
|
||||
@Throws(XMPException::class)
|
||||
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
|
||||
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
||||
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
|
||||
// double check retrieved items as the property sometimes is reported to exist but it is actually null
|
||||
if (item != null) save(item.value)
|
||||
try {
|
||||
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
||||
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
|
||||
// double check retrieved items as the property sometimes is reported to exist but it is actually null
|
||||
if (item != null) save(item.value)
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to get text for XMP propName=$propName", e)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
|
||||
class AvesImageEntry(map: Map<String?, Any?>) {
|
||||
@JvmField
|
||||
|
@ -21,18 +20,4 @@ class AvesImageEntry(map: Map<String?, Any?>) {
|
|||
|
||||
@JvmField
|
||||
val rotationDegrees = map["rotationDegrees"] as Int
|
||||
|
||||
@JvmField
|
||||
val dateModifiedSecs = toLong(map["dateModifiedSecs"])
|
||||
|
||||
val isVideo: Boolean
|
||||
get() = MimeTypes.isVideo(mimeType)
|
||||
|
||||
companion object {
|
||||
// convenience method
|
||||
private fun toLong(o: Any?): Long? = when (o) {
|
||||
is Int -> o.toLong()
|
||||
else -> o as? Long
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
|
||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong
|
||||
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
|
||||
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import java.io.IOException
|
||||
|
@ -101,15 +101,12 @@ class SourceImageEntry {
|
|||
return null
|
||||
}
|
||||
|
||||
val hasSize: Boolean
|
||||
val isSized: Boolean
|
||||
get() = width ?: 0 > 0 && height ?: 0 > 0
|
||||
|
||||
private val hasDuration: Boolean
|
||||
get() = durationMillis ?: 0 > 0
|
||||
|
||||
private val isImage: Boolean
|
||||
get() = MimeTypes.isImage(sourceMimeType)
|
||||
|
||||
private val isVideo: Boolean
|
||||
get() = MimeTypes.isVideo(sourceMimeType)
|
||||
|
||||
|
@ -123,15 +120,15 @@ class SourceImageEntry {
|
|||
if (isSvg) return this
|
||||
if (isVideo) {
|
||||
fillVideoByMediaMetadataRetriever(context)
|
||||
if (hasSize && hasDuration) return this
|
||||
if (isSized && hasDuration) return this
|
||||
}
|
||||
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
|
||||
fillByMetadataExtractor(context)
|
||||
if (hasSize && foundExif) return this
|
||||
if (isSized && foundExif) return this
|
||||
}
|
||||
if (ExifInterface.isSupportedMimeType(sourceMimeType)) {
|
||||
fillByExifInterface(context)
|
||||
if (hasSize) return this
|
||||
if (isSized) return this
|
||||
}
|
||||
fillByBitmapDecode(context)
|
||||
return this
|
||||
|
@ -158,7 +155,7 @@ class SourceImageEntry {
|
|||
// finds: width, height, orientation, date, duration
|
||||
private fun fillByMetadataExtractor(context: Context) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
||||
// do not switch on specific mime types, as the reported mime type could be wrong
|
||||
|
@ -209,7 +206,7 @@ class SourceImageEntry {
|
|||
// finds: width, height, orientation, date
|
||||
private fun fillByExifInterface(context: Context) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
foundExif = true
|
||||
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
|
||||
|
@ -226,7 +223,7 @@ class SourceImageEntry {
|
|||
// finds: width, height
|
||||
private fun fillByBitmapDecode(context: Context) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri).use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeStream(input, null, options)
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
||||
|
||||
object BitmapUtils {
|
||||
@JvmStatic
|
||||
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
|
||||
if (rotationDegrees == 0 && !isFlipped) return bitmap
|
||||
val exifOrientation = getExifCode(rotationDegrees, isFlipped)
|
||||
return TransformationUtils.rotateImageExif(getBitmapPool(context), bitmap, exifOrientation)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun centerSquareCrop(context: Context, bitmap: Bitmap?, size: Int): Bitmap? {
|
||||
bitmap ?: return bitmap
|
||||
return TransformationUtils.centerCrop(getBitmapPool(context), bitmap, size, size)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getBitmapPool(context: Context) = Glide.get(context).bitmapPool
|
||||
}
|
|
@ -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) {
|
|
@ -1,7 +1,5 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import java.util.*
|
||||
|
||||
object MimeTypes {
|
||||
private const val IMAGE = "image"
|
||||
|
||||
|
@ -12,44 +10,20 @@ object MimeTypes {
|
|||
private const val HEIF = "image/heif"
|
||||
private const val ICO = "image/x-icon"
|
||||
private const val JPEG = "image/jpeg"
|
||||
private const val PCX = "image/x-pcx"
|
||||
private const val PNG = "image/png"
|
||||
private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop"
|
||||
private const val TIFF = "image/tiff"
|
||||
const val TIFF = "image/tiff"
|
||||
private const val WBMP = "image/vnd.wap.wbmp"
|
||||
const val WEBP = "image/webp"
|
||||
|
||||
// raw raster
|
||||
private const val ARW = "image/x-sony-arw"
|
||||
private const val CR2 = "image/x-canon-cr2"
|
||||
private const val CRW = "image/x-canon-crw"
|
||||
private const val DCR = "image/x-kodak-dcr"
|
||||
private const val DNG = "image/x-adobe-dng"
|
||||
private const val ERF = "image/x-epson-erf"
|
||||
private const val K25 = "image/x-kodak-k25"
|
||||
private const val KDC = "image/x-kodak-kdc"
|
||||
private const val MRW = "image/x-minolta-mrw"
|
||||
private const val NEF = "image/x-nikon-nef"
|
||||
private const val NRW = "image/x-nikon-nrw"
|
||||
private const val ORF = "image/x-olympus-orf"
|
||||
private const val PEF = "image/x-pentax-pef"
|
||||
private const val RAF = "image/x-fuji-raf"
|
||||
private const val RAW = "image/x-panasonic-raw"
|
||||
private const val RW2 = "image/x-panasonic-rw2"
|
||||
private const val SR2 = "image/x-sony-sr2"
|
||||
private const val SRF = "image/x-sony-srf"
|
||||
private const val SRW = "image/x-samsung-srw"
|
||||
private const val X3F = "image/x-sigma-x3f"
|
||||
|
||||
// vector
|
||||
const val SVG = "image/svg+xml"
|
||||
|
||||
private const val VIDEO = "video"
|
||||
|
||||
private const val AVI = "video/avi"
|
||||
private const val MOV = "video/quicktime"
|
||||
private const val MP2T = "video/mp2t"
|
||||
private const val MP4 = "video/mp4"
|
||||
private const val WEBM = "video/webm"
|
||||
|
||||
@JvmStatic
|
||||
|
@ -58,11 +32,19 @@ object MimeTypes {
|
|||
@JvmStatic
|
||||
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
||||
|
||||
@JvmStatic
|
||||
// returns whether the specified MIME type represents
|
||||
// a raster image format that allows an alpha channel
|
||||
fun canHaveAlpha(mimeType: String?) = when (mimeType) {
|
||||
BMP, GIF, ICO, PNG, TIFF, WEBP -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of Flutter v1.22.0
|
||||
@JvmStatic
|
||||
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?) = when (mimeType) {
|
||||
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
|
||||
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
||||
PNG -> rotationDegrees ?: 0 == 0
|
||||
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
@ -75,6 +57,8 @@ object MimeTypes {
|
|||
|
||||
// Glide automatically applies EXIF orientation when decoding images of known formats
|
||||
// but we need to rotate the decoded bitmap for the other formats
|
||||
// maybe related to ExifInterface version used by Glide:
|
||||
// https://github.com/bumptech/glide/blob/master/gradle.properties#L21
|
||||
@JvmStatic
|
||||
fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
|
||||
DNG, HEIC, HEIF, PNG, WEBP -> true
|
||||
|
@ -89,50 +73,4 @@ object MimeTypes {
|
|||
DNG, PNG -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) {
|
||||
// generic raster
|
||||
".bmp" -> BMP
|
||||
".gif" -> GIF
|
||||
".heic" -> HEIC
|
||||
".heif" -> HEIF
|
||||
".ico" -> ICO
|
||||
".jpg", ".jpeg", ".jpe" -> JPEG
|
||||
".pcx" -> PCX
|
||||
".png" -> PNG
|
||||
".psd" -> PSD
|
||||
".tiff", ".tif" -> TIFF
|
||||
".wbmp" -> WBMP
|
||||
".webp" -> WEBP
|
||||
// raw raster
|
||||
".arw" -> ARW
|
||||
".cr2" -> CR2
|
||||
".crw" -> CRW
|
||||
".dcr" -> DCR
|
||||
".dng" -> DNG
|
||||
".erf" -> ERF
|
||||
".k25" -> K25
|
||||
".kdc" -> KDC
|
||||
".mrw" -> MRW
|
||||
".nef" -> NEF
|
||||
".nrw" -> NRW
|
||||
".orf" -> ORF
|
||||
".pef" -> PEF
|
||||
".raf" -> RAF
|
||||
".raw" -> RAW
|
||||
".rw2" -> RW2
|
||||
".sr2" -> SR2
|
||||
".srf" -> SRF
|
||||
".srw" -> SRW
|
||||
".x3f" -> X3F
|
||||
// vector
|
||||
".svg" -> SVG
|
||||
// video
|
||||
".avi" -> AVI
|
||||
".m2ts" -> MP2T
|
||||
".mov", ".qt" -> MOV
|
||||
".mp4", ".m4a", ".m4p", ".m4b", ".m4r", ".m4v" -> MP4
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object PermissionManager {
|
||||
private val LOG_TAG = createTag(PermissionManager::class.java)
|
||||
|
||||
const val VOLUME_ACCESS_REQUEST_CODE = 1
|
||||
|
||||
// permission request code to pending runnable
|
||||
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
|
||||
|
||||
@JvmStatic
|
||||
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) {
|
||||
Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path")
|
||||
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied)
|
||||
|
||||
var intent: Intent? = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val sm = activity.getSystemService(StorageManager::class.java)
|
||||
intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent()
|
||||
}
|
||||
|
||||
// fallback to basic open document tree intent
|
||||
if (intent == null) {
|
||||
intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
}
|
||||
|
||||
ActivityCompat.startActivityForResult(activity, intent, VOLUME_ACCESS_REQUEST_CODE, null)
|
||||
}
|
||||
|
||||
fun onPermissionResult(requestCode: Int, treeUri: Uri?) {
|
||||
Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, treeUri=$treeUri")
|
||||
val handler = pendingPermissionMap.remove(requestCode) ?: return
|
||||
(if (treeUri != null) handler.onGranted else handler.onDenied)()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getGrantedDirForPath(context: Context, anyPath: String): String? {
|
||||
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
|
||||
val accessibleDirs = getAccessibleDirs(context)
|
||||
|
||||
// find set of inaccessible directories for each volume
|
||||
val dirsPerVolume = HashMap<String, MutableSet<String>>()
|
||||
for (dirPath in dirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) {
|
||||
if (accessibleDirs.none { dirPath.startsWith(it) }) {
|
||||
// inaccessible dirs
|
||||
val segments = PathSegments(context, dirPath)
|
||||
segments.volumePath?.let { volumePath ->
|
||||
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// request primary directory on volume from Android R
|
||||
segments.relativeDir?.apply {
|
||||
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() }
|
||||
primaryDir?.let { dirSet.add(it) }
|
||||
}
|
||||
} else {
|
||||
// request volume root until Android Q
|
||||
dirSet.add("")
|
||||
}
|
||||
dirsPerVolume[volumePath] = dirSet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// format for easier handling on Flutter
|
||||
val inaccessibleDirs = ArrayList<Map<String, String>>()
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for ((volumePath, relativeDirs) in dirsPerVolume) {
|
||||
var volumeDescription: String? = null
|
||||
try {
|
||||
volumeDescription = sm.getStorageVolume(File(volumePath))?.getDescription(context)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
for (relativeDir in relativeDirs) {
|
||||
val dirMap = HashMap<String, String>()
|
||||
dirMap["volumePath"] = volumePath
|
||||
dirMap["volumeDescription"] = volumeDescription ?: ""
|
||||
dirMap["relativeDir"] = relativeDir
|
||||
inaccessibleDirs.add(dirMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=$dirPaths -> inaccessibleDirs=$inaccessibleDirs")
|
||||
return inaccessibleDirs
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun revokeDirectoryAccess(context: Context, path: String) {
|
||||
StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.releasePersistableUriPermission(it, flags)
|
||||
}
|
||||
}
|
||||
|
||||
// returns paths matching URIs granted by the user
|
||||
@JvmStatic
|
||||
fun getGrantedDirs(context: Context): Set<String> {
|
||||
val grantedDirs = HashSet<String>()
|
||||
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
||||
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
|
||||
dirPath?.let { grantedDirs.add(it) }
|
||||
}
|
||||
return grantedDirs
|
||||
}
|
||||
|
||||
// returns paths accessible to the app (granted by the user or by default)
|
||||
private fun getAccessibleDirs(context: Context): Set<String> {
|
||||
val accessibleDirs = HashSet(getGrantedDirs(context))
|
||||
// from Android R, we no longer have access permission by default on primary volume
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||
accessibleDirs.add(StorageUtils.primaryVolumePath)
|
||||
}
|
||||
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs")
|
||||
return accessibleDirs
|
||||
}
|
||||
|
||||
// onGranted: user gave access to a directory, with no guarantee that it matches the specified `path`
|
||||
// onDenied: user cancelled
|
||||
internal data class PendingPermissionHandler(val path: String, val onGranted: () -> Unit, val onDenied: () -> Unit)
|
||||
}
|
|
@ -0,0 +1,428 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.utils.LogUtils.createTag
|
||||
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object StorageUtils {
|
||||
private val LOG_TAG = createTag(StorageUtils::class.java)
|
||||
|
||||
/**
|
||||
* Volume paths
|
||||
*/
|
||||
|
||||
// volume paths, with trailing "/"
|
||||
private var mStorageVolumePaths: Array<String>? = null
|
||||
|
||||
// primary volume path, with trailing "/"
|
||||
private var mPrimaryVolumePath: String? = null
|
||||
|
||||
val primaryVolumePath: String
|
||||
get() {
|
||||
if (mPrimaryVolumePath == null) {
|
||||
mPrimaryVolumePath = findPrimaryVolumePath()
|
||||
}
|
||||
return mPrimaryVolumePath!!
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getVolumePaths(context: Context): Array<String> {
|
||||
if (mStorageVolumePaths == null) {
|
||||
mStorageVolumePaths = findVolumePaths(context)
|
||||
}
|
||||
return mStorageVolumePaths!!
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getVolumePath(context: Context, anyPath: String): String? {
|
||||
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
|
||||
}
|
||||
|
||||
private fun getPathStepIterator(context: Context, anyPath: String, root: String?): Iterator<String?>? {
|
||||
val rootLength = (root ?: getVolumePath(context, anyPath))?.length ?: return null
|
||||
|
||||
var filename: String? = null
|
||||
var relativePath: String? = null
|
||||
val lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1
|
||||
if (lastSeparatorIndex > rootLength) {
|
||||
filename = anyPath.substring(lastSeparatorIndex)
|
||||
relativePath = anyPath.substring(rootLength, lastSeparatorIndex)
|
||||
}
|
||||
relativePath ?: return null
|
||||
|
||||
val pathSteps = relativePath.split(File.separator).filter { it.isNotEmpty() }.toMutableList()
|
||||
if (filename?.isNotEmpty() == true) {
|
||||
pathSteps.add(filename)
|
||||
}
|
||||
return pathSteps.iterator()
|
||||
}
|
||||
|
||||
private fun findPrimaryVolumePath(): String {
|
||||
return ensureTrailingSeparator(Environment.getExternalStorageDirectory().absolutePath)
|
||||
}
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
private fun findVolumePaths(context: Context): Array<String> {
|
||||
// Final set of paths
|
||||
val paths = HashSet<String>()
|
||||
|
||||
// Primary emulated SD-CARD
|
||||
val rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET") ?: ""
|
||||
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
|
||||
// fix of empty raw emulated storage on marshmallow
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
lateinit var files: List<File>
|
||||
var validFiles: Boolean
|
||||
do {
|
||||
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
|
||||
// (e.g. on API 30 emulator) so we retry until the file system is ready
|
||||
val externalFilesDirs = context.getExternalFilesDirs(null)
|
||||
validFiles = !externalFilesDirs.contains(null)
|
||||
if (validFiles) {
|
||||
files = externalFilesDirs.filterNotNull()
|
||||
} else {
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (e: InterruptedException) {
|
||||
Log.e(LOG_TAG, "insomnia", e)
|
||||
}
|
||||
}
|
||||
} while (!validFiles)
|
||||
for (file in files) {
|
||||
val appSpecificAbsolutePath = file.absolutePath
|
||||
val emulatedRootPath = appSpecificAbsolutePath.substring(0, appSpecificAbsolutePath.indexOf("Android/data"))
|
||||
paths.add(emulatedRootPath)
|
||||
}
|
||||
} else {
|
||||
// Primary physical SD-CARD (not emulated)
|
||||
val rawExternalStorage = System.getenv("EXTERNAL_STORAGE") ?: ""
|
||||
|
||||
// Device has physical external storage; use plain paths.
|
||||
if (TextUtils.isEmpty(rawExternalStorage)) {
|
||||
// EXTERNAL_STORAGE undefined; falling back to default.
|
||||
paths.addAll(physicalPaths)
|
||||
} else {
|
||||
paths.add(rawExternalStorage)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||
val path = Environment.getExternalStorageDirectory().absolutePath
|
||||
val rawUserId = path.split(File.separator).lastOrNull()?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
|
||||
// /storage/emulated/0[1,2,...]
|
||||
if (TextUtils.isEmpty(rawUserId)) {
|
||||
paths.add(rawEmulatedStorageTarget)
|
||||
} else {
|
||||
paths.add(rawEmulatedStorageTarget + File.separator + rawUserId)
|
||||
}
|
||||
}
|
||||
|
||||
// All Secondary SD-CARDs (all exclude primary) separated by ":"
|
||||
System.getenv("SECONDARY_STORAGE")?.let { secondaryStorages ->
|
||||
paths.addAll(secondaryStorages.split(File.pathSeparator).filter { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
return paths.map { ensureTrailingSeparator(it) }.toTypedArray()
|
||||
}
|
||||
|
||||
// return physicalPaths based on phone model
|
||||
private val physicalPaths: Array<String>
|
||||
@SuppressLint("SdCardPath")
|
||||
get() = arrayOf(
|
||||
"/storage/sdcard0",
|
||||
"/storage/sdcard1", //Motorola Xoom
|
||||
"/storage/extsdcard", //Samsung SGS3
|
||||
"/storage/sdcard0/external_sdcard", //User request
|
||||
"/mnt/extsdcard",
|
||||
"/mnt/sdcard/external_sd", //Samsung galaxy family
|
||||
"/mnt/external_sd",
|
||||
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
|
||||
"/removable/microsd", //Asus transformer prime
|
||||
"/mnt/emmc",
|
||||
"/storage/external_SD", //LG
|
||||
"/storage/ext_sd", //HTC One Max
|
||||
"/storage/removable/sdcard1", //Sony Xperia Z1
|
||||
"/data/sdext",
|
||||
"/data/sdext2",
|
||||
"/data/sdext3",
|
||||
"/data/sdext4",
|
||||
"/sdcard1", //Sony Xperia Z
|
||||
"/sdcard2", //HTC One M8s
|
||||
"/storage/microsd" //ASUS ZenFone 2
|
||||
)
|
||||
|
||||
/**
|
||||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
val volume = sm.getStorageVolume(File(anyPath))
|
||||
if (volume != null) {
|
||||
if (volume.isPrimary) {
|
||||
return "primary"
|
||||
}
|
||||
val uuid = volume.uuid
|
||||
if (uuid != null) {
|
||||
return uuid.toUpperCase(Locale.ROOT)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=$anyPath")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
|
||||
if (uuid == "primary") {
|
||||
return primaryVolumePath
|
||||
}
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
val volume = sm.getStorageVolume(File(volumePath))
|
||||
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
|
||||
return volumePath
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
|
||||
return null
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
|
||||
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
|
||||
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
|
||||
val uuid = getVolumeUuidForTreeUri(context, dirPath)
|
||||
if (uuid != null) {
|
||||
var relativeDir = PathSegments(context, dirPath).relativeDir ?: ""
|
||||
if (relativeDir.endsWith(File.separator)) {
|
||||
relativeDir = relativeDir.substring(0, relativeDir.length - 1)
|
||||
}
|
||||
return DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "$uuid:$relativeDir")
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree URI")
|
||||
return null
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
|
||||
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
|
||||
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? {
|
||||
val encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length)
|
||||
val matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded))
|
||||
with(matcher) {
|
||||
if (find()) {
|
||||
val uuid = group(1)
|
||||
val relativePath = group(2)
|
||||
if (uuid != null && relativePath != null) {
|
||||
val volumePath = getVolumePathFromTreeUriUuid(context, uuid)
|
||||
if (volumePath != null) {
|
||||
return ensureTrailingSeparator(volumePath + relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to convert treeUri=$treeUri to path")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Document files
|
||||
*/
|
||||
|
||||
@JvmStatic
|
||||
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
|
||||
if (requireAccessPermission(anyPath)) {
|
||||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// cleanest API to get it
|
||||
val docUri = MediaStore.getDocumentUri(context, mediaUri)
|
||||
if (docUri != null) {
|
||||
return DocumentFileCompat.fromSingleUri(context, docUri)
|
||||
}
|
||||
}
|
||||
// fallback for older APIs
|
||||
return getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
|
||||
}
|
||||
// good old `File`
|
||||
return DocumentFileCompat.fromFile(File(anyPath))
|
||||
}
|
||||
|
||||
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
|
||||
// returns null if directory does not exist and could not be created
|
||||
@JvmStatic
|
||||
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
|
||||
val cleanDirPath = ensureTrailingSeparator(dirPath)
|
||||
return if (requireAccessPermission(cleanDirPath)) {
|
||||
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
|
||||
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
|
||||
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
|
||||
val pathIterator = getPathStepIterator(context, cleanDirPath, grantedDir)
|
||||
while (pathIterator?.hasNext() == true) {
|
||||
val dirName = pathIterator.next()
|
||||
var dirFile = findDocumentFileIgnoreCase(parentFile, dirName)
|
||||
if (dirFile == null || !dirFile.exists()) {
|
||||
try {
|
||||
dirFile = parentFile?.createDirectory(dirName)
|
||||
if (dirFile == null) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=$dirName from parent=$parentFile")
|
||||
return null
|
||||
}
|
||||
} catch (e: FileNotFoundException) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=$dirName from parent=$parentFile", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
parentFile = dirFile
|
||||
}
|
||||
parentFile
|
||||
} else {
|
||||
val directory = File(cleanDirPath)
|
||||
if (!directory.exists() && !directory.mkdirs()) {
|
||||
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
|
||||
return null
|
||||
}
|
||||
DocumentFileCompat.fromFile(directory)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun copyFileToTemp(documentFile: DocumentFileCompat, path: String): String? {
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString())
|
||||
try {
|
||||
val temp = File.createTempFile("aves", ".$extension")
|
||||
documentFile.copyTo(DocumentFileCompat.fromFile(temp))
|
||||
temp.deleteOnExit()
|
||||
return temp.path
|
||||
} catch (e: IOException) {
|
||||
Log.e(LOG_TAG, "failed to copy file from path=$path")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? {
|
||||
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
|
||||
|
||||
// follow the entry path down the document tree
|
||||
val pathIterator = getPathStepIterator(context, anyPath, null)
|
||||
while (pathIterator?.hasNext() == true) {
|
||||
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next()) ?: return null
|
||||
}
|
||||
return documentFile
|
||||
}
|
||||
|
||||
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
|
||||
private fun findDocumentFileIgnoreCase(documentFile: DocumentFileCompat?, displayName: String?): DocumentFileCompat? {
|
||||
documentFile ?: return null
|
||||
for (doc in documentFile.listFiles()) {
|
||||
if (displayName.equals(doc.name, ignoreCase = true)) {
|
||||
return doc
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Misc
|
||||
*/
|
||||
|
||||
@JvmStatic
|
||||
fun requireAccessPermission(anyPath: String): Boolean {
|
||||
// on Android R, we should always require access permission, even on primary volume
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||
return true
|
||||
}
|
||||
val onPrimaryVolume = anyPath.startsWith(primaryVolumePath)
|
||||
return !onPrimaryVolume
|
||||
}
|
||||
|
||||
private fun isMediaStoreContentUri(uri: Uri?): Boolean {
|
||||
uri ?: return false
|
||||
// a URI's authority is [userinfo@]host[:port]
|
||||
// but we only want the host when comparing to Media Store's "authority"
|
||||
return ContentResolver.SCHEME_CONTENT.equals(uri.scheme, ignoreCase = true) && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||
var effectiveUri = uri
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
effectiveUri = MediaStore.setRequireOriginal(uri)
|
||||
}
|
||||
|
||||
return try {
|
||||
context.contentResolver.openInputStream(effectiveUri)
|
||||
} catch (e: FileNotFoundException) {
|
||||
Log.w(LOG_TAG, "failed to find file at uri=$effectiveUri")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun openMetadataRetriever(context: Context, uri: Uri): MediaMetadataRetriever? {
|
||||
var effectiveUri = uri
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
effectiveUri = MediaStore.setRequireOriginal(uri)
|
||||
}
|
||||
|
||||
return try {
|
||||
MediaMetadataRetriever().apply {
|
||||
setDataSource(context, effectiveUri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// unsupported format
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// convenience methods
|
||||
|
||||
private fun ensureTrailingSeparator(dirPath: String): String {
|
||||
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
|
||||
}
|
||||
|
||||
// `fullPath` should match "volumePath + relativeDir + filename"
|
||||
class PathSegments(context: Context, fullPath: String) {
|
||||
var volumePath: String? = null // `volumePath` with trailing "/"
|
||||
var relativeDir: String? = null // `relativeDir` with trailing "/"
|
||||
var filename: String? = null // null for directories
|
||||
|
||||
init {
|
||||
volumePath = getVolumePath(context, fullPath)
|
||||
if (volumePath != null) {
|
||||
val lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1
|
||||
val volumePathLength = volumePath!!.length
|
||||
if (lastSeparatorIndex > volumePathLength) {
|
||||
filename = fullPath.substring(lastSeparatorIndex)
|
||||
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ buildscript {
|
|||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.6.3' // do not upgrade to 4+ until this is fixed: https://github.com/flutter/flutter/issues/58247
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.3'
|
||||
classpath 'com.google.gms:google-services:4.3.4'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0'
|
||||
}
|
||||
}
|
||||
|
|
BIN
extra/play/screenshots v1.2.1/S10/1-S10-collection.png
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
extra/play/screenshots v1.2.1/S10/2-S10-image.png
Normal file
After Width: | Height: | Size: 3.8 MiB |
BIN
extra/play/screenshots v1.2.1/S10/3-S10-info__basic_.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
extra/play/screenshots v1.2.1/S10/4-S10-info__metadata_.png
Normal file
After Width: | Height: | Size: 322 KiB |
BIN
extra/play/screenshots v1.2.1/S10/5-S10-stats.png
Normal file
After Width: | Height: | Size: 330 KiB |
BIN
extra/play/screenshots v1.2.1/S10/6-S10-countries.png
Normal file
After Width: | Height: | Size: 3.3 MiB |
BIN
extra/play/screenshots v1.2.1/raw/1 Collection.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
extra/play/screenshots v1.2.1/raw/2 image.png
Normal file
After Width: | Height: | Size: 3.4 MiB |
BIN
extra/play/screenshots v1.2.1/raw/3 Info basic.png
Normal file
After Width: | Height: | Size: 914 KiB |
|
@ -65,6 +65,21 @@ class _AvesAppState extends State<AvesApp> {
|
|||
),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
primary: accentColor,
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
primary: accentColor,
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
primary: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget get firstPage => settings.hasAcceptedTerms ? HomePage() : WelcomePage();
|
||||
|
|
45
lib/model/entry_cache.dart
Normal file
|
@ -0,0 +1,45 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
||||
|
||||
class EntryCache {
|
||||
static Future<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(ThumbnailProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
)).evict();
|
||||
|
||||
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
||||
final extents = List.generate(6, (index) => pow(2, index + 5).toDouble());
|
||||
await Future.forEach<double>(
|
||||
extents,
|
||||
(extent) => ThumbnailProvider(ThumbnailProviderKey(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
dateModifiedSecs: dateModifiedSecs,
|
||||
rotationDegrees: oldRotationDegrees,
|
||||
isFlipped: oldIsFlipped,
|
||||
extent: extent,
|
||||
)).evict());
|
||||
}
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry_cache.dart';
|
||||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
|
@ -75,8 +77,8 @@ class ImageEntry {
|
|||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: durationMillis,
|
||||
)
|
||||
.._catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
|
||||
.._addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
|
||||
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
@ -154,7 +156,7 @@ class ImageEntry {
|
|||
|
||||
// the MIME type reported by the Media Store is unreliable
|
||||
// so we use the one found during cataloguing if possible
|
||||
String get mimeType => catalogMetadata?.mimeType ?? sourceMimeType;
|
||||
String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType;
|
||||
|
||||
String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
|
||||
|
||||
|
@ -173,13 +175,11 @@ class ImageEntry {
|
|||
|
||||
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
|
||||
|
||||
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
|
||||
|
||||
bool get canEdit => path != null;
|
||||
|
||||
bool get canPrint => !isVideo;
|
||||
|
||||
bool get canRotate => canEdit && canEditExif;
|
||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||
|
||||
// support for writing EXIF
|
||||
// as of androidx.exifinterface:exifinterface:1.3.0
|
||||
|
@ -194,7 +194,7 @@ class ImageEntry {
|
|||
}
|
||||
}
|
||||
|
||||
bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.rotationDegrees : rotationDegrees) % 180 == 90;
|
||||
bool get portrait => rotationDegrees % 180 == 90;
|
||||
|
||||
double get displayAspectRatio {
|
||||
if (width == 0 || height == 0) return 1;
|
||||
|
@ -220,13 +220,17 @@ class ImageEntry {
|
|||
return _bestDate;
|
||||
}
|
||||
|
||||
int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
|
||||
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees ?? 0;
|
||||
|
||||
set rotationDegrees(int rotationDegrees) {
|
||||
sourceRotationDegrees = rotationDegrees;
|
||||
catalogMetadata?.rotationDegrees = rotationDegrees;
|
||||
_catalogMetadata?.rotationDegrees = rotationDegrees;
|
||||
}
|
||||
|
||||
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
|
||||
|
||||
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
|
||||
|
||||
int get dateModifiedSecs => _dateModifiedSecs;
|
||||
|
||||
set dateModifiedSecs(int dateModifiedSecs) {
|
||||
|
@ -276,10 +280,16 @@ class ImageEntry {
|
|||
}
|
||||
|
||||
set catalogMetadata(CatalogMetadata newMetadata) {
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
|
||||
catalogDateMillis = newMetadata?.dateMillis;
|
||||
_catalogMetadata = newMetadata;
|
||||
_bestTitle = null;
|
||||
metadataChangeNotifier.notifyListeners();
|
||||
|
||||
_onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
}
|
||||
|
||||
void clearMetadata() {
|
||||
|
@ -351,12 +361,7 @@ class ImageEntry {
|
|||
return false;
|
||||
}
|
||||
|
||||
Future<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'];
|
||||
|
@ -365,6 +370,24 @@ class ImageEntry {
|
|||
if (contentId is int) this.contentId = contentId;
|
||||
final sourceTitle = newFields['title'];
|
||||
if (sourceTitle is String) this.sourceTitle = sourceTitle;
|
||||
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
||||
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
||||
final rotationDegrees = newFields['rotationDegrees'];
|
||||
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
|
||||
final isFlipped = newFields['isFlipped'];
|
||||
if (isFlipped is bool) this.isFlipped = isFlipped;
|
||||
|
||||
await metadataDb.saveEntries({this});
|
||||
await metadataDb.saveMetadata({catalogMetadata});
|
||||
}
|
||||
|
||||
Future<bool> rename(String newName) async {
|
||||
if (newName == filenameWithoutExtension) return true;
|
||||
|
||||
final newFields = await ImageFileService.rename(this, '$newName$extension');
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
await _applyNewFields(newFields);
|
||||
_bestTitle = null;
|
||||
metadataChangeNotifier.notifyListeners();
|
||||
return true;
|
||||
|
@ -374,14 +397,23 @@ class ImageEntry {
|
|||
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final width = newFields['width'];
|
||||
if (width is int) this.width = width;
|
||||
final height = newFields['height'];
|
||||
if (height is int) this.height = height;
|
||||
final rotationDegrees = newFields['rotationDegrees'];
|
||||
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
await _applyNewFields(newFields);
|
||||
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
return true;
|
||||
}
|
||||
|
||||
imageChangeNotifier.notifyListeners();
|
||||
Future<bool> flip() async {
|
||||
final newFields = await ImageFileService.flip(this);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
await _applyNewFields(newFields);
|
||||
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -399,6 +431,16 @@ class ImageEntry {
|
|||
return completer.future;
|
||||
}
|
||||
|
||||
// when the entry image itself changed (e.g. after rotation)
|
||||
void _onImageChanged(int oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
|
||||
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
||||
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
imageChangeNotifier.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// favourites
|
||||
|
||||
void toggleFavourite() {
|
||||
if (isFavourite) {
|
||||
removeFromFavourites();
|
||||
|
@ -419,18 +461,29 @@ class ImageEntry {
|
|||
}
|
||||
}
|
||||
|
||||
// compare by:
|
||||
// 1) title ascending
|
||||
// 2) extension ascending
|
||||
static int compareByName(ImageEntry a, ImageEntry b) {
|
||||
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
|
||||
}
|
||||
|
||||
// compare by:
|
||||
// 1) size descending
|
||||
// 2) name ascending
|
||||
static int compareBySize(ImageEntry a, ImageEntry b) {
|
||||
final c = b.sizeBytes.compareTo(a.sizeBytes);
|
||||
return c != 0 ? c : compareByName(a, b);
|
||||
}
|
||||
|
||||
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
// compare by:
|
||||
// 1) date descending
|
||||
// 2) name ascending
|
||||
static int compareByDate(ImageEntry a, ImageEntry b) {
|
||||
final c = b.bestDate?.compareTo(a.bestDate) ?? -1;
|
||||
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
||||
return c != 0 ? c : compareByName(a, b);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -110,7 +110,9 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
double get collectionTileExtent => _prefs.getDouble(collectionTileExtentKey) ?? 0;
|
||||
|
||||
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue);
|
||||
// do not notify, as `collectionTileExtent` is only used internally by `TileExtentManager`
|
||||
// and should not trigger rebuilding by change notification
|
||||
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue, notify: false);
|
||||
|
||||
bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true);
|
||||
|
||||
|
@ -189,7 +191,7 @@ class Settings extends ChangeNotifier {
|
|||
return _prefs.getStringList(key)?.map((s) => values.firstWhere((el) => el.toString() == s, orElse: () => null))?.where((el) => el != null)?.toList() ?? defaultValue;
|
||||
}
|
||||
|
||||
void setAndNotify(String key, dynamic newValue) {
|
||||
void setAndNotify(String key, dynamic newValue, {bool notify = true}) {
|
||||
var oldValue = _prefs.get(key);
|
||||
if (newValue == null) {
|
||||
_prefs.remove(key);
|
||||
|
@ -209,7 +211,7 @@ class Settings extends ChangeNotifier {
|
|||
oldValue = _prefs.getBool(key);
|
||||
_prefs.setBool(key, newValue);
|
||||
}
|
||||
if (oldValue != newValue) {
|
||||
if (oldValue != newValue && notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
enum Activity { browse, select }
|
||||
|
||||
enum ChipSortFactor { date, name }
|
||||
enum ChipSortFactor { date, name, count }
|
||||
|
||||
enum EntrySortFactor { date, size, name }
|
||||
|
||||
|
|
|
@ -30,7 +30,15 @@ class AppShortcutService {
|
|||
Uint8List iconBytes;
|
||||
if (iconEntry != null) {
|
||||
final size = iconEntry.isVideo ? 0.0 : 256.0;
|
||||
iconBytes = await ImageFileService.getThumbnail(iconEntry, size, size);
|
||||
iconBytes = await ImageFileService.getThumbnail(
|
||||
iconEntry.uri,
|
||||
iconEntry.mimeType,
|
||||
iconEntry.dateModifiedSecs,
|
||||
iconEntry.rotationDegrees,
|
||||
iconEntry.isFlipped,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await platform.invokeMethod('pin', <String, dynamic>{
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/mime_types.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -24,6 +25,7 @@ class ImageFileService {
|
|||
'width': entry.width,
|
||||
'height': entry.height,
|
||||
'rotationDegrees': entry.rotationDegrees,
|
||||
'isFlipped': entry.isFlipped,
|
||||
'dateModifiedSecs': entry.dateModifiedSecs,
|
||||
};
|
||||
}
|
||||
|
@ -66,7 +68,14 @@ class ImageFileService {
|
|||
return null;
|
||||
}
|
||||
|
||||
static Future<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();
|
||||
|
@ -75,6 +84,7 @@ class ImageFileService {
|
|||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
'rotationDegrees': rotationDegrees ?? 0,
|
||||
'isFlipped': isFlipped ?? false,
|
||||
}).listen(
|
||||
(data) {
|
||||
final chunk = data as Uint8List;
|
||||
|
@ -103,15 +113,29 @@ class ImageFileService {
|
|||
return Future.sync(() => null);
|
||||
}
|
||||
|
||||
static Future<Uint8List> getThumbnail(ImageEntry entry, double width, double height, {Object taskKey, int priority}) {
|
||||
if (entry.isSvg) {
|
||||
static Future<Uint8List> getThumbnail(
|
||||
String uri,
|
||||
String mimeType,
|
||||
int dateModifiedSecs,
|
||||
int rotationDegrees,
|
||||
bool isFlipped,
|
||||
double width,
|
||||
double height, {
|
||||
Object taskKey,
|
||||
int priority,
|
||||
}) {
|
||||
if (mimeType == MimeTypes.svg) {
|
||||
return Future.sync(() => null);
|
||||
}
|
||||
return servicePolicy.call(
|
||||
() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
'dateModifiedSecs': dateModifiedSecs,
|
||||
'rotationDegrees': rotationDegrees,
|
||||
'isFlipped': isFlipped,
|
||||
'widthDip': width,
|
||||
'heightDip': height,
|
||||
'defaultSizeDip': thumbnailDefaultSize,
|
||||
|
@ -183,7 +207,7 @@ class ImageFileService {
|
|||
|
||||
static Future<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,
|
||||
|
@ -194,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
|
||||
|
|
|
@ -43,7 +43,6 @@ class MetadataService {
|
|||
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'extension': entry.extension,
|
||||
}) as Map;
|
||||
result['contentId'] = entry.contentId;
|
||||
return CatalogMetadata.fromMap(result);
|
||||
|
|
|
@ -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;
|
||||
|
@ -103,7 +103,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('Crashlytics'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: FirebaseCrashlytics.instance.crash,
|
||||
child: Text('Crash'),
|
||||
),
|
||||
|
@ -123,7 +123,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
imageCache.clear();
|
||||
setState(() {});
|
||||
|
@ -138,7 +138,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
PictureProvider.clearCache();
|
||||
setState(() {});
|
||||
|
@ -153,7 +153,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('Glide disk cache: ?'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
|
||||
child: Text('Clear'),
|
||||
),
|
||||
|
@ -171,7 +171,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
|
||||
child: Text('Reset'),
|
||||
),
|
||||
|
@ -190,7 +190,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('DB entry rows: ${snapshot.data.length}'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
|
@ -209,7 +209,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('DB date rows: ${snapshot.data.length}'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
|
@ -228,7 +228,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('DB metadata rows: ${snapshot.data.length}'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
|
@ -247,7 +247,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('DB address rows: ${snapshot.data.length}'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
|
@ -266,7 +266,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => favourites.clear().then((_) => _startDbReport()),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
|
@ -288,7 +288,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
child: Text('Settings'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => settings.reset().then((_) => setState(() {})),
|
||||
child: Text('Reset'),
|
||||
),
|
|
@ -43,28 +43,42 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initProvider();
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ThumbnailRasterImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.entry != entry) {
|
||||
_pauseProvider();
|
||||
_initProvider();
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pauseProvider();
|
||||
_unregisterWidget(widget);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(ThumbnailRasterImage widget) {
|
||||
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
|
||||
_initProvider();
|
||||
}
|
||||
|
||||
void _unregisterWidget(ThumbnailRasterImage widget) {
|
||||
widget.entry.imageChangeNotifier?.removeListener(_onImageChanged);
|
||||
_pauseProvider();
|
||||
}
|
||||
|
||||
void _initProvider() {
|
||||
_fastThumbnailProvider = ThumbnailProvider(entry: entry);
|
||||
_fastThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry),
|
||||
);
|
||||
if (!entry.isVideo) {
|
||||
_sizedThumbnailProvider = ThumbnailProvider(entry: entry, extent: requestExtent);
|
||||
_sizedThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry, extent: requestExtent),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -139,6 +153,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
);
|
||||
if (imageCache.statusForKey(imageProvider).keepAlive) {
|
||||
|
@ -153,4 +168,12 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
child: image,
|
||||
);
|
||||
}
|
||||
|
||||
// when the entry image itself changed (e.g. after rotation)
|
||||
void _onImageChanged() async {
|
||||
// rebuild to refresh the thumbnails
|
||||
_pauseProvider();
|
||||
_initProvider();
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,14 +48,14 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
|
|||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return FlatButton(
|
||||
return TextButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text('Add'.toUpperCase()),
|
||||
);
|
||||
|
|
|
@ -92,14 +92,14 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
),
|
||||
],
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return FlatButton(
|
||||
return TextButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text('Create'.toUpperCase()),
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -127,11 +139,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
return AvesDialog(
|
||||
content: Text('Are you sure?'),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('Delete'.toUpperCase()),
|
||||
),
|
||||
|
|
|
@ -28,11 +28,11 @@ mixin PermissionAwareMixin {
|
|||
title: 'Storage Volume Access',
|
||||
content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('OK'.toUpperCase()),
|
||||
),
|
||||
|
|
|
@ -54,14 +54,14 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
|
|||
);
|
||||
}),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return FlatButton(
|
||||
return TextButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text('Apply'.toUpperCase()),
|
||||
);
|
||||
|
|
|
@ -48,14 +48,14 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
|||
onSubmitted: (_) => _submit(context),
|
||||
),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return FlatButton(
|
||||
return TextButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text('Apply'.toUpperCase()),
|
||||
);
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/widgets/collection/collection_actions.dart';
|
||||
|
@ -14,11 +16,14 @@ import 'package:aves/widgets/common/aves_dialog.dart';
|
|||
import 'package:aves/widgets/common/entry_actions.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||
final CollectionLens collection;
|
||||
|
@ -58,39 +63,49 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
|
||||
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
|
||||
final source = collection.source;
|
||||
final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source);
|
||||
final destinationAlbum = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<String>(
|
||||
builder: (context) {
|
||||
return FilterGridPage(
|
||||
source: source,
|
||||
appBar: SliverAppBar(
|
||||
leading: BackButton(),
|
||||
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(AIcons.createAlbum),
|
||||
onPressed: () async {
|
||||
final newAlbum = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => CreateAlbumDialog(),
|
||||
);
|
||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
||||
Navigator.pop<String>(context, newAlbum);
|
||||
}
|
||||
},
|
||||
tooltip: 'Create album',
|
||||
return Selector<Settings, ChipSortFactor>(
|
||||
selector: (context, s) => s.albumSortFactor,
|
||||
builder: (context, sortFactor, child) {
|
||||
return FilterGridPage(
|
||||
source: source,
|
||||
appBar: SliverAppBar(
|
||||
leading: BackButton(),
|
||||
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(AIcons.createAlbum),
|
||||
onPressed: () async {
|
||||
final newAlbum = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => CreateAlbumDialog(),
|
||||
);
|
||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
||||
Navigator.pop<String>(context, newAlbum);
|
||||
}
|
||||
},
|
||||
tooltip: 'Create album',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(AIcons.sort),
|
||||
onPressed: () => chipSetActionDelegate.onActionSelected(context, ChipSetAction.sort),
|
||||
),
|
||||
],
|
||||
floating: true,
|
||||
),
|
||||
],
|
||||
floating: true,
|
||||
),
|
||||
filterEntries: AlbumListPage.getAlbumEntries(source),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
),
|
||||
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
|
||||
filterEntries: AlbumListPage.getAlbumEntries(source),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
),
|
||||
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -148,11 +163,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
return AvesDialog(
|
||||
content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('Delete'.toUpperCase()),
|
||||
),
|
||||
|
|
|
@ -38,7 +38,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
|
|||
title: widget.title,
|
||||
scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||
enum EntryAction {
|
||||
delete,
|
||||
edit,
|
||||
flip,
|
||||
info,
|
||||
open,
|
||||
openMap,
|
||||
|
@ -29,8 +30,6 @@ class EntryActions {
|
|||
EntryAction.share,
|
||||
EntryAction.delete,
|
||||
EntryAction.rename,
|
||||
EntryAction.rotateCCW,
|
||||
EntryAction.rotateCW,
|
||||
EntryAction.print,
|
||||
];
|
||||
|
||||
|
@ -56,9 +55,11 @@ extension ExtraEntryAction on EntryAction {
|
|||
case EntryAction.rename:
|
||||
return 'Rename';
|
||||
case EntryAction.rotateCCW:
|
||||
return 'Rotate left';
|
||||
return 'Rotate counterclockwise';
|
||||
case EntryAction.rotateCW:
|
||||
return 'Rotate right';
|
||||
return 'Rotate clockwise';
|
||||
case EntryAction.flip:
|
||||
return 'Flip horizontally';
|
||||
case EntryAction.print:
|
||||
return 'Print';
|
||||
case EntryAction.share:
|
||||
|
@ -94,6 +95,8 @@ extension ExtraEntryAction on EntryAction {
|
|||
return AIcons.rotateLeft;
|
||||
case EntryAction.rotateCW:
|
||||
return AIcons.rotateRight;
|
||||
case EntryAction.flip:
|
||||
return AIcons.flip;
|
||||
case EntryAction.print:
|
||||
return AIcons.print;
|
||||
case EntryAction.share:
|
||||
|
|
|
@ -11,6 +11,7 @@ class AIcons {
|
|||
static const IconData allCollection = Icons.collections_outlined;
|
||||
static const IconData image = Icons.photo_outlined;
|
||||
static const IconData video = Icons.movie_outlined;
|
||||
static const IconData audio = Icons.audiotrack_outlined;
|
||||
static const IconData vector = Icons.code_outlined;
|
||||
|
||||
static const IconData android = Icons.android;
|
||||
|
@ -34,6 +35,7 @@ class AIcons {
|
|||
static const IconData debug = Icons.whatshot_outlined;
|
||||
static const IconData delete = Icons.delete_outlined;
|
||||
static const IconData expand = Icons.expand_more_outlined;
|
||||
static const IconData flip = Icons.flip_outlined;
|
||||
static const IconData favourite = Icons.favorite_border;
|
||||
static const IconData favouriteActive = Icons.favorite;
|
||||
static const IconData goUp = Icons.arrow_upward_outlined;
|
||||
|
|
|
@ -39,11 +39,13 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
|
|||
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
|
||||
try {
|
||||
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
|
||||
if (bytes == null) return null;
|
||||
if (bytes == null) {
|
||||
throw StateError('$packageName app icon loading failed');
|
||||
}
|
||||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error');
|
||||
return null;
|
||||
throw StateError('$packageName app icon decoding failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,93 +6,108 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||
ThumbnailProvider({
|
||||
@required this.entry,
|
||||
this.extent = 0,
|
||||
this.scale = 1,
|
||||
}) : assert(entry != null),
|
||||
assert(extent != null),
|
||||
assert(scale != null) {
|
||||
_cancellationKey = _buildKey(ImageConfiguration.empty);
|
||||
}
|
||||
final ThumbnailProviderKey key;
|
||||
|
||||
final ImageEntry entry;
|
||||
final double extent;
|
||||
final double scale;
|
||||
|
||||
Object _cancellationKey;
|
||||
ThumbnailProvider(this.key) : assert(key != null);
|
||||
|
||||
@override
|
||||
Future<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(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
scale: scale,
|
||||
);
|
||||
|
||||
@override
|
||||
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('uri=${entry.uri}, extent=$extent');
|
||||
yield ErrorDescription('uri=${key.uri}, extent=${key.extent}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
|
||||
var uri = key.uri;
|
||||
var mimeType = key.mimeType;
|
||||
try {
|
||||
final bytes = await ImageFileService.getThumbnail(key.entry, extent, extent, taskKey: _cancellationKey);
|
||||
if (bytes == null) return null;
|
||||
final bytes = await ImageFileService.getThumbnail(
|
||||
uri,
|
||||
mimeType,
|
||||
key.dateModifiedSecs,
|
||||
key.rotationDegrees,
|
||||
key.isFlipped,
|
||||
key.extent,
|
||||
key.extent,
|
||||
taskKey: key,
|
||||
);
|
||||
if (bytes == null) {
|
||||
throw StateError('$uri ($mimeType) loading failed');
|
||||
}
|
||||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with path=${entry.path}, error=$error');
|
||||
return null;
|
||||
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
|
||||
throw StateError('$mimeType decoding failed');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
|
||||
ImageFileService.resumeThumbnail(_cancellationKey);
|
||||
ImageFileService.resumeThumbnail(key);
|
||||
super.resolveStreamForKey(configuration, stream, key, handleError);
|
||||
}
|
||||
|
||||
void pause() => ImageFileService.cancelThumbnail(_cancellationKey);
|
||||
void pause() => ImageFileService.cancelThumbnail(key);
|
||||
}
|
||||
|
||||
class ThumbnailProviderKey {
|
||||
final ImageEntry entry;
|
||||
final double extent;
|
||||
final double scale;
|
||||
final String uri, mimeType;
|
||||
final int dateModifiedSecs, rotationDegrees;
|
||||
final bool isFlipped;
|
||||
final double extent, scale;
|
||||
|
||||
// do not access `contentId` via `entry` for hashCode and equality purposes
|
||||
// as an entry is not constant and its contentId can change
|
||||
final int contentId;
|
||||
const ThumbnailProviderKey({
|
||||
@required this.uri,
|
||||
@required this.mimeType,
|
||||
@required this.dateModifiedSecs,
|
||||
@required this.rotationDegrees,
|
||||
@required this.isFlipped,
|
||||
this.extent = 0,
|
||||
this.scale = 1,
|
||||
}) : assert(uri != null),
|
||||
assert(mimeType != null),
|
||||
assert(dateModifiedSecs != null),
|
||||
assert(rotationDegrees != null),
|
||||
assert(isFlipped != null),
|
||||
assert(extent != null),
|
||||
assert(scale != null);
|
||||
|
||||
ThumbnailProviderKey({
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
this.scale,
|
||||
}) : contentId = entry.contentId;
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) {
|
||||
return ThumbnailProviderKey(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
dateModifiedSecs: entry.dateModifiedSecs ?? -1, // can happen in viewer mode
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
extent: extent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ThumbnailProviderKey && other.contentId == contentId && other.extent == extent && other.scale == scale;
|
||||
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(contentId, extent, scale);
|
||||
int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ThumbnailProviderKey{contentId=$contentId, extent=$extent, scale=$scale}';
|
||||
return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
@ -55,11 +58,13 @@ class UriImage extends ImageProvider<UriImage> {
|
|||
));
|
||||
},
|
||||
);
|
||||
if (bytes == null) return null;
|
||||
if (bytes == null) {
|
||||
throw StateError('$uri ($mimeType) loading failed');
|
||||
}
|
||||
return await decode(bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
||||
return null;
|
||||
throw StateError('$mimeType decoding failed');
|
||||
} finally {
|
||||
unawaited(chunkEvents.close());
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -62,42 +62,56 @@ class AlbumListPage extends StatelessWidget {
|
|||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
|
||||
switch (settings.albumSortFactor) {
|
||||
case ChipSortFactor.date:
|
||||
final allAlbumMapEntries = source.sortedAlbums.map((album) => MapEntry(
|
||||
album,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
|
||||
));
|
||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allAlbumMapEntries, (e) => pinned.contains(e.key));
|
||||
final pinnedMapEntries = (byPin[true] ?? [])..sort(FilterNavigationPage.compareChipsByDate);
|
||||
final unpinnedMapEntries = (byPin[false] ?? [])..sort(FilterNavigationPage.compareChipsByDate);
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
case ChipSortFactor.name:
|
||||
default:
|
||||
final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||
for (var album in source.sortedAlbums) {
|
||||
if (pinned.contains(album)) {
|
||||
pinnedAlbums.add(album);
|
||||
} else {
|
||||
switch (androidFileUtils.getAlbumType(album)) {
|
||||
case AlbumType.regular:
|
||||
regularAlbums.add(album);
|
||||
break;
|
||||
case AlbumType.app:
|
||||
appAlbums.add(album);
|
||||
break;
|
||||
default:
|
||||
specialAlbums.add(album);
|
||||
break;
|
||||
}
|
||||
// albums are initially sorted by name at the source level
|
||||
var sortedAlbums = source.sortedAlbums;
|
||||
|
||||
if (settings.albumSortFactor == ChipSortFactor.name) {
|
||||
final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||
for (var album in sortedAlbums) {
|
||||
if (pinned.contains(album)) {
|
||||
pinnedAlbums.add(album);
|
||||
} else {
|
||||
switch (androidFileUtils.getAlbumType(album)) {
|
||||
case AlbumType.regular:
|
||||
regularAlbums.add(album);
|
||||
break;
|
||||
case AlbumType.app:
|
||||
appAlbums.add(album);
|
||||
break;
|
||||
default:
|
||||
specialAlbums.add(album);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) {
|
||||
return MapEntry(
|
||||
album,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
|
||||
);
|
||||
}));
|
||||
}
|
||||
return Map.fromEntries([...pinnedAlbums, ...specialAlbums, ...appAlbums, ...regularAlbums].map((album) {
|
||||
return MapEntry(
|
||||
album,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
if (settings.albumSortFactor == ChipSortFactor.count) {
|
||||
CollectionFilter _buildFilter(String album) => AlbumFilter(album, source.getUniqueAlbumName(album));
|
||||
var filtersWithCount = List.of(sortedAlbums.map((s) => MapEntry(s, source.count(_buildFilter(s)))));
|
||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
||||
sortedAlbums = filtersWithCount.map((kv) => kv.key).toList();
|
||||
}
|
||||
|
||||
final allMapEntries = sortedAlbums.map((album) => MapEntry(
|
||||
album,
|
||||
entriesByDate.firstWhere((entry) => entry.directory == album, orElse: () => null),
|
||||
));
|
||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||
|
||||
if (settings.albumSortFactor == ChipSortFactor.date) {
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
}
|
||||
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,11 +65,11 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
|||
return AvesDialog(
|
||||
content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'),
|
||||
actions: [
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('Delete'.toUpperCase()),
|
||||
),
|
||||
|
|
|
@ -46,6 +46,7 @@ abstract class ChipSetActionDelegate {
|
|||
options: {
|
||||
ChipSortFactor.date: 'By date',
|
||||
ChipSortFactor.name: 'By name',
|
||||
ChipSortFactor.count: 'By entry count',
|
||||
},
|
||||
title: 'Sort',
|
||||
),
|
||||
|
|
|
@ -153,6 +153,11 @@ class FilterNavigationPage extends StatelessWidget {
|
|||
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
|
||||
}
|
||||
|
||||
static int compareChipsByEntryCount(MapEntry<String, num> a, MapEntry<String, num> b) {
|
||||
final c = b.value.compareTo(a.value) ?? -1;
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterGridPage extends StatelessWidget {
|
||||
|
|
|
@ -39,7 +39,7 @@ class CountryListPage extends StatelessWidget {
|
|||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
],
|
||||
filterEntries: _getCountryEntries(),
|
||||
filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
|
||||
filterBuilder: _buildFilter,
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.location,
|
||||
text: 'No countries',
|
||||
|
@ -50,12 +50,22 @@ class CountryListPage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
CollectionFilter _buildFilter(String location) => LocationFilter(LocationLevel.country, location);
|
||||
|
||||
Map<String, ImageEntry> _getCountryEntries() {
|
||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>().map((f) => f.countryNameAndCode);
|
||||
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
// countries are initially sorted by name at the source level
|
||||
var sortedCountries = source.sortedCountries;
|
||||
if (settings.countrySortFactor == ChipSortFactor.count) {
|
||||
var filtersWithCount = List.of(sortedCountries.map((s) => MapEntry(s, source.count(_buildFilter(s)))));
|
||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
||||
sortedCountries = filtersWithCount.map((kv) => kv.key).toList();
|
||||
}
|
||||
|
||||
final locatedEntries = entriesByDate.where((entry) => entry.isLocated);
|
||||
final allMapEntries = source.sortedCountries.map((countryNameAndCode) {
|
||||
final allMapEntries = sortedCountries.map((countryNameAndCode) {
|
||||
final split = countryNameAndCode.split(LocationFilter.locationSeparator);
|
||||
ImageEntry entry;
|
||||
if (split.length > 1) {
|
||||
|
@ -63,21 +73,16 @@ class CountryListPage extends StatelessWidget {
|
|||
entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null);
|
||||
}
|
||||
return MapEntry(countryNameAndCode, entry);
|
||||
}).toList();
|
||||
|
||||
});
|
||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||
|
||||
switch (settings.countrySortFactor) {
|
||||
case ChipSortFactor.date:
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
break;
|
||||
case ChipSortFactor.name:
|
||||
// already sorted by name at the source level
|
||||
break;
|
||||
if (settings.countrySortFactor == ChipSortFactor.date) {
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
}
|
||||
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ class TagListPage extends StatelessWidget {
|
|||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||
],
|
||||
filterEntries: _getTagEntries(),
|
||||
filterBuilder: (s) => TagFilter(s),
|
||||
filterBuilder: _buildFilter,
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.tag,
|
||||
text: 'No tags',
|
||||
|
@ -50,30 +50,33 @@ class TagListPage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
CollectionFilter _buildFilter(String tag) => TagFilter(tag);
|
||||
|
||||
Map<String, ImageEntry> _getTagEntries() {
|
||||
final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag);
|
||||
|
||||
final entriesByDate = source.sortedEntriesForFilterList;
|
||||
final allMapEntries = source.sortedTags
|
||||
.map((tag) => MapEntry(
|
||||
tag,
|
||||
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
|
||||
))
|
||||
.toList();
|
||||
// tags are initially sorted by name at the source level
|
||||
var sortedTags = source.sortedTags;
|
||||
if (settings.tagSortFactor == ChipSortFactor.count) {
|
||||
var filtersWithCount = List.of(sortedTags.map((s) => MapEntry(s, source.count(_buildFilter(s)))));
|
||||
filtersWithCount.sort(FilterNavigationPage.compareChipsByEntryCount);
|
||||
sortedTags = filtersWithCount.map((kv) => kv.key).toList();
|
||||
}
|
||||
|
||||
final allMapEntries = sortedTags.map((tag) => MapEntry(
|
||||
tag,
|
||||
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
|
||||
));
|
||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||
final pinnedMapEntries = (byPin[true] ?? []);
|
||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||
|
||||
switch (settings.tagSortFactor) {
|
||||
case ChipSortFactor.date:
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
break;
|
||||
case ChipSortFactor.name:
|
||||
// already sorted by name at the source level
|
||||
break;
|
||||
if (settings.tagSortFactor == ChipSortFactor.date) {
|
||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||
}
|
||||
|
||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,329 +0,0 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class FullscreenDebugPage extends StatefulWidget {
|
||||
static const routeName = '/fullscreen/debug';
|
||||
|
||||
final ImageEntry entry;
|
||||
|
||||
const FullscreenDebugPage({@required this.entry});
|
||||
|
||||
@override
|
||||
_FullscreenDebugPageState createState() => _FullscreenDebugPageState();
|
||||
}
|
||||
|
||||
class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
||||
Future<DateMetadata> _dbDateLoader;
|
||||
Future<CatalogMetadata> _dbMetadataLoader;
|
||||
Future<AddressDetails> _dbAddressLoader;
|
||||
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
int get contentId => entry.contentId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDatabase();
|
||||
_loadMetadata();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tabs = <Tuple2<Tab, Widget>>[
|
||||
Tuple2(Tab(text: 'Entry'), _buildEntryTabView()),
|
||||
Tuple2(Tab(text: 'DB'), _buildDbTabView()),
|
||||
Tuple2(Tab(icon: Icon(AIcons.android)), _buildContentResolverTabView()),
|
||||
Tuple2(Tab(icon: Icon(AIcons.image)), _buildThumbnailsTabView()),
|
||||
];
|
||||
return DefaultTabController(
|
||||
length: tabs.length,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Debug'),
|
||||
bottom: TabBar(
|
||||
tabs: tabs.map((t) => t.item1).toList(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: TabBarView(
|
||||
children: tabs.map((t) => t.item2).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntryTabView() {
|
||||
String toDateValue(int time, {int factor = 1}) {
|
||||
var value = '$time';
|
||||
if (time != null && time > 0) {
|
||||
value += ' (${DateTime.fromMillisecondsSinceEpoch(time * factor)})';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
InfoRowGroup({
|
||||
'uri': '${entry.uri}',
|
||||
'contentId': '${entry.contentId}',
|
||||
'path': '${entry.path}',
|
||||
'directory': '${entry.directory}',
|
||||
'filenameWithoutExtension': '${entry.filenameWithoutExtension}',
|
||||
'sourceTitle': '${entry.sourceTitle}',
|
||||
'sourceMimeType': '${entry.sourceMimeType}',
|
||||
'mimeType': '${entry.mimeType}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000),
|
||||
'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis),
|
||||
'bestDate': '${entry.bestDate}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'width': '${entry.width}',
|
||||
'height': '${entry.height}',
|
||||
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
|
||||
'rotationDegrees': '${entry.rotationDegrees}',
|
||||
'portrait': '${entry.portrait}',
|
||||
'displayAspectRatio': '${entry.displayAspectRatio}',
|
||||
'displaySize': '${entry.displaySize}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'durationMillis': '${entry.durationMillis}',
|
||||
'durationText': '${entry.durationText}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'sizeBytes': '${entry.sizeBytes}',
|
||||
'isFavourite': '${entry.isFavourite}',
|
||||
'isSvg': '${entry.isSvg}',
|
||||
'isPhoto': '${entry.isPhoto}',
|
||||
'isVideo': '${entry.isVideo}',
|
||||
'isCatalogued': '${entry.isCatalogued}',
|
||||
'isAnimated': '${entry.isAnimated}',
|
||||
'isFlipped': '${entry.isFlipped}',
|
||||
'canEdit': '${entry.canEdit}',
|
||||
'canEditExif': '${entry.canEditExif}',
|
||||
'canPrint': '${entry.canPrint}',
|
||||
'canRotate': '${entry.canRotate}',
|
||||
'xmpSubjects': '${entry.xmpSubjects}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'hasGps': '${entry.hasGps}',
|
||||
'isLocated': '${entry.isLocated}',
|
||||
'latLng': '${entry.latLng}',
|
||||
'geoUri': '${entry.geoUri}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThumbnailsTabView() {
|
||||
const extent = 128.0;
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
if (entry.isSvg) ...[
|
||||
Text('SVG ($extent)'),
|
||||
SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
),
|
||||
width: extent,
|
||||
height: extent,
|
||||
)
|
||||
],
|
||||
if (!entry.isSvg) ...[
|
||||
Text('Raster (fast)'),
|
||||
Center(child: Image(image: ThumbnailProvider(entry: entry))),
|
||||
SizedBox(height: 16),
|
||||
Text('Raster ($extent)'),
|
||||
Center(child: Image(image: ThumbnailProvider(entry: entry, extent: extent))),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDbTabView() {
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('DB'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () async {
|
||||
await metadataDb.removeIds([entry.contentId]);
|
||||
_loadDatabase();
|
||||
},
|
||||
child: Text('Remove from DB'),
|
||||
),
|
||||
],
|
||||
),
|
||||
FutureBuilder<DateMetadata>(
|
||||
future: _dbDateLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB date:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
FutureBuilder<CatalogMetadata>(
|
||||
future: _dbMetadataLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB metadata:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'mimeType': '${data.mimeType}',
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
'isAnimated': '${data.isAnimated}',
|
||||
'isFlipped': '${data.isFlipped}',
|
||||
'rotationDegrees': '${data.rotationDegrees}',
|
||||
'latitude': '${data.latitude}',
|
||||
'longitude': '${data.longitude}',
|
||||
'xmpSubjects': '${data.xmpSubjects}',
|
||||
'xmpTitleDescription': '${data.xmpTitleDescription}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
FutureBuilder<AddressDetails>(
|
||||
future: _dbAddressLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB address:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'addressLine': '${data.addressLine}',
|
||||
'countryCode': '${data.countryCode}',
|
||||
'countryName': '${data.countryName}',
|
||||
'adminArea': '${data.adminArea}',
|
||||
'locality': '${data.locality}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// MediaStore timestamp keys
|
||||
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
||||
static const millisecondTimestampKeys = ['datetaken', 'datetime'];
|
||||
|
||||
Widget _buildContentResolverTabView() {
|
||||
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
|
||||
final key = k.toString();
|
||||
var value = v?.toString() ?? 'null';
|
||||
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
|
||||
if (secondTimestampKeys.contains(key)) {
|
||||
v *= 1000;
|
||||
}
|
||||
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
|
||||
}
|
||||
if (key == 'xmp' && v != null && v is Uint8List) {
|
||||
value = String.fromCharCodes(v);
|
||||
}
|
||||
return MapEntry(key, value);
|
||||
}));
|
||||
return AvesExpansionTile(
|
||||
title: title,
|
||||
children: [
|
||||
Container(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(
|
||||
data,
|
||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(8),
|
||||
children: [
|
||||
FutureBuilder<Map>(
|
||||
future: _contentResolverMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _exifInterfaceMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _mediaMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _loadDatabase() {
|
||||
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _loadMetadata() {
|
||||
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
|
||||
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
|
||||
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
159
lib/widgets/fullscreen/debug/db.dart
Normal file
|
@ -0,0 +1,159 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DbTab extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
|
||||
const DbTab({@required this.entry});
|
||||
|
||||
@override
|
||||
_DbTabState createState() => _DbTabState();
|
||||
}
|
||||
|
||||
class _DbTabState extends State<DbTab> {
|
||||
Future<DateMetadata> _dbDateLoader;
|
||||
Future<ImageEntry> _dbEntryLoader;
|
||||
Future<CatalogMetadata> _dbMetadataLoader;
|
||||
Future<AddressDetails> _dbAddressLoader;
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDatabase();
|
||||
}
|
||||
|
||||
void _loadDatabase() {
|
||||
final contentId = entry.contentId;
|
||||
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
_dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('DB'),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await metadataDb.removeIds([entry.contentId]);
|
||||
_loadDatabase();
|
||||
},
|
||||
child: Text('Remove from DB'),
|
||||
),
|
||||
],
|
||||
),
|
||||
FutureBuilder<DateMetadata>(
|
||||
future: _dbDateLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB date:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
FutureBuilder<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) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB metadata:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'mimeType': '${data.mimeType}',
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
'isAnimated': '${data.isAnimated}',
|
||||
'isFlipped': '${data.isFlipped}',
|
||||
'rotationDegrees': '${data.rotationDegrees}',
|
||||
'latitude': '${data.latitude}',
|
||||
'longitude': '${data.longitude}',
|
||||
'xmpSubjects': '${data.xmpSubjects}',
|
||||
'xmpTitleDescription': '${data.xmpTitleDescription}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
FutureBuilder<AddressDetails>(
|
||||
future: _dbAddressLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('DB address:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'addressLine': '${data.addressLine}',
|
||||
'countryCode': '${data.countryCode}',
|
||||
'countryName': '${data.countryName}',
|
||||
'adminArea': '${data.adminArea}',
|
||||
'locality': '${data.locality}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
94
lib/widgets/fullscreen/debug/metadata.dart
Normal file
|
@ -0,0 +1,94 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MetadataTab extends StatefulWidget {
|
||||
final ImageEntry entry;
|
||||
|
||||
const MetadataTab({@required this.entry});
|
||||
|
||||
@override
|
||||
_MetadataTabState createState() => _MetadataTabState();
|
||||
}
|
||||
|
||||
class _MetadataTabState extends State<MetadataTab> {
|
||||
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
|
||||
|
||||
// MediaStore timestamp keys
|
||||
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
||||
static const millisecondTimestampKeys = ['datetaken', 'datetime'];
|
||||
|
||||
ImageEntry get entry => widget.entry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMetadata();
|
||||
}
|
||||
|
||||
void _loadMetadata() {
|
||||
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
|
||||
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
|
||||
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
|
||||
final key = k.toString();
|
||||
var value = v?.toString() ?? 'null';
|
||||
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
|
||||
if (secondTimestampKeys.contains(key)) {
|
||||
v *= 1000;
|
||||
}
|
||||
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
|
||||
}
|
||||
if (key == 'xmp' && v != null && v is Uint8List) {
|
||||
value = String.fromCharCodes(v);
|
||||
}
|
||||
return MapEntry(key, value);
|
||||
}));
|
||||
return AvesExpansionTile(
|
||||
title: title,
|
||||
children: [
|
||||
Container(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(
|
||||
data,
|
||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(8),
|
||||
children: [
|
||||
FutureBuilder<Map>(
|
||||
future: _contentResolverMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _exifInterfaceMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _mediaMetadataLoader,
|
||||
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
|
@ -10,8 +9,6 @@ import 'package:aves/utils/change_notifier.dart';
|
|||
import 'package:aves/utils/durations.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/action_delegates/entry_action_delegate.dart';
|
||||
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
||||
import 'package:aves/widgets/fullscreen/image_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
|
||||
|
@ -541,19 +538,6 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
|
|||
|
||||
// when the entry image itself changed (e.g. after rotation)
|
||||
void _onImageChanged() async {
|
||||
await UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
).evict();
|
||||
// evict low quality thumbnail (without specified extents)
|
||||
await ThumbnailProvider(entry: entry).evict();
|
||||
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
||||
final extents = List.generate(6, (index) => pow(2, index + 5).toDouble());
|
||||
await Future.forEach<double>(extents, (extent) => ThumbnailProvider(entry: entry, extent: extent).evict());
|
||||
|
||||
await ThumbnailProvider(entry: entry).evict();
|
||||
if (entry.path != null) await FileImage(File(entry.path)).evict();
|
||||
// rebuild to refresh the Image inside ImagePage
|
||||
setState(() {});
|
||||
}
|
||||
|
|
154
lib/widgets/fullscreen/fullscreen_debug_page.dart
Normal file
|
@ -0,0 +1,154 @@
|
|||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
|
||||
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
|
||||
import 'package:aves/widgets/fullscreen/debug/db.dart';
|
||||
import 'package:aves/widgets/fullscreen/debug/metadata.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class FullscreenDebugPage extends StatelessWidget {
|
||||
static const routeName = '/fullscreen/debug';
|
||||
|
||||
final ImageEntry entry;
|
||||
|
||||
const FullscreenDebugPage({@required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tabs = <Tuple2<Tab, Widget>>[
|
||||
Tuple2(Tab(text: 'Entry'), _buildEntryTabView()),
|
||||
if (AvesApp.mode != AppMode.view) Tuple2(Tab(text: 'DB'), DbTab(entry: entry)),
|
||||
Tuple2(Tab(icon: Icon(AIcons.android)), MetadataTab(entry: entry)),
|
||||
Tuple2(Tab(icon: Icon(AIcons.image)), _buildThumbnailsTabView()),
|
||||
];
|
||||
return DefaultTabController(
|
||||
length: tabs.length,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Debug'),
|
||||
bottom: TabBar(
|
||||
tabs: tabs.map((t) => t.item1).toList(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: TabBarView(
|
||||
children: tabs.map((t) => t.item2).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntryTabView() {
|
||||
String toDateValue(int time, {int factor = 1}) {
|
||||
var value = '$time';
|
||||
if (time != null && time > 0) {
|
||||
value += ' (${DateTime.fromMillisecondsSinceEpoch(time * factor)})';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
InfoRowGroup({
|
||||
'uri': '${entry.uri}',
|
||||
'contentId': '${entry.contentId}',
|
||||
'path': '${entry.path}',
|
||||
'directory': '${entry.directory}',
|
||||
'filenameWithoutExtension': '${entry.filenameWithoutExtension}',
|
||||
'sourceTitle': '${entry.sourceTitle}',
|
||||
'sourceMimeType': '${entry.sourceMimeType}',
|
||||
'mimeType': '${entry.mimeType}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000),
|
||||
'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis),
|
||||
'bestDate': '${entry.bestDate}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'width': '${entry.width}',
|
||||
'height': '${entry.height}',
|
||||
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
|
||||
'rotationDegrees': '${entry.rotationDegrees}',
|
||||
'isFlipped': '${entry.isFlipped}',
|
||||
'portrait': '${entry.portrait}',
|
||||
'displayAspectRatio': '${entry.displayAspectRatio}',
|
||||
'displaySize': '${entry.displaySize}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'durationMillis': '${entry.durationMillis}',
|
||||
'durationText': '${entry.durationText}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'sizeBytes': '${entry.sizeBytes}',
|
||||
'isFavourite': '${entry.isFavourite}',
|
||||
'isSvg': '${entry.isSvg}',
|
||||
'isPhoto': '${entry.isPhoto}',
|
||||
'isVideo': '${entry.isVideo}',
|
||||
'isCatalogued': '${entry.isCatalogued}',
|
||||
'isAnimated': '${entry.isAnimated}',
|
||||
'canEdit': '${entry.canEdit}',
|
||||
'canEditExif': '${entry.canEditExif}',
|
||||
'canPrint': '${entry.canPrint}',
|
||||
'canRotateAndFlip': '${entry.canRotateAndFlip}',
|
||||
'xmpSubjects': '${entry.xmpSubjects}',
|
||||
}),
|
||||
Divider(),
|
||||
InfoRowGroup({
|
||||
'hasGps': '${entry.hasGps}',
|
||||
'isLocated': '${entry.isLocated}',
|
||||
'latLng': '${entry.latLng}',
|
||||
'geoUri': '${entry.geoUri}',
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThumbnailsTabView() {
|
||||
const extent = 128.0;
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
if (entry.isSvg) ...[
|
||||
Text('SVG ($extent)'),
|
||||
SvgPicture(
|
||||
UriPicture(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
),
|
||||
width: extent,
|
||||
height: extent,
|
||||
)
|
||||
],
|
||||
if (!entry.isSvg) ...[
|
||||
Text('Raster (fast)'),
|
||||
Center(
|
||||
child: Image(
|
||||
image: ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text('Raster ($extent)'),
|
||||
Center(
|
||||
child: Image(
|
||||
image: ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry, extent: extent),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -57,7 +57,7 @@ class ImageView extends StatelessWidget {
|
|||
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
|
||||
// there's a black frame between the hero animation and the final image, even when it's cached.
|
||||
|
||||
final fastThumbnailProvider = ThumbnailProvider(entry: entry);
|
||||
final fastThumbnailProvider = ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry));
|
||||
// this loading builder shows a transition image until the final image is ready
|
||||
// if the image is already in the cache it will show the final image, otherwise the thumbnail
|
||||
// in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image
|
||||
|
@ -98,11 +98,12 @@ class ImageView extends StatelessWidget {
|
|||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
);
|
||||
child = PhotoView(
|
||||
// key includes size and orientation to refresh when the image is rotated
|
||||
key: ValueKey('${entry.rotationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
|
||||
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||
imageProvider: uriImage,
|
||||
// when the full image is ready, we use it in the `loadingBuilder`
|
||||
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
|
||||
|
|
|
@ -33,10 +33,10 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
|
||||
bool get isVisible => widget.visibleNotifier.value;
|
||||
|
||||
// directory names from metadata-extractor
|
||||
// special directory names
|
||||
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
||||
static const xmpDirectory = 'XMP'; // from metadata-extractor
|
||||
static const videoDirectory = 'Video'; // additional generic video directory
|
||||
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -87,13 +87,43 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength);
|
||||
}
|
||||
final dir = directoriesWithTitle[index - 1 - untitledDirectoryCount];
|
||||
Widget thumbnail;
|
||||
final prefixChildren = <Widget>[];
|
||||
switch (dir.name) {
|
||||
case exifThumbnailDirectory:
|
||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
|
||||
break;
|
||||
case xmpDirectory:
|
||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
|
||||
break;
|
||||
case mediaDirectory:
|
||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
|
||||
Widget builder(IconData data) => Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
child: Icon(data),
|
||||
);
|
||||
if (dir.tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video));
|
||||
if (dir.tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio));
|
||||
if (dir.tags['Has Image'] == 'yes') {
|
||||
int count;
|
||||
if (dir.tags.containsKey('Image Count')) {
|
||||
count = int.tryParse(dir.tags['Image Count']);
|
||||
}
|
||||
prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return AvesExpansionTile(
|
||||
title: dir.name,
|
||||
expandedNotifier: _expandedDirectoryNotifier,
|
||||
children: [
|
||||
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry),
|
||||
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry),
|
||||
if (dir.name == videoDirectory) MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry),
|
||||
if (prefixChildren.isNotEmpty)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: Wrap(children: prefixChildren),
|
||||
),
|
||||
if (thumbnail != null) thumbnail,
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
|
|
|
@ -50,19 +50,15 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
|
|||
future: _loader,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
|
||||
final turns = (entry.rotationDegrees / 90).round();
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
return Container(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4),
|
||||
child: Wrap(
|
||||
children: snapshot.data.map((bytes) {
|
||||
return RotatedBox(
|
||||
quarterTurns: turns,
|
||||
child: Image.memory(
|
||||
bytes,
|
||||
scale: devicePixelRatio,
|
||||
),
|
||||
return Image.memory(
|
||||
bytes,
|
||||
scale: devicePixelRatio,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
|
|
@ -82,7 +82,8 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
return entry.canEdit;
|
||||
case EntryAction.rotateCCW:
|
||||
case EntryAction.rotateCW:
|
||||
return entry.canRotate;
|
||||
case EntryAction.flip:
|
||||
return entry.canRotateAndFlip;
|
||||
case EntryAction.print:
|
||||
return entry.canPrint;
|
||||
case EntryAction.openMap:
|
||||
|
@ -136,6 +137,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
key: Key('entry-menu-button'),
|
||||
itemBuilder: (context) => [
|
||||
...inAppActions.map(_buildPopupMenuItem),
|
||||
if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(),
|
||||
PopupMenuDivider(),
|
||||
...externalAppActions.map(_buildPopupMenuItem),
|
||||
if (kDebugMode) ...[
|
||||
|
@ -166,6 +168,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
case EntryAction.rename:
|
||||
case EntryAction.rotateCCW:
|
||||
case EntryAction.rotateCW:
|
||||
case EntryAction.flip:
|
||||
case EntryAction.print:
|
||||
child = IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
|
@ -207,6 +210,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
case EntryAction.rename:
|
||||
case EntryAction.rotateCCW:
|
||||
case EntryAction.rotateCW:
|
||||
case EntryAction.flip:
|
||||
case EntryAction.print:
|
||||
case EntryAction.debug:
|
||||
child = MenuRow(text: action.getText(), icon: action.getIcon());
|
||||
|
@ -224,6 +228,40 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<EntryAction> _buildRotateAndFlipMenuItems() {
|
||||
Widget buildDivider() => SizedBox(
|
||||
height: 16,
|
||||
child: VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
|
||||
Widget buildItem(EntryAction action) => Expanded(
|
||||
child: PopupMenuItem(
|
||||
value: action,
|
||||
child: Tooltip(
|
||||
message: action.getText(),
|
||||
child: Center(child: Icon(action.getIcon())),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
buildDivider(),
|
||||
buildItem(EntryAction.rotateCCW),
|
||||
buildDivider(),
|
||||
buildItem(EntryAction.rotateCW),
|
||||
buildDivider(),
|
||||
buildItem(EntryAction.flip),
|
||||
buildDivider(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavouriteToggler extends StatefulWidget {
|
||||
|
|
|
@ -80,7 +80,7 @@ class AvesVideoState extends State<AvesVideo> {
|
|||
color: Colors.black,
|
||||
);
|
||||
|
||||
final degree = entry.catalogMetadata?.rotationDegrees ?? 0;
|
||||
final degree = entry.rotationDegrees ?? 0;
|
||||
if (degree != 0) {
|
||||
child = RotatedBox(
|
||||
quarterTurns: degree ~/ 90,
|
||||
|
@ -102,6 +102,7 @@ class AvesVideoState extends State<AvesVideo> {
|
|||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
expectedContentLength: entry.sizeBytes,
|
||||
),
|
||||
width: entry.width.toDouble(),
|
||||
|
|
|
@ -46,13 +46,13 @@ class _GrantedDirectoriesState extends State<GrantedDirectories> {
|
|||
children: [
|
||||
Expanded(child: Text(path, style: textTheme.caption)),
|
||||
SizedBox(width: 8),
|
||||
OutlineButton(
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
await AndroidFileService.revokeDirectoryAccess(path);
|
||||
_load();
|
||||
setState(() {});
|
||||
},
|
||||
child: Text('Revoke'),
|
||||
child: Text('Revoke'.toUpperCase()),
|
||||
),
|
||||
],
|
||||
)),
|
||||
|
|
|
@ -64,6 +64,7 @@ class StatsPage extends StatelessWidget {
|
|||
final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/')));
|
||||
final mimeDonuts = Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes),
|
||||
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'video', other: 'videos'), videoByMimeTypes),
|
||||
|
|
|
@ -110,7 +110,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
],
|
||||
);
|
||||
|
||||
final button = RaisedButton(
|
||||
final button = ElevatedButton(
|
||||
key: Key('continue-button'),
|
||||
child: Text('Continue'),
|
||||
onPressed: _hasAcceptedTerms
|
||||
|
|
|
@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 1.2.1+27
|
||||
version: 1.2.2+28
|
||||
|
||||
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
||||
# - does not support content URIs (by default, but trivial by fork)
|
||||
|
|