Merge branch 'develop'
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
|
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
|
## Features
|
||||||
|
|
||||||
|
|
|
@ -109,10 +109,10 @@ dependencies {
|
||||||
// enable support for Java 8 language APIs (stream, optional, etc.)
|
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||||
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
|
// 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 "androidx.exifinterface:exifinterface:1.3.0"
|
||||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
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.github.bumptech.glide:glide:4.11.0'
|
||||||
implementation 'com.google.guava:guava:29.0-android'
|
implementation 'com.google.guava:guava:29.0-android'
|
||||||
|
|
||||||
|
|
|
@ -18,10 +18,9 @@ import androidx.annotation.Nullable;
|
||||||
import androidx.core.content.FileProvider;
|
import androidx.core.content.FileProvider;
|
||||||
|
|
||||||
import com.bumptech.glide.Glide;
|
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.FutureTarget;
|
||||||
import com.bumptech.glide.request.RequestOptions;
|
import com.bumptech.glide.request.RequestOptions;
|
||||||
import com.bumptech.glide.signature.ObjectKey;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
@ -34,12 +33,12 @@ import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import deckers.thibault.aves.utils.Utils;
|
import deckers.thibault.aves.utils.LogUtils;
|
||||||
import io.flutter.plugin.common.MethodCall;
|
import io.flutter.plugin.common.MethodCall;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
|
||||||
public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
|
public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
|
||||||
private static final String LOG_TAG = Utils.createLogTag(AppAdapterHandler.class);
|
private static final String LOG_TAG = LogUtils.createTag(AppAdapterHandler.class);
|
||||||
|
|
||||||
public static final String CHANNEL = "deckers.thibault/aves/app";
|
public static final String CHANNEL = "deckers.thibault/aves/app";
|
||||||
|
|
||||||
|
@ -173,25 +172,21 @@ public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
|
||||||
.path(String.valueOf(iconResourceId))
|
.path(String.valueOf(iconResourceId))
|
||||||
.build();
|
.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()
|
RequestOptions options = new RequestOptions()
|
||||||
.signature(signature)
|
.format(DecodeFormat.PREFER_RGB_565)
|
||||||
|
.centerCrop()
|
||||||
.override(size, size);
|
.override(size, size);
|
||||||
|
|
||||||
FutureTarget<Bitmap> target = Glide.with(context)
|
FutureTarget<Bitmap> target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(options)
|
.apply(options)
|
||||||
.centerCrop()
|
|
||||||
.load(uri)
|
.load(uri)
|
||||||
.signature(signature)
|
|
||||||
.submit(size, size);
|
.submit(size, size);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Bitmap bmp = target.get();
|
Bitmap bitmap = target.get();
|
||||||
if (bmp != null) {
|
if (bitmap != null) {
|
||||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, stream);
|
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream);
|
||||||
data = stream.toByteArray();
|
data = stream.toByteArray();
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
|
@ -12,13 +12,11 @@ import androidx.core.content.pm.ShortcutInfoCompat;
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat;
|
import androidx.core.content.pm.ShortcutManagerCompat;
|
||||||
import androidx.core.graphics.drawable.IconCompat;
|
import androidx.core.graphics.drawable.IconCompat;
|
||||||
|
|
||||||
import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool;
|
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import deckers.thibault.aves.MainActivity;
|
import deckers.thibault.aves.MainActivity;
|
||||||
import deckers.thibault.aves.R;
|
import deckers.thibault.aves.R;
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils;
|
||||||
import io.flutter.plugin.common.MethodCall;
|
import io.flutter.plugin.common.MethodCall;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
|
||||||
|
@ -57,12 +55,15 @@ public class AppShortcutHandler implements MethodChannel.MethodCallHandler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
IconCompat icon;
|
IconCompat icon = null;
|
||||||
if (iconBytes != null && iconBytes.length > 0) {
|
if (iconBytes != null && iconBytes.length > 0) {
|
||||||
Bitmap bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length);
|
Bitmap bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length);
|
||||||
bitmap = TransformationUtils.centerCrop(new LruBitmapPool(2 << 24), bitmap, 256, 256);
|
bitmap = BitmapUtils.centerSquareCrop(context, bitmap, 256);
|
||||||
|
if (bitmap != null) {
|
||||||
icon = IconCompat.createWithBitmap(bitmap);
|
icon = IconCompat.createWithBitmap(bitmap);
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
if (icon == null) {
|
||||||
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection);
|
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,19 +5,20 @@ import android.app.Activity;
|
||||||
import android.content.ContentResolver;
|
import android.content.ContentResolver;
|
||||||
import android.content.ContentUris;
|
import android.content.ContentUris;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.provider.MediaStore;
|
import android.provider.MediaStore;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.util.Size;
|
import android.util.Size;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
||||||
import com.bumptech.glide.Glide;
|
import com.bumptech.glide.Glide;
|
||||||
import com.bumptech.glide.load.Key;
|
import com.bumptech.glide.load.DecodeFormat;
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
|
|
||||||
import com.bumptech.glide.request.FutureTarget;
|
import com.bumptech.glide.request.FutureTarget;
|
||||||
import com.bumptech.glide.request.RequestOptions;
|
import com.bumptech.glide.request.RequestOptions;
|
||||||
import com.bumptech.glide.signature.ObjectKey;
|
import com.bumptech.glide.signature.ObjectKey;
|
||||||
|
@ -27,21 +28,28 @@ import java.io.IOException;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail;
|
import deckers.thibault.aves.decoder.VideoThumbnail;
|
||||||
import deckers.thibault.aves.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.MimeTypes;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
|
||||||
public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, ImageDecodeTask.Result> {
|
public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, ImageDecodeTask.Result> {
|
||||||
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class);
|
private static final String LOG_TAG = LogUtils.createTag(ImageDecodeTask.class);
|
||||||
|
|
||||||
static class Params {
|
static class Params {
|
||||||
AvesImageEntry entry;
|
Uri uri;
|
||||||
Integer width, height, defaultSize;
|
String mimeType;
|
||||||
|
Long dateModifiedSecs;
|
||||||
|
Integer rotationDegrees, width, height, defaultSize;
|
||||||
|
Boolean isFlipped;
|
||||||
MethodChannel.Result result;
|
MethodChannel.Result result;
|
||||||
|
|
||||||
Params(AvesImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
|
Params(@NonNull String uri, @NonNull String mimeType, @NonNull Long dateModifiedSecs, @NonNull Integer rotationDegrees, @NonNull Boolean isFlipped, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
|
||||||
this.entry = entry;
|
this.uri = Uri.parse(uri);
|
||||||
|
this.mimeType = mimeType;
|
||||||
|
this.dateModifiedSecs = dateModifiedSecs;
|
||||||
|
this.rotationDegrees = rotationDegrees;
|
||||||
|
this.isFlipped = isFlipped;
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.result = result;
|
this.result = result;
|
||||||
|
@ -80,6 +88,10 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
||||||
if (w == null || h == null || w == 0 || h == 0) {
|
if (w == null || h == null || w == 0 || h == 0) {
|
||||||
p.width = p.defaultSize;
|
p.width = p.defaultSize;
|
||||||
p.height = p.defaultSize;
|
p.height = p.defaultSize;
|
||||||
|
// 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 {
|
try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
bitmap = getThumbnailBytesByResolver(p);
|
bitmap = getThumbnailBytesByResolver(p);
|
||||||
|
@ -90,6 +102,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
||||||
exception = e;
|
exception = e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fallback if the native methods failed or for higher quality thumbnails
|
// fallback if the native methods failed or for higher quality thumbnails
|
||||||
try {
|
try {
|
||||||
|
@ -100,7 +113,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
||||||
exception = e;
|
exception = e;
|
||||||
}
|
}
|
||||||
} else {
|
} 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;
|
byte[] data = null;
|
||||||
|
@ -124,70 +137,66 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||||
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
|
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
|
||||||
AvesImageEntry entry = params.entry;
|
|
||||||
Integer width = params.width;
|
|
||||||
Integer height = params.height;
|
|
||||||
|
|
||||||
ContentResolver resolver = activity.getContentResolver();
|
ContentResolver resolver = activity.getContentResolver();
|
||||||
Bitmap bitmap = resolver.loadThumbnail(entry.uri, new Size(width, height), null);
|
Bitmap bitmap = resolver.loadThumbnail(params.uri, new Size(params.width, params.height), null);
|
||||||
String mimeType = entry.mimeType;
|
String mimeType = params.mimeType;
|
||||||
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
|
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
|
||||||
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
|
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
|
||||||
}
|
}
|
||||||
return bitmap;
|
return bitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Bitmap getThumbnailBytesByMediaStore(Params params) {
|
private Bitmap getThumbnailBytesByMediaStore(Params params) {
|
||||||
AvesImageEntry entry = params.entry;
|
long contentId = ContentUris.parseId(params.uri);
|
||||||
long contentId = ContentUris.parseId(entry.uri);
|
|
||||||
|
|
||||||
ContentResolver resolver = activity.getContentResolver();
|
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);
|
return MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null);
|
||||||
} else {
|
} else {
|
||||||
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null);
|
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null);
|
||||||
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
|
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
|
||||||
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
|
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
|
||||||
}
|
}
|
||||||
return bitmap;
|
return bitmap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
|
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 width = params.width;
|
||||||
int height = params.height;
|
int height = params.height;
|
||||||
|
|
||||||
// add signature to ignore cache for images which got modified but kept the same URI
|
|
||||||
Key signature = new ObjectKey("" + entry.dateModifiedSecs + entry.width + entry.rotationDegrees);
|
|
||||||
RequestOptions options = new RequestOptions()
|
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);
|
.override(width, height);
|
||||||
|
|
||||||
FutureTarget<Bitmap> target;
|
FutureTarget<Bitmap> target;
|
||||||
if (entry.isVideo()) {
|
if (MimeTypes.isVideo(mimeType)) {
|
||||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
|
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
|
||||||
target = Glide.with(activity)
|
target = Glide.with(activity)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(options)
|
.apply(options)
|
||||||
.load(new VideoThumbnail(activity, entry.uri))
|
.load(new VideoThumbnail(activity, uri))
|
||||||
.signature(signature)
|
|
||||||
.submit(width, height);
|
.submit(width, height);
|
||||||
} else {
|
} else {
|
||||||
target = Glide.with(activity)
|
target = Glide.with(activity)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(options)
|
.apply(options)
|
||||||
.load(entry.uri)
|
.load(uri)
|
||||||
.signature(signature)
|
|
||||||
.submit(width, height);
|
.submit(width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Bitmap bitmap = target.get();
|
Bitmap bitmap = target.get();
|
||||||
String mimeType = entry.mimeType;
|
|
||||||
if (MimeTypes.needRotationAfterGlide(mimeType)) {
|
if (MimeTypes.needRotationAfterGlide(mimeType)) {
|
||||||
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
|
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
|
||||||
}
|
}
|
||||||
return bitmap;
|
return bitmap;
|
||||||
} finally {
|
} 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
|
@Override
|
||||||
protected void onPostExecute(Result result) {
|
protected void onPostExecute(Result result) {
|
||||||
Params params = result.params;
|
Params params = result.params;
|
||||||
MethodChannel.Result r = params.result;
|
MethodChannel.Result r = params.result;
|
||||||
AvesImageEntry entry = params.entry;
|
String uri = params.uri.toString();
|
||||||
String uri = entry.uri.toString();
|
|
||||||
if (result.data != null) {
|
if (result.data != null) {
|
||||||
r.success(result.data);
|
r.success(result.data);
|
||||||
} else {
|
} 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.List;
|
||||||
import java.util.Map;
|
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.ImageProvider;
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
|
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
|
||||||
|
@ -58,6 +58,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
||||||
case "rotate":
|
case "rotate":
|
||||||
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
|
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
|
||||||
break;
|
break;
|
||||||
|
case "flip":
|
||||||
|
new Thread(() -> flip(call, new MethodResultWrapper(result))).start();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
result.notImplemented();
|
result.notImplemented();
|
||||||
break;
|
break;
|
||||||
|
@ -65,12 +68,16 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||||
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 widthDip = call.argument("widthDip");
|
||||||
Double heightDip = call.argument("heightDip");
|
Double heightDip = call.argument("heightDip");
|
||||||
Double defaultSizeDip = call.argument("defaultSizeDip");
|
Double defaultSizeDip = call.argument("defaultSizeDip");
|
||||||
|
|
||||||
if (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);
|
result.error("getThumbnail-args", "failed because of missing arguments", null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -80,8 +87,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
||||||
int height = (int) Math.round(heightDip * density);
|
int height = (int) Math.round(heightDip * density);
|
||||||
int defaultSize = (int) Math.round(defaultSizeDip * density);
|
int defaultSize = (int) Math.round(defaultSizeDip * density);
|
||||||
|
|
||||||
AvesImageEntry entry = new AvesImageEntry(entryMap);
|
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(uri, mimeType, dateModifiedSecs.longValue(), rotationDegrees, isFlipped, width, height, defaultSize, result));
|
||||||
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void getObsoleteEntries(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
private void getObsoleteEntries(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||||
|
@ -167,7 +173,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
||||||
result.error("rotate-provider", "failed to find provider for uri=" + uri, null);
|
result.error("rotate-provider", "failed to find provider for uri=" + uri, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
provider.rotate(activity, path, uri, mimeType, clockwise, new ImageProvider.ImageOpCallback() {
|
ExifOrientationOp op = clockwise ? ExifOrientationOp.ROTATE_CW : ExifOrientationOp.ROTATE_CCW;
|
||||||
|
provider.changeOrientation(activity, path, uri, mimeType, op, new ImageProvider.ImageOpCallback() {
|
||||||
@Override
|
@Override
|
||||||
public void onSuccess(Map<String, Object> newFields) {
|
public void onSuccess(Map<String, Object> newFields) {
|
||||||
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
|
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
|
||||||
|
@ -179,4 +186,32 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void flip(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||||
|
Map<String, Object> entryMap = call.argument("entry");
|
||||||
|
if (entryMap == null) {
|
||||||
|
result.error("flip-args", "failed because of missing arguments", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Uri uri = Uri.parse((String) entryMap.get("uri"));
|
||||||
|
String path = (String) entryMap.get("path");
|
||||||
|
String mimeType = (String) entryMap.get("mimeType");
|
||||||
|
|
||||||
|
ImageProvider provider = ImageProviderFactory.getProvider(uri);
|
||||||
|
if (provider == null) {
|
||||||
|
result.error("flip-provider", "failed to find provider for uri=" + uri, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
provider.changeOrientation(activity, path, uri, mimeType, ExifOrientationOp.FLIP, new ImageProvider.ImageOpCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(Map<String, Object> newFields) {
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Throwable throwable) {
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> result.error("flip-failure", "failed to flip", throwable.getMessage()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -8,8 +8,8 @@ import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
|
||||||
import com.bumptech.glide.Glide;
|
import com.bumptech.glide.Glide;
|
||||||
|
import com.bumptech.glide.load.DecodeFormat;
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||||
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
|
|
||||||
import com.bumptech.glide.request.FutureTarget;
|
import com.bumptech.glide.request.FutureTarget;
|
||||||
import com.bumptech.glide.request.RequestOptions;
|
import com.bumptech.glide.request.RequestOptions;
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ import java.io.InputStream;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail;
|
import deckers.thibault.aves.decoder.VideoThumbnail;
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils;
|
||||||
import deckers.thibault.aves.utils.MimeTypes;
|
import deckers.thibault.aves.utils.MimeTypes;
|
||||||
import io.flutter.plugin.common.EventChannel;
|
import io.flutter.plugin.common.EventChannel;
|
||||||
|
|
||||||
|
@ -29,6 +30,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
||||||
private Uri uri;
|
private Uri uri;
|
||||||
private String mimeType;
|
private String mimeType;
|
||||||
private int rotationDegrees;
|
private int rotationDegrees;
|
||||||
|
private boolean isFlipped;
|
||||||
private EventChannel.EventSink eventSink;
|
private EventChannel.EventSink eventSink;
|
||||||
private Handler handler;
|
private Handler handler;
|
||||||
|
|
||||||
|
@ -40,6 +42,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
||||||
this.mimeType = (String) argMap.get("mimeType");
|
this.mimeType = (String) argMap.get("mimeType");
|
||||||
this.uri = Uri.parse((String) argMap.get("uri"));
|
this.uri = Uri.parse((String) argMap.get("uri"));
|
||||||
this.rotationDegrees = (int) argMap.get("rotationDegrees");
|
this.rotationDegrees = (int) argMap.get("rotationDegrees");
|
||||||
|
this.isFlipped = (boolean) argMap.get("isFlipped");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,9 +74,13 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
||||||
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
|
// - 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
|
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
|
||||||
private void getImage() {
|
private void getImage() {
|
||||||
if (MimeTypes.isVideo(mimeType)) {
|
// request a fresh image with the highest quality format
|
||||||
RequestOptions options = new RequestOptions()
|
RequestOptions options = new RequestOptions()
|
||||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.skipMemoryCache(true);
|
||||||
|
|
||||||
|
if (MimeTypes.isVideo(mimeType)) {
|
||||||
FutureTarget<Bitmap> target = Glide.with(activity)
|
FutureTarget<Bitmap> target = Glide.with(activity)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(options)
|
.apply(options)
|
||||||
|
@ -95,21 +102,27 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
|
||||||
} finally {
|
} finally {
|
||||||
Glide.with(activity).clear(target);
|
Glide.with(activity).clear(target);
|
||||||
}
|
}
|
||||||
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees)) {
|
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||||
// we convert the image on platform side first, when Dart Image.memory does not support it
|
// we convert the image on platform side first, when Dart Image.memory does not support it
|
||||||
FutureTarget<Bitmap> target = Glide.with(activity)
|
FutureTarget<Bitmap> target = Glide.with(activity)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
|
.apply(options)
|
||||||
.load(uri)
|
.load(uri)
|
||||||
.submit();
|
.submit();
|
||||||
try {
|
try {
|
||||||
Bitmap bitmap = target.get();
|
Bitmap bitmap = target.get();
|
||||||
|
if (MimeTypes.needRotationAfterGlide(mimeType)) {
|
||||||
|
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
|
||||||
|
}
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
// TODO TLAD use exif orientation to rotate & flip?
|
|
||||||
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
|
|
||||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
||||||
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
|
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
|
||||||
// Bitmap.CompressFormat.PNG is slower than JPEG
|
// Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
|
if (MimeTypes.canHaveAlpha(mimeType)) {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
|
||||||
|
} else {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
|
||||||
|
}
|
||||||
success(stream.toByteArray());
|
success(stream.toByteArray());
|
||||||
} else {
|
} else {
|
||||||
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
|
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.AvesImageEntry;
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider;
|
import deckers.thibault.aves.model.provider.ImageProvider;
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
import deckers.thibault.aves.utils.LogUtils;
|
||||||
import io.flutter.plugin.common.EventChannel;
|
import io.flutter.plugin.common.EventChannel;
|
||||||
|
|
||||||
public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
||||||
private static final String LOG_TAG = Utils.createLogTag(ImageOpStreamHandler.class);
|
private static final String LOG_TAG = LogUtils.createTag(ImageOpStreamHandler.class);
|
||||||
|
|
||||||
public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
|
public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
|
||||||
|
|
||||||
|
|
|
@ -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.GlideBuilder;
|
||||||
import com.bumptech.glide.Registry;
|
import com.bumptech.glide.Registry;
|
||||||
import com.bumptech.glide.annotation.GlideModule;
|
import com.bumptech.glide.annotation.GlideModule;
|
||||||
import com.bumptech.glide.load.DecodeFormat;
|
|
||||||
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser;
|
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser;
|
||||||
import com.bumptech.glide.module.AppGlideModule;
|
import com.bumptech.glide.module.AppGlideModule;
|
||||||
import com.bumptech.glide.request.RequestOptions;
|
|
||||||
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
public class AvesAppGlideModule extends AppGlideModule {
|
public class AvesAppGlideModule extends AppGlideModule {
|
||||||
@Override
|
@Override
|
||||||
public void applyOptions(@NotNull Context context, @NonNull GlideBuilder builder) {
|
public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
|
||||||
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
|
|
||||||
|
|
||||||
// hide noisy warning (e.g. for images that can't be decoded)
|
// hide noisy warning (e.g. for images that can't be decoded)
|
||||||
builder.setLogLevel(Log.ERROR);
|
builder.setLogLevel(Log.ERROR);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,44 @@
|
||||||
package deckers.thibault.aves.model.provider;
|
package deckers.thibault.aves.model.provider;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.database.Cursor;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.provider.MediaStore;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import deckers.thibault.aves.model.SourceImageEntry;
|
import deckers.thibault.aves.model.SourceImageEntry;
|
||||||
|
|
||||||
class ContentImageProvider extends ImageProvider {
|
class ContentImageProvider extends ImageProvider {
|
||||||
@Override
|
@Override
|
||||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||||
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());
|
callback.onSuccess(entry.toMap());
|
||||||
} else {
|
} else {
|
||||||
callback.onFailure(new Exception("entry has no size"));
|
callback.onFailure(new Exception("entry has no size"));
|
||||||
|
|
|
@ -8,14 +8,13 @@ import androidx.annotation.NonNull;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
|
||||||
import deckers.thibault.aves.model.SourceImageEntry;
|
import deckers.thibault.aves.model.SourceImageEntry;
|
||||||
import deckers.thibault.aves.utils.FileUtils;
|
|
||||||
|
|
||||||
class FileImageProvider extends ImageProvider {
|
class FileImageProvider extends ImageProvider {
|
||||||
@Override
|
@Override
|
||||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||||
SourceImageEntry entry = new SourceImageEntry(uri, mimeType);
|
SourceImageEntry entry = new SourceImageEntry(uri, mimeType);
|
||||||
|
|
||||||
String path = FileUtils.getPathFromUri(context, uri);
|
String path = uri.getPath();
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
try {
|
try {
|
||||||
File file = new File(path);
|
File file = new File(path);
|
||||||
|
@ -28,7 +27,7 @@ class FileImageProvider extends ImageProvider {
|
||||||
}
|
}
|
||||||
entry.fillPreCatalogMetadata(context);
|
entry.fillPreCatalogMetadata(context);
|
||||||
|
|
||||||
if (entry.getHasSize() || entry.isSvg()) {
|
if (entry.isSized() || entry.isSvg()) {
|
||||||
callback.onSuccess(entry.toMap());
|
callback.onSuccess(entry.toMap());
|
||||||
} else {
|
} else {
|
||||||
callback.onFailure(new Exception("entry has no size"));
|
callback.onFailure(new Exception("entry has no size"));
|
||||||
|
|
|
@ -23,9 +23,10 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import deckers.thibault.aves.model.AvesImageEntry;
|
import deckers.thibault.aves.model.AvesImageEntry;
|
||||||
|
import deckers.thibault.aves.model.ExifOrientationOp;
|
||||||
|
import deckers.thibault.aves.utils.LogUtils;
|
||||||
import deckers.thibault.aves.utils.MimeTypes;
|
import deckers.thibault.aves.utils.MimeTypes;
|
||||||
import deckers.thibault.aves.utils.StorageUtils;
|
import deckers.thibault.aves.utils.StorageUtils;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
|
||||||
|
|
||||||
// *** about file access to write/rename/delete
|
// *** about file access to write/rename/delete
|
||||||
// * primary volume
|
// * primary volume
|
||||||
|
@ -37,7 +38,7 @@ import deckers.thibault.aves.utils.Utils;
|
||||||
// from 21/Lollipop, use `DocumentFile` (not `File`) after getting permission to the volume root
|
// from 21/Lollipop, use `DocumentFile` (not `File`) after getting permission to the volume root
|
||||||
|
|
||||||
public abstract class ImageProvider {
|
public abstract class ImageProvider {
|
||||||
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
|
private static final String LOG_TAG = LogUtils.createTag(ImageProvider.class);
|
||||||
|
|
||||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||||
callback.onFailure(new UnsupportedOperationException());
|
callback.onFailure(new UnsupportedOperationException());
|
||||||
|
@ -94,7 +95,7 @@ public abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
|
public void changeOrientation(final Context context, final String path, final Uri uri, final String mimeType, final ExifOrientationOp op, final ImageOpCallback callback) {
|
||||||
if (!canEditExif(mimeType)) {
|
if (!canEditExif(mimeType)) {
|
||||||
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
|
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
|
||||||
return;
|
return;
|
||||||
|
@ -124,7 +125,17 @@ public abstract class ImageProvider {
|
||||||
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
||||||
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL));
|
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL));
|
||||||
}
|
}
|
||||||
exif.rotate(clockwise ? 90 : -90);
|
switch (op) {
|
||||||
|
case ROTATE_CW:
|
||||||
|
exif.rotate(90);
|
||||||
|
break;
|
||||||
|
case ROTATE_CCW:
|
||||||
|
exif.rotate(-90);
|
||||||
|
break;
|
||||||
|
case FLIP:
|
||||||
|
exif.flipHorizontally();
|
||||||
|
break;
|
||||||
|
}
|
||||||
exif.saveAttributes();
|
exif.saveAttributes();
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
|
@ -137,26 +148,22 @@ public abstract class ImageProvider {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentResolver contentResolver = context.getContentResolver();
|
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> {
|
||||||
// ContentValues values = new ContentValues();
|
String[] projection = {MediaStore.MediaColumns.DATE_MODIFIED};
|
||||||
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
try {
|
||||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
|
||||||
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
if (cursor != null) {
|
||||||
// // TODO TLAD catch RecoverableSecurityException
|
if (cursor.moveToNext()) {
|
||||||
// contentResolver.update(uri, values, null, null);
|
newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
|
||||||
// values.clear();
|
}
|
||||||
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
cursor.close();
|
||||||
// }
|
}
|
||||||
// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
|
} catch (Exception e) {
|
||||||
// values.put(MediaStore.Images.Media.ORIENTATION, rotationDegrees);
|
callback.onFailure(e);
|
||||||
// // TODO TLAD catch RecoverableSecurityException
|
return;
|
||||||
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
}
|
||||||
// if (updatedRowCount > 0) {
|
callback.onSuccess(newFields);
|
||||||
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
|
});
|
||||||
// } else {
|
|
||||||
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
|
||||||
// callback.onSuccess(newFields);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
|
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
|
||||||
|
|
|
@ -31,12 +31,12 @@ import java.util.stream.Stream;
|
||||||
|
|
||||||
import deckers.thibault.aves.model.AvesImageEntry;
|
import deckers.thibault.aves.model.AvesImageEntry;
|
||||||
import deckers.thibault.aves.model.SourceImageEntry;
|
import deckers.thibault.aves.model.SourceImageEntry;
|
||||||
|
import deckers.thibault.aves.utils.LogUtils;
|
||||||
import deckers.thibault.aves.utils.MimeTypes;
|
import deckers.thibault.aves.utils.MimeTypes;
|
||||||
import deckers.thibault.aves.utils.StorageUtils;
|
import deckers.thibault.aves.utils.StorageUtils;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
|
||||||
|
|
||||||
public class MediaStoreImageProvider extends ImageProvider {
|
public class MediaStoreImageProvider extends ImageProvider {
|
||||||
private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class);
|
private static final String LOG_TAG = LogUtils.createTag(MediaStoreImageProvider.class);
|
||||||
|
|
||||||
private static final String[] BASE_PROJECTION = {
|
private static final String[] BASE_PROJECTION = {
|
||||||
MediaStore.MediaColumns._ID,
|
MediaStore.MediaColumns._ID,
|
||||||
|
@ -437,7 +437,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
|
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
|
||||||
fullPath = destinationDir;
|
fullPath = destinationDir;
|
||||||
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
|
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
|
||||||
volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
|
volumePath = StorageUtils.getVolumePath(context, destinationDir);
|
||||||
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
|
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 app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.calls.*
|
import deckers.thibault.aves.channel.calls.*
|
||||||
import deckers.thibault.aves.channel.streams.*
|
import deckers.thibault.aves.channel.streams.*
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.PermissionManager
|
import deckers.thibault.aves.utils.PermissionManager
|
||||||
import deckers.thibault.aves.utils.Utils
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = Utils.createLogTag(MainActivity::class.java)
|
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
|
||||||
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
|
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
|
||||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||||
}
|
}
|
||||||
|
@ -139,7 +139,7 @@ class MainActivity : FlutterActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
|
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
|
val treeUri = data.data
|
||||||
if (resultCode != RESULT_OK || treeUri == null) {
|
if (resultCode != RESULT_OK || treeUri == null) {
|
||||||
PermissionManager.onPermissionResult(requestCode, null)
|
PermissionManager.onPermissionResult(requestCode, null)
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
@ -12,6 +14,7 @@ import androidx.exifinterface.media.ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPUtils
|
import com.adobe.internal.xmp.XMPUtils
|
||||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
||||||
import com.drew.imaging.ImageMetadataReader
|
import com.drew.imaging.ImageMetadataReader
|
||||||
import com.drew.imaging.ImageProcessingException
|
import com.drew.imaging.ImageProcessingException
|
||||||
import com.drew.lang.Rational
|
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.gif.GifAnimationDirectory
|
||||||
import com.drew.metadata.webp.WebpDirectory
|
import com.drew.metadata.webp.WebpDirectory
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
import deckers.thibault.aves.utils.*
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.utils.ExifInterfaceHelper.describeAll
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
||||||
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDescription
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
|
||||||
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||||
import deckers.thibault.aves.utils.Metadata.isFlippedForExifCode
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.utils.Metadata.parseVideoMetadataDate
|
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeBoolean
|
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.Metadata.parseVideoMetadataDate
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDescription
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeRational
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDescription
|
||||||
import deckers.thibault.aves.utils.MimeTypes.getMimeTypeForExtension
|
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.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.XMP.getSafeLocalizedText
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
@ -67,7 +78,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getAllMetadata-args", "failed because of missing arguments", null)
|
result.error("getAllMetadata-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -79,7 +90,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
||||||
|
@ -120,7 +131,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (!foundExif) {
|
if (!foundExif) {
|
||||||
// fallback to read EXIF via ExifInterface
|
// fallback to read EXIF via ExifInterface
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
val allTags = describeAll(exif).toMutableMap()
|
val allTags = describeAll(exif).toMutableMap()
|
||||||
if (foundXmp) {
|
if (foundXmp) {
|
||||||
|
@ -167,14 +178,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
val extension = call.argument<String>("extension")
|
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
|
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension))
|
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType))
|
||||||
if (isVideo(mimeType)) {
|
if (isVideo(mimeType)) {
|
||||||
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
|
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
|
||||||
}
|
}
|
||||||
|
@ -183,14 +193,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(metadataMap)
|
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>()
|
val metadataMap = HashMap<String, Any>()
|
||||||
|
|
||||||
var foundExif = false
|
var foundExif = false
|
||||||
|
|
||||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
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`)
|
// the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`)
|
||||||
// `context.getContentResolver().getType()` sometimes return incorrect value
|
// `context.getContentResolver().getType()` sometimes return incorrect value
|
||||||
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
|
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
|
||||||
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
|
// file extension is unreliable
|
||||||
val detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
|
// in the end, `metadata-extractor` is the most reliable, unless it reports `tiff`
|
||||||
if (detectedMimeType != null && detectedMimeType != mimeType) {
|
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
|
||||||
// file extension is unreliable, but we use it as a tie breaker
|
if (it != MimeTypes.TIFF) {
|
||||||
val extensionMimeType = extension?.toLowerCase(Locale.ROOT)?.let { getMimeTypeForExtension(it) }
|
metadataMap[KEY_MIME_TYPE] = it
|
||||||
if (extensionMimeType == null || detectedMimeType == extensionMimeType) {
|
|
||||||
metadataMap[KEY_MIME_TYPE] = detectedMimeType
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -278,7 +285,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (!foundExif) {
|
if (!foundExif) {
|
||||||
// fallback to read EXIF via ExifInterface
|
// fallback to read EXIF via ExifInterface
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
|
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||||
|
@ -289,7 +296,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
|
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
|
||||||
}
|
}
|
||||||
val latLong = exif.latLong
|
val latLong = exif.latLong
|
||||||
if (latLong != null && latLong.size == 2) {
|
if (latLong?.size == 2) {
|
||||||
metadataMap[KEY_LATITUDE] = latLong[0]
|
metadataMap[KEY_LATITUDE] = latLong[0]
|
||||||
metadataMap[KEY_LONGITUDE] = latLong[1]
|
metadataMap[KEY_LONGITUDE] = latLong[1]
|
||||||
}
|
}
|
||||||
|
@ -320,23 +327,15 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
|
||||||
if (locationString != null) {
|
if (locationString != null) {
|
||||||
val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
|
||||||
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
|
if (matcher.find() && matcher.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`
|
// 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) {
|
if (latitude != 0.0 || longitude != 0.0) {
|
||||||
metadataMap[KEY_LATITUDE] = latitude
|
metadataMap[KEY_LATITUDE] = latitude
|
||||||
metadataMap[KEY_LONGITUDE] = longitude
|
metadataMap[KEY_LONGITUDE] = longitude
|
||||||
}
|
}
|
||||||
} catch (e: NumberFormatException) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -350,7 +349,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
|
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -362,7 +361,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
|
||||||
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
|
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
|
||||||
|
@ -382,7 +381,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(metadataMap)
|
result.success(metadataMap)
|
||||||
}
|
} ?: result.error("getOverlayMetadata-noinput", "failed to get metadata for uri=$uri", null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
|
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
|
||||||
} catch (e: NoClassDefFoundError) {
|
} catch (e: NoClassDefFoundError) {
|
||||||
|
@ -392,14 +391,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
|
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var contentUri: Uri = uri
|
||||||
|
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
|
||||||
|
try {
|
||||||
val id = ContentUris.parseId(uri)
|
val id = ContentUris.parseId(uri)
|
||||||
var contentUri = when {
|
contentUri = when {
|
||||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||||
else -> uri
|
else -> uri
|
||||||
|
@ -407,6 +409,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
contentUri = MediaStore.setRequireOriginal(contentUri)
|
contentUri = MediaStore.setRequireOriginal(contentUri)
|
||||||
}
|
}
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
|
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
@ -436,21 +442,21 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
|
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
val metadataMap = HashMap<String, String?>()
|
val metadataMap = HashMap<String, String?>()
|
||||||
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
|
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
|
||||||
metadataMap[tag] = exif.getAttribute(tag)
|
metadataMap[tag] = exif.getAttribute(tag)
|
||||||
}
|
}
|
||||||
result.success(metadataMap)
|
result.success(metadataMap)
|
||||||
}
|
} ?: result.error("getExifInterfaceMetadata-noinput", "failed to get exif for uri=$uri", null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// ExifInterface initialization can fail with a RuntimeException
|
// ExifInterface initialization can fail with a RuntimeException
|
||||||
// caused by an internal MediaMetadataRetriever failure
|
// 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) {
|
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
|
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -483,7 +489,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
|
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -505,7 +511,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
|
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -513,9 +519,19 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
val thumbnails = ArrayList<ByteArray>()
|
val thumbnails = ArrayList<ByteArray>()
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(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) {
|
} catch (e: Exception) {
|
||||||
// ExifInterface initialization can fail with a RuntimeException
|
// 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) {
|
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = Uri.parse(call.argument("uri"))
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
|
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -535,7 +551,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val thumbnails = ArrayList<ByteArray>()
|
val thumbnails = ArrayList<ByteArray>()
|
||||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||||
val xmpMeta = dir.xmpMeta
|
val xmpMeta = dir.xmpMeta
|
||||||
|
@ -567,7 +583,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = Utils.createLogTag(MetadataHandler::class.java)
|
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||||
|
|
||||||
// catalog metadata
|
// catalog metadata
|
||||||
|
|
|
@ -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 android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
@ -8,12 +8,13 @@ import com.drew.metadata.exif.*
|
||||||
import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory
|
import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory
|
||||||
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
|
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
|
||||||
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
|
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
object ExifInterfaceHelper {
|
object ExifInterfaceHelper {
|
||||||
private val LOG_TAG = Utils.createLogTag(ExifInterfaceHelper::class.java)
|
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java)
|
||||||
|
|
||||||
// ExifInterface always states it has the following attributes
|
// ExifInterface always states it has the following attributes
|
||||||
// and returns "0" instead of "null" when they are actually missing
|
// and returns "0" instead of "null" when they are actually missing
|
|
@ -1,4 +1,4 @@
|
||||||
package deckers.thibault.aves.utils
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
|
@ -1,4 +1,4 @@
|
||||||
package deckers.thibault.aves.utils
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
|
@ -34,6 +34,16 @@ object Metadata {
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getExifCode(rotationDegrees: Int, isFlipped: Boolean): Int {
|
||||||
|
return when (rotationDegrees) {
|
||||||
|
90 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSVERSE else ExifInterface.ORIENTATION_ROTATE_90
|
||||||
|
180 -> if (isFlipped) ExifInterface.ORIENTATION_FLIP_VERTICAL else ExifInterface.ORIENTATION_ROTATE_180
|
||||||
|
270 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSPOSE else ExifInterface.ORIENTATION_ROTATE_270
|
||||||
|
else -> if (isFlipped) ExifInterface.ORIENTATION_FLIP_HORIZONTAL else ExifInterface.ORIENTATION_NORMAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
|
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun parseVideoMetadataDate(metadataDate: String?): Long {
|
fun parseVideoMetadataDate(metadataDate: String?): Long {
|
|
@ -1,4 +1,4 @@
|
||||||
package deckers.thibault.aves.utils
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import com.drew.lang.Rational
|
import com.drew.lang.Rational
|
||||||
import com.drew.metadata.Directory
|
import com.drew.metadata.Directory
|
||||||
|
@ -11,6 +11,10 @@ object MetadataExtractorHelper {
|
||||||
if (this.containsTag(tag)) save(this.getDescription(tag))
|
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) {
|
fun Directory.getSafeBoolean(tag: Int, save: (value: Boolean) -> Unit) {
|
||||||
if (this.containsTag(tag)) save(this.getBoolean(tag))
|
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.XMPException
|
||||||
import com.adobe.internal.xmp.XMPMeta
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
|
||||||
object XMP {
|
object XMP {
|
||||||
|
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
|
||||||
|
|
||||||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
||||||
const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
|
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 GENERIC_LANG = ""
|
||||||
private const val SPECIFIC_LANG = "en-US"
|
private const val SPECIFIC_LANG = "en-US"
|
||||||
|
|
||||||
@Throws(XMPException::class)
|
|
||||||
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
|
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
|
||||||
|
try {
|
||||||
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
|
||||||
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
|
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
|
// double check retrieved items as the property sometimes is reported to exist but it is actually null
|
||||||
if (item != null) save(item.value)
|
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
|
package deckers.thibault.aves.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
|
||||||
|
|
||||||
class AvesImageEntry(map: Map<String?, Any?>) {
|
class AvesImageEntry(map: Map<String?, Any?>) {
|
||||||
@JvmField
|
@JvmField
|
||||||
|
@ -21,18 +20,4 @@ class AvesImageEntry(map: Map<String?, Any?>) {
|
||||||
|
|
||||||
@JvmField
|
@JvmField
|
||||||
val rotationDegrees = map["rotationDegrees"] as Int
|
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.Mp4Directory
|
||||||
import com.drew.metadata.mp4.media.Mp4VideoDirectory
|
import com.drew.metadata.mp4.media.Mp4VideoDirectory
|
||||||
import com.drew.metadata.photoshop.PsdHeaderDirectory
|
import com.drew.metadata.photoshop.PsdHeaderDirectory
|
||||||
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
|
||||||
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeString
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||||
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -101,15 +101,12 @@ class SourceImageEntry {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasSize: Boolean
|
val isSized: Boolean
|
||||||
get() = width ?: 0 > 0 && height ?: 0 > 0
|
get() = width ?: 0 > 0 && height ?: 0 > 0
|
||||||
|
|
||||||
private val hasDuration: Boolean
|
private val hasDuration: Boolean
|
||||||
get() = durationMillis ?: 0 > 0
|
get() = durationMillis ?: 0 > 0
|
||||||
|
|
||||||
private val isImage: Boolean
|
|
||||||
get() = MimeTypes.isImage(sourceMimeType)
|
|
||||||
|
|
||||||
private val isVideo: Boolean
|
private val isVideo: Boolean
|
||||||
get() = MimeTypes.isVideo(sourceMimeType)
|
get() = MimeTypes.isVideo(sourceMimeType)
|
||||||
|
|
||||||
|
@ -123,15 +120,15 @@ class SourceImageEntry {
|
||||||
if (isSvg) return this
|
if (isSvg) return this
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
fillVideoByMediaMetadataRetriever(context)
|
fillVideoByMediaMetadataRetriever(context)
|
||||||
if (hasSize && hasDuration) return this
|
if (isSized && hasDuration) return this
|
||||||
}
|
}
|
||||||
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
|
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
|
||||||
fillByMetadataExtractor(context)
|
fillByMetadataExtractor(context)
|
||||||
if (hasSize && foundExif) return this
|
if (isSized && foundExif) return this
|
||||||
}
|
}
|
||||||
if (ExifInterface.isSupportedMimeType(sourceMimeType)) {
|
if (ExifInterface.isSupportedMimeType(sourceMimeType)) {
|
||||||
fillByExifInterface(context)
|
fillByExifInterface(context)
|
||||||
if (hasSize) return this
|
if (isSized) return this
|
||||||
}
|
}
|
||||||
fillByBitmapDecode(context)
|
fillByBitmapDecode(context)
|
||||||
return this
|
return this
|
||||||
|
@ -158,7 +155,7 @@ class SourceImageEntry {
|
||||||
// finds: width, height, orientation, date, duration
|
// finds: width, height, orientation, date, duration
|
||||||
private fun fillByMetadataExtractor(context: Context) {
|
private fun fillByMetadataExtractor(context: Context) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
|
|
||||||
// do not switch on specific mime types, as the reported mime type could be wrong
|
// 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
|
// finds: width, height, orientation, date
|
||||||
private fun fillByExifInterface(context: Context) {
|
private fun fillByExifInterface(context: Context) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val exif = ExifInterface(input)
|
val exif = ExifInterface(input)
|
||||||
foundExif = true
|
foundExif = true
|
||||||
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
|
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
|
||||||
|
@ -226,7 +223,7 @@ class SourceImageEntry {
|
||||||
// finds: width, height
|
// finds: width, height
|
||||||
private fun fillByBitmapDecode(context: Context) {
|
private fun fillByBitmapDecode(context: Context) {
|
||||||
try {
|
try {
|
||||||
StorageUtils.openInputStream(context, uri).use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val options = BitmapFactory.Options()
|
val options = BitmapFactory.Options()
|
||||||
options.inJustDecodeBounds = true
|
options.inJustDecodeBounds = true
|
||||||
BitmapFactory.decodeStream(input, null, options)
|
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
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
object Utils {
|
object LogUtils {
|
||||||
private const val LOG_TAG_MAX_LENGTH = 23
|
private const val LOG_TAG_MAX_LENGTH = 23
|
||||||
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.")
|
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.")
|
||||||
|
|
||||||
|
// create an Android logger friendly log tag for the specified class
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun createLogTag(clazz: Class<*>): String {
|
fun createTag(clazz: Class<*>): String {
|
||||||
// shorten class name to "a.b.CccDdd"
|
// shorten class name to "a.b.CccDdd"
|
||||||
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.")
|
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.")
|
||||||
if (logTag.length > LOG_TAG_MAX_LENGTH) {
|
if (logTag.length > LOG_TAG_MAX_LENGTH) {
|
|
@ -1,7 +1,5 @@
|
||||||
package deckers.thibault.aves.utils
|
package deckers.thibault.aves.utils
|
||||||
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
object MimeTypes {
|
object MimeTypes {
|
||||||
private const val IMAGE = "image"
|
private const val IMAGE = "image"
|
||||||
|
|
||||||
|
@ -12,44 +10,20 @@ object MimeTypes {
|
||||||
private const val HEIF = "image/heif"
|
private const val HEIF = "image/heif"
|
||||||
private const val ICO = "image/x-icon"
|
private const val ICO = "image/x-icon"
|
||||||
private const val JPEG = "image/jpeg"
|
private const val JPEG = "image/jpeg"
|
||||||
private const val PCX = "image/x-pcx"
|
|
||||||
private const val PNG = "image/png"
|
private const val PNG = "image/png"
|
||||||
private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop"
|
const val TIFF = "image/tiff"
|
||||||
private const val TIFF = "image/tiff"
|
|
||||||
private const val WBMP = "image/vnd.wap.wbmp"
|
private const val WBMP = "image/vnd.wap.wbmp"
|
||||||
const val WEBP = "image/webp"
|
const val WEBP = "image/webp"
|
||||||
|
|
||||||
// raw raster
|
// 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 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
|
// vector
|
||||||
const val SVG = "image/svg+xml"
|
const val SVG = "image/svg+xml"
|
||||||
|
|
||||||
private const val VIDEO = "video"
|
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 MP2T = "video/mp2t"
|
||||||
private const val MP4 = "video/mp4"
|
|
||||||
private const val WEBM = "video/webm"
|
private const val WEBM = "video/webm"
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
@ -58,11 +32,19 @@ object MimeTypes {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
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
|
// as of Flutter v1.22.0
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?) = when (mimeType) {
|
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
|
||||||
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
||||||
PNG -> rotationDegrees ?: 0 == 0
|
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,6 +57,8 @@ object MimeTypes {
|
||||||
|
|
||||||
// Glide automatically applies EXIF orientation when decoding images of known formats
|
// Glide automatically applies EXIF orientation when decoding images of known formats
|
||||||
// but we need to rotate the decoded bitmap for the other formats
|
// but we need to rotate the decoded bitmap for the other formats
|
||||||
|
// maybe related to ExifInterface version used by Glide:
|
||||||
|
// https://github.com/bumptech/glide/blob/master/gradle.properties#L21
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
|
fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
|
||||||
DNG, HEIC, HEIF, PNG, WEBP -> true
|
DNG, HEIC, HEIF, PNG, WEBP -> true
|
||||||
|
@ -89,50 +73,4 @@ object MimeTypes {
|
||||||
DNG, PNG -> true
|
DNG, PNG -> true
|
||||||
else -> false
|
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 {
|
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 '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 "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'
|
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();
|
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 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry_cache.dart';
|
||||||
import 'package:aves/model/favourite_repo.dart';
|
import 'package:aves/model/favourite_repo.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/image_metadata.dart';
|
||||||
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
|
@ -75,8 +77,8 @@ class ImageEntry {
|
||||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||||
durationMillis: durationMillis,
|
durationMillis: durationMillis,
|
||||||
)
|
)
|
||||||
.._catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
|
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
|
||||||
.._addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
|
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
|
||||||
|
|
||||||
return copied;
|
return copied;
|
||||||
}
|
}
|
||||||
|
@ -154,7 +156,7 @@ class ImageEntry {
|
||||||
|
|
||||||
// the MIME type reported by the Media Store is unreliable
|
// the MIME type reported by the Media Store is unreliable
|
||||||
// so we use the one found during cataloguing if possible
|
// 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('/.*'), '/*');
|
String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
|
||||||
|
|
||||||
|
@ -173,13 +175,11 @@ class ImageEntry {
|
||||||
|
|
||||||
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
|
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
|
||||||
|
|
||||||
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
|
|
||||||
|
|
||||||
bool get canEdit => path != null;
|
bool get canEdit => path != null;
|
||||||
|
|
||||||
bool get canPrint => !isVideo;
|
bool get canPrint => !isVideo;
|
||||||
|
|
||||||
bool get canRotate => canEdit && canEditExif;
|
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||||
|
|
||||||
// support for writing EXIF
|
// support for writing EXIF
|
||||||
// as of androidx.exifinterface:exifinterface:1.3.0
|
// as of androidx.exifinterface:exifinterface:1.3.0
|
||||||
|
@ -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 {
|
double get displayAspectRatio {
|
||||||
if (width == 0 || height == 0) return 1;
|
if (width == 0 || height == 0) return 1;
|
||||||
|
@ -220,13 +220,17 @@ class ImageEntry {
|
||||||
return _bestDate;
|
return _bestDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
|
int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees ?? 0;
|
||||||
|
|
||||||
set rotationDegrees(int rotationDegrees) {
|
set rotationDegrees(int rotationDegrees) {
|
||||||
sourceRotationDegrees = 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;
|
int get dateModifiedSecs => _dateModifiedSecs;
|
||||||
|
|
||||||
set dateModifiedSecs(int dateModifiedSecs) {
|
set dateModifiedSecs(int dateModifiedSecs) {
|
||||||
|
@ -276,10 +280,16 @@ class ImageEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
set catalogMetadata(CatalogMetadata newMetadata) {
|
set catalogMetadata(CatalogMetadata newMetadata) {
|
||||||
|
final oldDateModifiedSecs = dateModifiedSecs;
|
||||||
|
final oldRotationDegrees = rotationDegrees;
|
||||||
|
final oldIsFlipped = isFlipped;
|
||||||
|
|
||||||
catalogDateMillis = newMetadata?.dateMillis;
|
catalogDateMillis = newMetadata?.dateMillis;
|
||||||
_catalogMetadata = newMetadata;
|
_catalogMetadata = newMetadata;
|
||||||
_bestTitle = null;
|
_bestTitle = null;
|
||||||
metadataChangeNotifier.notifyListeners();
|
metadataChangeNotifier.notifyListeners();
|
||||||
|
|
||||||
|
_onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearMetadata() {
|
void clearMetadata() {
|
||||||
|
@ -351,12 +361,7 @@ class ImageEntry {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> rename(String newName) async {
|
Future<void> _applyNewFields(Map newFields) async {
|
||||||
if (newName == filenameWithoutExtension) return true;
|
|
||||||
|
|
||||||
final newFields = await ImageFileService.rename(this, '$newName$extension');
|
|
||||||
if (newFields.isEmpty) return false;
|
|
||||||
|
|
||||||
final uri = newFields['uri'];
|
final uri = newFields['uri'];
|
||||||
if (uri is String) this.uri = uri;
|
if (uri is String) this.uri = uri;
|
||||||
final path = newFields['path'];
|
final path = newFields['path'];
|
||||||
|
@ -365,6 +370,24 @@ class ImageEntry {
|
||||||
if (contentId is int) this.contentId = contentId;
|
if (contentId is int) this.contentId = contentId;
|
||||||
final sourceTitle = newFields['title'];
|
final sourceTitle = newFields['title'];
|
||||||
if (sourceTitle is String) this.sourceTitle = sourceTitle;
|
if (sourceTitle is String) this.sourceTitle = sourceTitle;
|
||||||
|
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
||||||
|
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
||||||
|
final rotationDegrees = newFields['rotationDegrees'];
|
||||||
|
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
|
||||||
|
final isFlipped = newFields['isFlipped'];
|
||||||
|
if (isFlipped is bool) this.isFlipped = isFlipped;
|
||||||
|
|
||||||
|
await metadataDb.saveEntries({this});
|
||||||
|
await metadataDb.saveMetadata({catalogMetadata});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> rename(String newName) async {
|
||||||
|
if (newName == filenameWithoutExtension) return true;
|
||||||
|
|
||||||
|
final newFields = await ImageFileService.rename(this, '$newName$extension');
|
||||||
|
if (newFields.isEmpty) return false;
|
||||||
|
|
||||||
|
await _applyNewFields(newFields);
|
||||||
_bestTitle = null;
|
_bestTitle = null;
|
||||||
metadataChangeNotifier.notifyListeners();
|
metadataChangeNotifier.notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
|
@ -374,14 +397,23 @@ class ImageEntry {
|
||||||
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
|
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
|
||||||
if (newFields.isEmpty) return false;
|
if (newFields.isEmpty) return false;
|
||||||
|
|
||||||
final width = newFields['width'];
|
final oldDateModifiedSecs = dateModifiedSecs;
|
||||||
if (width is int) this.width = width;
|
final oldRotationDegrees = rotationDegrees;
|
||||||
final height = newFields['height'];
|
final oldIsFlipped = isFlipped;
|
||||||
if (height is int) this.height = height;
|
await _applyNewFields(newFields);
|
||||||
final rotationDegrees = newFields['rotationDegrees'];
|
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||||
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -399,6 +431,16 @@ class ImageEntry {
|
||||||
return completer.future;
|
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() {
|
void toggleFavourite() {
|
||||||
if (isFavourite) {
|
if (isFavourite) {
|
||||||
removeFromFavourites();
|
removeFromFavourites();
|
||||||
|
@ -419,18 +461,29 @@ class ImageEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compare by:
|
||||||
|
// 1) title ascending
|
||||||
|
// 2) extension ascending
|
||||||
static int compareByName(ImageEntry a, ImageEntry b) {
|
static int compareByName(ImageEntry a, ImageEntry b) {
|
||||||
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
||||||
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
|
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compare by:
|
||||||
|
// 1) size descending
|
||||||
|
// 2) name ascending
|
||||||
static int compareBySize(ImageEntry a, ImageEntry b) {
|
static int compareBySize(ImageEntry a, ImageEntry b) {
|
||||||
final c = b.sizeBytes.compareTo(a.sizeBytes);
|
final c = b.sizeBytes.compareTo(a.sizeBytes);
|
||||||
return c != 0 ? c : compareByName(a, b);
|
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) {
|
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);
|
return c != 0 ? c : compareByName(a, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -110,7 +110,9 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
double get collectionTileExtent => _prefs.getDouble(collectionTileExtentKey) ?? 0;
|
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);
|
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;
|
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);
|
var oldValue = _prefs.get(key);
|
||||||
if (newValue == null) {
|
if (newValue == null) {
|
||||||
_prefs.remove(key);
|
_prefs.remove(key);
|
||||||
|
@ -209,7 +211,7 @@ class Settings extends ChangeNotifier {
|
||||||
oldValue = _prefs.getBool(key);
|
oldValue = _prefs.getBool(key);
|
||||||
_prefs.setBool(key, newValue);
|
_prefs.setBool(key, newValue);
|
||||||
}
|
}
|
||||||
if (oldValue != newValue) {
|
if (oldValue != newValue && notify) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
enum Activity { browse, select }
|
enum Activity { browse, select }
|
||||||
|
|
||||||
enum ChipSortFactor { date, name }
|
enum ChipSortFactor { date, name, count }
|
||||||
|
|
||||||
enum EntrySortFactor { date, size, name }
|
enum EntrySortFactor { date, size, name }
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,15 @@ class AppShortcutService {
|
||||||
Uint8List iconBytes;
|
Uint8List iconBytes;
|
||||||
if (iconEntry != null) {
|
if (iconEntry != null) {
|
||||||
final size = iconEntry.isVideo ? 0.0 : 256.0;
|
final size = iconEntry.isVideo ? 0.0 : 256.0;
|
||||||
iconBytes = await ImageFileService.getThumbnail(iconEntry, size, size);
|
iconBytes = await ImageFileService.getThumbnail(
|
||||||
|
iconEntry.uri,
|
||||||
|
iconEntry.mimeType,
|
||||||
|
iconEntry.dateModifiedSecs,
|
||||||
|
iconEntry.rotationDegrees,
|
||||||
|
iconEntry.isFlipped,
|
||||||
|
size,
|
||||||
|
size,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('pin', <String, dynamic>{
|
await platform.invokeMethod('pin', <String, dynamic>{
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/image_entry.dart';
|
||||||
|
import 'package:aves/model/mime_types.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -24,6 +25,7 @@ class ImageFileService {
|
||||||
'width': entry.width,
|
'width': entry.width,
|
||||||
'height': entry.height,
|
'height': entry.height,
|
||||||
'rotationDegrees': entry.rotationDegrees,
|
'rotationDegrees': entry.rotationDegrees,
|
||||||
|
'isFlipped': entry.isFlipped,
|
||||||
'dateModifiedSecs': entry.dateModifiedSecs,
|
'dateModifiedSecs': entry.dateModifiedSecs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -66,7 +68,14 @@ class ImageFileService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Uint8List> getImage(String uri, String mimeType, {int rotationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) {
|
static Future<Uint8List> getImage(
|
||||||
|
String uri,
|
||||||
|
String mimeType,
|
||||||
|
int rotationDegrees,
|
||||||
|
bool isFlipped, {
|
||||||
|
int expectedContentLength,
|
||||||
|
BytesReceivedCallback onBytesReceived,
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
final completer = Completer<Uint8List>.sync();
|
final completer = Completer<Uint8List>.sync();
|
||||||
final sink = _OutputBuffer();
|
final sink = _OutputBuffer();
|
||||||
|
@ -75,6 +84,7 @@ class ImageFileService {
|
||||||
'uri': uri,
|
'uri': uri,
|
||||||
'mimeType': mimeType,
|
'mimeType': mimeType,
|
||||||
'rotationDegrees': rotationDegrees ?? 0,
|
'rotationDegrees': rotationDegrees ?? 0,
|
||||||
|
'isFlipped': isFlipped ?? false,
|
||||||
}).listen(
|
}).listen(
|
||||||
(data) {
|
(data) {
|
||||||
final chunk = data as Uint8List;
|
final chunk = data as Uint8List;
|
||||||
|
@ -103,15 +113,29 @@ class ImageFileService {
|
||||||
return Future.sync(() => null);
|
return Future.sync(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Uint8List> getThumbnail(ImageEntry entry, double width, double height, {Object taskKey, int priority}) {
|
static Future<Uint8List> getThumbnail(
|
||||||
if (entry.isSvg) {
|
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 Future.sync(() => null);
|
||||||
}
|
}
|
||||||
return servicePolicy.call(
|
return servicePolicy.call(
|
||||||
() async {
|
() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
|
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
|
||||||
'entry': _toPlatformEntryMap(entry),
|
'uri': uri,
|
||||||
|
'mimeType': mimeType,
|
||||||
|
'dateModifiedSecs': dateModifiedSecs,
|
||||||
|
'rotationDegrees': rotationDegrees,
|
||||||
|
'isFlipped': isFlipped,
|
||||||
'widthDip': width,
|
'widthDip': width,
|
||||||
'heightDip': height,
|
'heightDip': height,
|
||||||
'defaultSizeDip': thumbnailDefaultSize,
|
'defaultSizeDip': thumbnailDefaultSize,
|
||||||
|
@ -183,7 +207,7 @@ class ImageFileService {
|
||||||
|
|
||||||
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
|
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
|
||||||
try {
|
try {
|
||||||
// return map with: 'width' 'height' 'rotationDegrees' (all optional)
|
// return map with: 'rotationDegrees' 'isFlipped'
|
||||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||||
'entry': _toPlatformEntryMap(entry),
|
'entry': _toPlatformEntryMap(entry),
|
||||||
'clockwise': clockwise,
|
'clockwise': clockwise,
|
||||||
|
@ -194,6 +218,19 @@ class ImageFileService {
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map> flip(ImageEntry entry) async {
|
||||||
|
try {
|
||||||
|
// return map with: 'rotationDegrees' 'isFlipped'
|
||||||
|
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
||||||
|
'entry': _toPlatformEntryMap(entry),
|
||||||
|
}) as Map;
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
|
|
|
@ -43,7 +43,6 @@ class MetadataService {
|
||||||
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
|
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
|
||||||
'mimeType': entry.mimeType,
|
'mimeType': entry.mimeType,
|
||||||
'uri': entry.uri,
|
'uri': entry.uri,
|
||||||
'extension': entry.extension,
|
|
||||||
}) as Map;
|
}) as Map;
|
||||||
result['contentId'] = entry.contentId;
|
result['contentId'] = entry.contentId;
|
||||||
return CatalogMetadata.fromMap(result);
|
return CatalogMetadata.fromMap(result);
|
||||||
|
|
|
@ -19,18 +19,18 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
class DebugPage extends StatefulWidget {
|
class AppDebugPage extends StatefulWidget {
|
||||||
static const routeName = '/debug';
|
static const routeName = '/debug';
|
||||||
|
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
|
|
||||||
const DebugPage({this.source});
|
const AppDebugPage({this.source});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => DebugPageState();
|
State<StatefulWidget> createState() => AppDebugPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class DebugPageState extends State<DebugPage> {
|
class AppDebugPageState extends State<AppDebugPage> {
|
||||||
Future<int> _dbFileSizeLoader;
|
Future<int> _dbFileSizeLoader;
|
||||||
Future<List<ImageEntry>> _dbEntryLoader;
|
Future<List<ImageEntry>> _dbEntryLoader;
|
||||||
Future<List<DateMetadata>> _dbDateLoader;
|
Future<List<DateMetadata>> _dbDateLoader;
|
||||||
|
@ -103,7 +103,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('Crashlytics'),
|
child: Text('Crashlytics'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: FirebaseCrashlytics.instance.crash,
|
onPressed: FirebaseCrashlytics.instance.crash,
|
||||||
child: Text('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)}'),
|
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
imageCache.clear();
|
imageCache.clear();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
@ -138,7 +138,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
|
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
PictureProvider.clearCache();
|
PictureProvider.clearCache();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
@ -153,7 +153,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('Glide disk cache: ?'),
|
child: Text('Glide disk cache: ?'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
|
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
|
||||||
child: Text('Clear'),
|
child: Text('Clear'),
|
||||||
),
|
),
|
||||||
|
@ -171,7 +171,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
|
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
|
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
|
||||||
child: Text('Reset'),
|
child: Text('Reset'),
|
||||||
),
|
),
|
||||||
|
@ -190,7 +190,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('DB entry rows: ${snapshot.data.length}'),
|
child: Text('DB entry rows: ${snapshot.data.length}'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
|
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
|
||||||
child: Text('Clear'),
|
child: Text('Clear'),
|
||||||
),
|
),
|
||||||
|
@ -209,7 +209,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('DB date rows: ${snapshot.data.length}'),
|
child: Text('DB date rows: ${snapshot.data.length}'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
|
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
|
||||||
child: Text('Clear'),
|
child: Text('Clear'),
|
||||||
),
|
),
|
||||||
|
@ -228,7 +228,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('DB metadata rows: ${snapshot.data.length}'),
|
child: Text('DB metadata rows: ${snapshot.data.length}'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
|
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
|
||||||
child: Text('Clear'),
|
child: Text('Clear'),
|
||||||
),
|
),
|
||||||
|
@ -247,7 +247,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('DB address rows: ${snapshot.data.length}'),
|
child: Text('DB address rows: ${snapshot.data.length}'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
|
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
|
||||||
child: Text('Clear'),
|
child: Text('Clear'),
|
||||||
),
|
),
|
||||||
|
@ -266,7 +266,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
|
child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => favourites.clear().then((_) => _startDbReport()),
|
onPressed: () => favourites.clear().then((_) => _startDbReport()),
|
||||||
child: Text('Clear'),
|
child: Text('Clear'),
|
||||||
),
|
),
|
||||||
|
@ -288,7 +288,7 @@ class DebugPageState extends State<DebugPage> {
|
||||||
child: Text('Settings'),
|
child: Text('Settings'),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
RaisedButton(
|
ElevatedButton(
|
||||||
onPressed: () => settings.reset().then((_) => setState(() {})),
|
onPressed: () => settings.reset().then((_) => setState(() {})),
|
||||||
child: Text('Reset'),
|
child: Text('Reset'),
|
||||||
),
|
),
|
|
@ -43,28 +43,42 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initProvider();
|
_registerWidget(widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(ThumbnailRasterImage oldWidget) {
|
void didUpdateWidget(ThumbnailRasterImage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.entry != entry) {
|
if (oldWidget.entry != entry) {
|
||||||
_pauseProvider();
|
_unregisterWidget(oldWidget);
|
||||||
_initProvider();
|
_registerWidget(widget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pauseProvider();
|
_unregisterWidget(widget);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _registerWidget(ThumbnailRasterImage widget) {
|
||||||
|
widget.entry.imageChangeNotifier.addListener(_onImageChanged);
|
||||||
|
_initProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _unregisterWidget(ThumbnailRasterImage widget) {
|
||||||
|
widget.entry.imageChangeNotifier?.removeListener(_onImageChanged);
|
||||||
|
_pauseProvider();
|
||||||
|
}
|
||||||
|
|
||||||
void _initProvider() {
|
void _initProvider() {
|
||||||
_fastThumbnailProvider = ThumbnailProvider(entry: entry);
|
_fastThumbnailProvider = ThumbnailProvider(
|
||||||
|
ThumbnailProviderKey.fromEntry(entry),
|
||||||
|
);
|
||||||
if (!entry.isVideo) {
|
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,
|
uri: entry.uri,
|
||||||
mimeType: entry.mimeType,
|
mimeType: entry.mimeType,
|
||||||
rotationDegrees: entry.rotationDegrees,
|
rotationDegrees: entry.rotationDegrees,
|
||||||
|
isFlipped: entry.isFlipped,
|
||||||
expectedContentLength: entry.sizeBytes,
|
expectedContentLength: entry.sizeBytes,
|
||||||
);
|
);
|
||||||
if (imageCache.statusForKey(imageProvider).keepAlive) {
|
if (imageCache.statusForKey(imageProvider).keepAlive) {
|
||||||
|
@ -153,4 +168,12 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
||||||
child: image,
|
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),
|
onSubmitted: (_) => _submit(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
ValueListenableBuilder<bool>(
|
ValueListenableBuilder<bool>(
|
||||||
valueListenable: _isValidNotifier,
|
valueListenable: _isValidNotifier,
|
||||||
builder: (context, isValid, child) {
|
builder: (context, isValid, child) {
|
||||||
return FlatButton(
|
return TextButton(
|
||||||
onPressed: isValid ? () => _submit(context) : null,
|
onPressed: isValid ? () => _submit(context) : null,
|
||||||
child: Text('Add'.toUpperCase()),
|
child: Text('Add'.toUpperCase()),
|
||||||
);
|
);
|
||||||
|
|
|
@ -92,14 +92,14 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
ValueListenableBuilder<bool>(
|
ValueListenableBuilder<bool>(
|
||||||
valueListenable: _isValidNotifier,
|
valueListenable: _isValidNotifier,
|
||||||
builder: (context, isValid, child) {
|
builder: (context, isValid, child) {
|
||||||
return FlatButton(
|
return TextButton(
|
||||||
onPressed: isValid ? () => _submit(context) : null,
|
onPressed: isValid ? () => _submit(context) : null,
|
||||||
child: Text('Create'.toUpperCase()),
|
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/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/common/entry_actions.dart';
|
import 'package:aves/widgets/common/entry_actions.dart';
|
||||||
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
|
||||||
import 'package:aves/widgets/fullscreen/debug.dart';
|
import 'package:aves/widgets/fullscreen/fullscreen_debug_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
@ -60,6 +60,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
_rotate(context, entry, clockwise: true);
|
_rotate(context, entry, clockwise: true);
|
||||||
break;
|
break;
|
||||||
|
case EntryAction.flip:
|
||||||
|
_flip(context, entry);
|
||||||
|
break;
|
||||||
case EntryAction.setAs:
|
case EntryAction.setAs:
|
||||||
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
||||||
break;
|
break;
|
||||||
|
@ -76,12 +79,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
final uri = entry.uri;
|
final uri = entry.uri;
|
||||||
final mimeType = entry.mimeType;
|
final mimeType = entry.mimeType;
|
||||||
final rotationDegrees = entry.rotationDegrees;
|
final rotationDegrees = entry.rotationDegrees;
|
||||||
|
final isFlipped = entry.isFlipped;
|
||||||
final documentName = entry.bestTitle ?? 'Aves';
|
final documentName = entry.bestTitle ?? 'Aves';
|
||||||
final doc = pdf.Document(title: documentName);
|
final doc = pdf.Document(title: documentName);
|
||||||
|
|
||||||
PdfImage pdfImage;
|
PdfImage pdfImage;
|
||||||
if (entry.isSvg) {
|
if (entry.isSvg) {
|
||||||
final bytes = await ImageFileService.getImage(uri, mimeType, rotationDegrees: entry.rotationDegrees);
|
final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped);
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
if (bytes != null && bytes.isNotEmpty) {
|
||||||
final svgRoot = await svg.fromSvgBytes(bytes, uri);
|
final svgRoot = await svg.fromSvgBytes(bytes, uri);
|
||||||
final viewBox = svgRoot.viewport.viewBox;
|
final viewBox = svgRoot.viewport.viewBox;
|
||||||
|
@ -101,6 +105,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
uri: uri,
|
uri: uri,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
rotationDegrees: rotationDegrees,
|
rotationDegrees: rotationDegrees,
|
||||||
|
isFlipped: isFlipped,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -113,6 +118,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _flip(BuildContext context, ImageEntry entry) async {
|
||||||
|
if (!await checkStoragePermission(context, [entry])) return;
|
||||||
|
|
||||||
|
final success = await entry.flip();
|
||||||
|
if (!success) showFeedback(context, 'Failed');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async {
|
Future<void> _rotate(BuildContext context, ImageEntry entry, {@required bool clockwise}) async {
|
||||||
if (!await checkStoragePermission(context, [entry])) return;
|
if (!await checkStoragePermission(context, [entry])) return;
|
||||||
|
|
||||||
|
@ -127,11 +139,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
return AvesDialog(
|
return AvesDialog(
|
||||||
content: Text('Are you sure?'),
|
content: Text('Are you sure?'),
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: Text('Delete'.toUpperCase()),
|
child: Text('Delete'.toUpperCase()),
|
||||||
),
|
),
|
||||||
|
|
|
@ -28,11 +28,11 @@ mixin PermissionAwareMixin {
|
||||||
title: 'Storage Volume Access',
|
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.'),
|
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: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: Text('OK'.toUpperCase()),
|
child: Text('OK'.toUpperCase()),
|
||||||
),
|
),
|
||||||
|
|
|
@ -54,14 +54,14 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
ValueListenableBuilder<bool>(
|
ValueListenableBuilder<bool>(
|
||||||
valueListenable: _isValidNotifier,
|
valueListenable: _isValidNotifier,
|
||||||
builder: (context, isValid, child) {
|
builder: (context, isValid, child) {
|
||||||
return FlatButton(
|
return TextButton(
|
||||||
onPressed: isValid ? () => _submit(context) : null,
|
onPressed: isValid ? () => _submit(context) : null,
|
||||||
child: Text('Apply'.toUpperCase()),
|
child: Text('Apply'.toUpperCase()),
|
||||||
);
|
);
|
||||||
|
|
|
@ -48,14 +48,14 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
||||||
onSubmitted: (_) => _submit(context),
|
onSubmitted: (_) => _submit(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
ValueListenableBuilder<bool>(
|
ValueListenableBuilder<bool>(
|
||||||
valueListenable: _isValidNotifier,
|
valueListenable: _isValidNotifier,
|
||||||
builder: (context, isValid, child) {
|
builder: (context, isValid, child) {
|
||||||
return FlatButton(
|
return TextButton(
|
||||||
onPressed: isValid ? () => _submit(context) : null,
|
onPressed: isValid ? () => _submit(context) : null,
|
||||||
child: Text('Apply'.toUpperCase()),
|
child: Text('Apply'.toUpperCase()),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/filters/album.dart';
|
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_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.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/android_app_service.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/widgets/collection/collection_actions.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/entry_actions.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/widgets/common/icons.dart';
|
||||||
import 'package:aves/widgets/filter_grids/albums_page.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:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
|
@ -58,10 +63,14 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
|
|
||||||
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
|
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
|
||||||
final source = collection.source;
|
final source = collection.source;
|
||||||
|
final chipSetActionDelegate = AlbumChipSetActionDelegate(source: source);
|
||||||
final destinationAlbum = await Navigator.push(
|
final destinationAlbum = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute<String>(
|
MaterialPageRoute<String>(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
return Selector<Settings, ChipSortFactor>(
|
||||||
|
selector: (context, s) => s.albumSortFactor,
|
||||||
|
builder: (context, sortFactor, child) {
|
||||||
return FilterGridPage(
|
return FilterGridPage(
|
||||||
source: source,
|
source: source,
|
||||||
appBar: SliverAppBar(
|
appBar: SliverAppBar(
|
||||||
|
@ -81,6 +90,10 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
},
|
},
|
||||||
tooltip: 'Create album',
|
tooltip: 'Create album',
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(AIcons.sort),
|
||||||
|
onPressed: () => chipSetActionDelegate.onActionSelected(context, ChipSetAction.sort),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
floating: true,
|
floating: true,
|
||||||
),
|
),
|
||||||
|
@ -93,6 +106,8 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
|
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter)?.album),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||||
|
@ -148,11 +163,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
return AvesDialog(
|
return AvesDialog(
|
||||||
content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'),
|
content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'),
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: Text('Delete'.toUpperCase()),
|
child: Text('Delete'.toUpperCase()),
|
||||||
),
|
),
|
||||||
|
|
|
@ -38,7 +38,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
|
||||||
title: widget.title,
|
title: widget.title,
|
||||||
scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(),
|
scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(),
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
enum EntryAction {
|
enum EntryAction {
|
||||||
delete,
|
delete,
|
||||||
edit,
|
edit,
|
||||||
|
flip,
|
||||||
info,
|
info,
|
||||||
open,
|
open,
|
||||||
openMap,
|
openMap,
|
||||||
|
@ -29,8 +30,6 @@ class EntryActions {
|
||||||
EntryAction.share,
|
EntryAction.share,
|
||||||
EntryAction.delete,
|
EntryAction.delete,
|
||||||
EntryAction.rename,
|
EntryAction.rename,
|
||||||
EntryAction.rotateCCW,
|
|
||||||
EntryAction.rotateCW,
|
|
||||||
EntryAction.print,
|
EntryAction.print,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -56,9 +55,11 @@ extension ExtraEntryAction on EntryAction {
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
return 'Rename';
|
return 'Rename';
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
return 'Rotate left';
|
return 'Rotate counterclockwise';
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
return 'Rotate right';
|
return 'Rotate clockwise';
|
||||||
|
case EntryAction.flip:
|
||||||
|
return 'Flip horizontally';
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
return 'Print';
|
return 'Print';
|
||||||
case EntryAction.share:
|
case EntryAction.share:
|
||||||
|
@ -94,6 +95,8 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return AIcons.rotateLeft;
|
return AIcons.rotateLeft;
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
return AIcons.rotateRight;
|
return AIcons.rotateRight;
|
||||||
|
case EntryAction.flip:
|
||||||
|
return AIcons.flip;
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
return AIcons.print;
|
return AIcons.print;
|
||||||
case EntryAction.share:
|
case EntryAction.share:
|
||||||
|
|
|
@ -11,6 +11,7 @@ class AIcons {
|
||||||
static const IconData allCollection = Icons.collections_outlined;
|
static const IconData allCollection = Icons.collections_outlined;
|
||||||
static const IconData image = Icons.photo_outlined;
|
static const IconData image = Icons.photo_outlined;
|
||||||
static const IconData video = Icons.movie_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 vector = Icons.code_outlined;
|
||||||
|
|
||||||
static const IconData android = Icons.android;
|
static const IconData android = Icons.android;
|
||||||
|
@ -34,6 +35,7 @@ class AIcons {
|
||||||
static const IconData debug = Icons.whatshot_outlined;
|
static const IconData debug = Icons.whatshot_outlined;
|
||||||
static const IconData delete = Icons.delete_outlined;
|
static const IconData delete = Icons.delete_outlined;
|
||||||
static const IconData expand = Icons.expand_more_outlined;
|
static const IconData expand = Icons.expand_more_outlined;
|
||||||
|
static const IconData flip = Icons.flip_outlined;
|
||||||
static const IconData favourite = Icons.favorite_border;
|
static const IconData favourite = Icons.favorite_border;
|
||||||
static const IconData favouriteActive = Icons.favorite;
|
static const IconData favouriteActive = Icons.favorite;
|
||||||
static const IconData goUp = Icons.arrow_upward_outlined;
|
static const IconData goUp = Icons.arrow_upward_outlined;
|
||||||
|
|
|
@ -39,11 +39,13 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
|
||||||
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
|
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
|
||||||
try {
|
try {
|
||||||
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
|
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);
|
return await decode(bytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$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';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||||
ThumbnailProvider({
|
final ThumbnailProviderKey key;
|
||||||
@required this.entry,
|
|
||||||
this.extent = 0,
|
|
||||||
this.scale = 1,
|
|
||||||
}) : assert(entry != null),
|
|
||||||
assert(extent != null),
|
|
||||||
assert(scale != null) {
|
|
||||||
_cancellationKey = _buildKey(ImageConfiguration.empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
final ImageEntry entry;
|
ThumbnailProvider(this.key) : assert(key != null);
|
||||||
final double extent;
|
|
||||||
final double scale;
|
|
||||||
|
|
||||||
Object _cancellationKey;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) {
|
Future<ThumbnailProviderKey> obtainKey(ImageConfiguration configuration) {
|
||||||
// configuration can be empty (e.g. when obtaining key for eviction)
|
// configuration can be empty (e.g. when obtaining key for eviction)
|
||||||
// so we do not compute the target width/height here
|
// so we do not compute the target width/height here
|
||||||
// and pass it to the key, to use it later for image loading
|
// and pass it to the key, to use it later for image loading
|
||||||
return SynchronousFuture<ThumbnailProviderKey>(_buildKey(configuration));
|
return SynchronousFuture<ThumbnailProviderKey>(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
ThumbnailProviderKey _buildKey(ImageConfiguration configuration) => ThumbnailProviderKey(
|
|
||||||
entry: entry,
|
|
||||||
extent: extent,
|
|
||||||
scale: scale,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
|
ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) {
|
||||||
return MultiFrameImageStreamCompleter(
|
return MultiFrameImageStreamCompleter(
|
||||||
codec: _loadAsync(key, decode),
|
codec: _loadAsync(key, decode),
|
||||||
scale: key.scale,
|
scale: key.scale,
|
||||||
informationCollector: () sync* {
|
informationCollector: () sync* {
|
||||||
yield ErrorDescription('uri=${entry.uri}, extent=$extent');
|
yield ErrorDescription('uri=${key.uri}, extent=${key.extent}');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
|
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
|
||||||
|
var uri = key.uri;
|
||||||
|
var mimeType = key.mimeType;
|
||||||
try {
|
try {
|
||||||
final bytes = await ImageFileService.getThumbnail(key.entry, extent, extent, taskKey: _cancellationKey);
|
final bytes = await ImageFileService.getThumbnail(
|
||||||
if (bytes == null) return null;
|
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);
|
return await decode(bytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('$runtimeType _loadAsync failed with path=${entry.path}, error=$error');
|
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
|
||||||
return null;
|
throw StateError('$mimeType decoding failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
|
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
|
||||||
ImageFileService.resumeThumbnail(_cancellationKey);
|
ImageFileService.resumeThumbnail(key);
|
||||||
super.resolveStreamForKey(configuration, stream, key, handleError);
|
super.resolveStreamForKey(configuration, stream, key, handleError);
|
||||||
}
|
}
|
||||||
|
|
||||||
void pause() => ImageFileService.cancelThumbnail(_cancellationKey);
|
void pause() => ImageFileService.cancelThumbnail(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThumbnailProviderKey {
|
class ThumbnailProviderKey {
|
||||||
final ImageEntry entry;
|
final String uri, mimeType;
|
||||||
final double extent;
|
final int dateModifiedSecs, rotationDegrees;
|
||||||
final double scale;
|
final bool isFlipped;
|
||||||
|
final double extent, scale;
|
||||||
|
|
||||||
// do not access `contentId` via `entry` for hashCode and equality purposes
|
const ThumbnailProviderKey({
|
||||||
// as an entry is not constant and its contentId can change
|
@required this.uri,
|
||||||
final int contentId;
|
@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({
|
// do not store the entry as it is, because the key should be constant
|
||||||
@required this.entry,
|
// but the entry attributes may change over time
|
||||||
@required this.extent,
|
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {double extent = 0}) {
|
||||||
this.scale,
|
return ThumbnailProviderKey(
|
||||||
}) : contentId = entry.contentId;
|
uri: entry.uri,
|
||||||
|
mimeType: entry.mimeType,
|
||||||
|
dateModifiedSecs: entry.dateModifiedSecs ?? -1, // can happen in viewer mode
|
||||||
|
rotationDegrees: entry.rotationDegrees,
|
||||||
|
isFlipped: entry.isFlipped,
|
||||||
|
extent: extent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is ThumbnailProviderKey && other.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
|
@override
|
||||||
int get hashCode => hashValues(contentId, extent, scale);
|
int get hashCode => hashValues(uri, mimeType, dateModifiedSecs, rotationDegrees, isFlipped, extent, scale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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.uri,
|
||||||
@required this.mimeType,
|
@required this.mimeType,
|
||||||
@required this.rotationDegrees,
|
@required this.rotationDegrees,
|
||||||
|
@required this.isFlipped,
|
||||||
this.expectedContentLength,
|
this.expectedContentLength,
|
||||||
this.scale = 1.0,
|
this.scale = 1.0,
|
||||||
}) : assert(uri != null),
|
}) : assert(uri != null),
|
||||||
|
@ -18,6 +19,7 @@ class UriImage extends ImageProvider<UriImage> {
|
||||||
|
|
||||||
final String uri, mimeType;
|
final String uri, mimeType;
|
||||||
final int rotationDegrees, expectedContentLength;
|
final int rotationDegrees, expectedContentLength;
|
||||||
|
final bool isFlipped;
|
||||||
final double scale;
|
final double scale;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -46,7 +48,8 @@ class UriImage extends ImageProvider<UriImage> {
|
||||||
final bytes = await ImageFileService.getImage(
|
final bytes = await ImageFileService.getImage(
|
||||||
uri,
|
uri,
|
||||||
mimeType,
|
mimeType,
|
||||||
rotationDegrees: rotationDegrees,
|
rotationDegrees,
|
||||||
|
isFlipped,
|
||||||
expectedContentLength: expectedContentLength,
|
expectedContentLength: expectedContentLength,
|
||||||
onBytesReceived: (cumulative, total) {
|
onBytesReceived: (cumulative, total) {
|
||||||
chunkEvents.add(ImageChunkEvent(
|
chunkEvents.add(ImageChunkEvent(
|
||||||
|
@ -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);
|
return await decode(bytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
||||||
return null;
|
throw StateError('$mimeType decoding failed');
|
||||||
} finally {
|
} finally {
|
||||||
unawaited(chunkEvents.close());
|
unawaited(chunkEvents.close());
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ class UriPicture extends PictureProvider<UriPicture> {
|
||||||
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
|
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
|
||||||
assert(key == this);
|
assert(key == this);
|
||||||
|
|
||||||
final data = await ImageFileService.getImage(uri, mimeType);
|
final data = await ImageFileService.getImage(uri, mimeType, 0, false);
|
||||||
if (data == null || data.isEmpty) {
|
if (data == null || data.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,9 +10,9 @@ import 'package:aves/model/source/location.dart';
|
||||||
import 'package:aves/model/source/tag.dart';
|
import 'package:aves/model/source/tag.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/about/about_page.dart';
|
import 'package:aves/widgets/about/about_page.dart';
|
||||||
|
import 'package:aves/widgets/app_debug_page.dart';
|
||||||
import 'package:aves/widgets/common/aves_logo.dart';
|
import 'package:aves/widgets/common/aves_logo.dart';
|
||||||
import 'package:aves/widgets/common/icons.dart';
|
import 'package:aves/widgets/common/icons.dart';
|
||||||
import 'package:aves/widgets/debug_page.dart';
|
|
||||||
import 'package:aves/widgets/drawer/collection_tile.dart';
|
import 'package:aves/widgets/drawer/collection_tile.dart';
|
||||||
import 'package:aves/widgets/drawer/tile.dart';
|
import 'package:aves/widgets/drawer/tile.dart';
|
||||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||||
|
@ -225,7 +225,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
||||||
icon: AIcons.debug,
|
icon: AIcons.debug,
|
||||||
title: 'Debug',
|
title: 'Debug',
|
||||||
topLevel: false,
|
topLevel: false,
|
||||||
routeName: DebugPage.routeName,
|
routeName: AppDebugPage.routeName,
|
||||||
pageBuilder: (_) => DebugPage(source: source),
|
pageBuilder: (_) => AppDebugPage(source: source),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,20 +62,12 @@ class AlbumListPage extends StatelessWidget {
|
||||||
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
|
final pinned = settings.pinnedFilters.whereType<AlbumFilter>().map((f) => f.album);
|
||||||
final entriesByDate = source.sortedEntriesForFilterList;
|
final entriesByDate = source.sortedEntriesForFilterList;
|
||||||
|
|
||||||
switch (settings.albumSortFactor) {
|
// albums are initially sorted by name at the source level
|
||||||
case ChipSortFactor.date:
|
var sortedAlbums = source.sortedAlbums;
|
||||||
final allAlbumMapEntries = source.sortedAlbums.map((album) => MapEntry(
|
|
||||||
album,
|
if (settings.albumSortFactor == ChipSortFactor.name) {
|
||||||
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>[];
|
final pinnedAlbums = <String>[], regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||||
for (var album in source.sortedAlbums) {
|
for (var album in sortedAlbums) {
|
||||||
if (pinned.contains(album)) {
|
if (pinned.contains(album)) {
|
||||||
pinnedAlbums.add(album);
|
pinnedAlbums.add(album);
|
||||||
} else {
|
} else {
|
||||||
|
@ -99,5 +91,27 @@ class AlbumListPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
return AvesDialog(
|
||||||
content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'),
|
content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'),
|
||||||
actions: [
|
actions: [
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text('Cancel'.toUpperCase()),
|
child: Text('Cancel'.toUpperCase()),
|
||||||
),
|
),
|
||||||
FlatButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: Text('Delete'.toUpperCase()),
|
child: Text('Delete'.toUpperCase()),
|
||||||
),
|
),
|
||||||
|
|
|
@ -46,6 +46,7 @@ abstract class ChipSetActionDelegate {
|
||||||
options: {
|
options: {
|
||||||
ChipSortFactor.date: 'By date',
|
ChipSortFactor.date: 'By date',
|
||||||
ChipSortFactor.name: 'By name',
|
ChipSortFactor.name: 'By name',
|
||||||
|
ChipSortFactor.count: 'By entry count',
|
||||||
},
|
},
|
||||||
title: 'Sort',
|
title: 'Sort',
|
||||||
),
|
),
|
||||||
|
|
|
@ -153,6 +153,11 @@ class FilterNavigationPage extends StatelessWidget {
|
||||||
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
||||||
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
|
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 {
|
class FilterGridPage extends StatelessWidget {
|
||||||
|
|
|
@ -39,7 +39,7 @@ class CountryListPage extends StatelessWidget {
|
||||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||||
],
|
],
|
||||||
filterEntries: _getCountryEntries(),
|
filterEntries: _getCountryEntries(),
|
||||||
filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
|
filterBuilder: _buildFilter,
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
icon: AIcons.location,
|
icon: AIcons.location,
|
||||||
text: 'No countries',
|
text: 'No countries',
|
||||||
|
@ -50,12 +50,22 @@ class CountryListPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CollectionFilter _buildFilter(String location) => LocationFilter(LocationLevel.country, location);
|
||||||
|
|
||||||
Map<String, ImageEntry> _getCountryEntries() {
|
Map<String, ImageEntry> _getCountryEntries() {
|
||||||
final pinned = settings.pinnedFilters.whereType<LocationFilter>().map((f) => f.countryNameAndCode);
|
final pinned = settings.pinnedFilters.whereType<LocationFilter>().map((f) => f.countryNameAndCode);
|
||||||
|
|
||||||
final entriesByDate = source.sortedEntriesForFilterList;
|
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 locatedEntries = entriesByDate.where((entry) => entry.isLocated);
|
||||||
final allMapEntries = source.sortedCountries.map((countryNameAndCode) {
|
final allMapEntries = sortedCountries.map((countryNameAndCode) {
|
||||||
final split = countryNameAndCode.split(LocationFilter.locationSeparator);
|
final split = countryNameAndCode.split(LocationFilter.locationSeparator);
|
||||||
ImageEntry entry;
|
ImageEntry entry;
|
||||||
if (split.length > 1) {
|
if (split.length > 1) {
|
||||||
|
@ -63,21 +73,16 @@ class CountryListPage extends StatelessWidget {
|
||||||
entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null);
|
entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null);
|
||||||
}
|
}
|
||||||
return MapEntry(countryNameAndCode, entry);
|
return MapEntry(countryNameAndCode, entry);
|
||||||
}).toList();
|
});
|
||||||
|
|
||||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||||
final pinnedMapEntries = (byPin[true] ?? []);
|
final pinnedMapEntries = (byPin[true] ?? []);
|
||||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||||
|
|
||||||
switch (settings.countrySortFactor) {
|
if (settings.countrySortFactor == ChipSortFactor.date) {
|
||||||
case ChipSortFactor.date:
|
|
||||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||||
break;
|
|
||||||
case ChipSortFactor.name:
|
|
||||||
// already sorted by name at the source level
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,7 @@ class TagListPage extends StatelessWidget {
|
||||||
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
|
||||||
],
|
],
|
||||||
filterEntries: _getTagEntries(),
|
filterEntries: _getTagEntries(),
|
||||||
filterBuilder: (s) => TagFilter(s),
|
filterBuilder: _buildFilter,
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
icon: AIcons.tag,
|
icon: AIcons.tag,
|
||||||
text: 'No tags',
|
text: 'No tags',
|
||||||
|
@ -50,30 +50,33 @@ class TagListPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CollectionFilter _buildFilter(String tag) => TagFilter(tag);
|
||||||
|
|
||||||
Map<String, ImageEntry> _getTagEntries() {
|
Map<String, ImageEntry> _getTagEntries() {
|
||||||
final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag);
|
final pinned = settings.pinnedFilters.whereType<TagFilter>().map((f) => f.tag);
|
||||||
|
|
||||||
final entriesByDate = source.sortedEntriesForFilterList;
|
final entriesByDate = source.sortedEntriesForFilterList;
|
||||||
final allMapEntries = source.sortedTags
|
// tags are initially sorted by name at the source level
|
||||||
.map((tag) => MapEntry(
|
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,
|
tag,
|
||||||
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
|
entriesByDate.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
|
||||||
))
|
));
|
||||||
.toList();
|
|
||||||
|
|
||||||
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
final byPin = groupBy<MapEntry<String, ImageEntry>, bool>(allMapEntries, (e) => pinned.contains(e.key));
|
||||||
final pinnedMapEntries = (byPin[true] ?? []);
|
final pinnedMapEntries = (byPin[true] ?? []);
|
||||||
final unpinnedMapEntries = (byPin[false] ?? []);
|
final unpinnedMapEntries = (byPin[false] ?? []);
|
||||||
|
|
||||||
switch (settings.tagSortFactor) {
|
if (settings.tagSortFactor == ChipSortFactor.date) {
|
||||||
case ChipSortFactor.date:
|
|
||||||
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
pinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||||
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
unpinnedMapEntries.sort(FilterNavigationPage.compareChipsByDate);
|
||||||
break;
|
|
||||||
case ChipSortFactor.name:
|
|
||||||
// already sorted by name at the source level
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Map.fromEntries([...pinnedMapEntries, ...unpinnedMapEntries]);
|
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 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/filters/filters.dart';
|
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/utils/durations.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.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/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/image_page.dart';
|
||||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||||
import 'package:aves/widgets/fullscreen/overlay/bottom.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)
|
// when the entry image itself changed (e.g. after rotation)
|
||||||
void _onImageChanged() async {
|
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
|
// rebuild to refresh the Image inside ImagePage
|
||||||
setState(() {});
|
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,
|
// 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.
|
// 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
|
// this loading builder shows a transition image until the final image is ready
|
||||||
// if the image is already in the cache it will show the final image, otherwise the thumbnail
|
// if the image is already in the cache it will show the final image, otherwise the thumbnail
|
||||||
// in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image
|
// 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,
|
uri: entry.uri,
|
||||||
mimeType: entry.mimeType,
|
mimeType: entry.mimeType,
|
||||||
rotationDegrees: entry.rotationDegrees,
|
rotationDegrees: entry.rotationDegrees,
|
||||||
|
isFlipped: entry.isFlipped,
|
||||||
expectedContentLength: entry.sizeBytes,
|
expectedContentLength: entry.sizeBytes,
|
||||||
);
|
);
|
||||||
child = PhotoView(
|
child = PhotoView(
|
||||||
// key includes size and orientation to refresh when the image is rotated
|
// key includes size and orientation to refresh when the image is rotated
|
||||||
key: ValueKey('${entry.rotationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
|
key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'),
|
||||||
imageProvider: uriImage,
|
imageProvider: uriImage,
|
||||||
// when the full image is ready, we use it in the `loadingBuilder`
|
// when the full image is ready, we use it in the `loadingBuilder`
|
||||||
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
|
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation
|
||||||
|
|
|
@ -33,10 +33,10 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
|
|
||||||
bool get isVisible => widget.visibleNotifier.value;
|
bool get isVisible => widget.visibleNotifier.value;
|
||||||
|
|
||||||
// directory names from metadata-extractor
|
// special directory names
|
||||||
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
||||||
static const xmpDirectory = 'XMP'; // 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -87,13 +87,43 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength);
|
return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength);
|
||||||
}
|
}
|
||||||
final dir = directoriesWithTitle[index - 1 - untitledDirectoryCount];
|
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(
|
return AvesExpansionTile(
|
||||||
title: dir.name,
|
title: dir.name,
|
||||||
expandedNotifier: _expandedDirectoryNotifier,
|
expandedNotifier: _expandedDirectoryNotifier,
|
||||||
children: [
|
children: [
|
||||||
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry),
|
if (prefixChildren.isNotEmpty)
|
||||||
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry),
|
Align(
|
||||||
if (dir.name == videoDirectory) MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry),
|
alignment: AlignmentDirectional.topStart,
|
||||||
|
child: Wrap(children: prefixChildren),
|
||||||
|
),
|
||||||
|
if (thumbnail != null) thumbnail,
|
||||||
Container(
|
Container(
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
|
|
|
@ -50,19 +50,15 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
|
||||||
future: _loader,
|
future: _loader,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
|
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
|
||||||
final turns = (entry.rotationDegrees / 90).round();
|
|
||||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
return Container(
|
return Container(
|
||||||
alignment: AlignmentDirectional.topStart,
|
alignment: AlignmentDirectional.topStart,
|
||||||
padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4),
|
padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
children: snapshot.data.map((bytes) {
|
children: snapshot.data.map((bytes) {
|
||||||
return RotatedBox(
|
return Image.memory(
|
||||||
quarterTurns: turns,
|
|
||||||
child: Image.memory(
|
|
||||||
bytes,
|
bytes,
|
||||||
scale: devicePixelRatio,
|
scale: devicePixelRatio,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|
|
@ -82,7 +82,8 @@ class FullscreenTopOverlay extends StatelessWidget {
|
||||||
return entry.canEdit;
|
return entry.canEdit;
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
return entry.canRotate;
|
case EntryAction.flip:
|
||||||
|
return entry.canRotateAndFlip;
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
return entry.canPrint;
|
return entry.canPrint;
|
||||||
case EntryAction.openMap:
|
case EntryAction.openMap:
|
||||||
|
@ -136,6 +137,7 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
key: Key('entry-menu-button'),
|
key: Key('entry-menu-button'),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
...inAppActions.map(_buildPopupMenuItem),
|
...inAppActions.map(_buildPopupMenuItem),
|
||||||
|
if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(),
|
||||||
PopupMenuDivider(),
|
PopupMenuDivider(),
|
||||||
...externalAppActions.map(_buildPopupMenuItem),
|
...externalAppActions.map(_buildPopupMenuItem),
|
||||||
if (kDebugMode) ...[
|
if (kDebugMode) ...[
|
||||||
|
@ -166,6 +168,7 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
|
case EntryAction.flip:
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
child = IconButton(
|
child = IconButton(
|
||||||
icon: Icon(action.getIcon()),
|
icon: Icon(action.getIcon()),
|
||||||
|
@ -207,6 +210,7 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
case EntryAction.rotateCCW:
|
case EntryAction.rotateCCW:
|
||||||
case EntryAction.rotateCW:
|
case EntryAction.rotateCW:
|
||||||
|
case EntryAction.flip:
|
||||||
case EntryAction.print:
|
case EntryAction.print:
|
||||||
case EntryAction.debug:
|
case EntryAction.debug:
|
||||||
child = MenuRow(text: action.getText(), icon: action.getIcon());
|
child = MenuRow(text: action.getText(), icon: action.getIcon());
|
||||||
|
@ -224,6 +228,40 @@ class _TopOverlayRow extends StatelessWidget {
|
||||||
child: child,
|
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 {
|
class _FavouriteToggler extends StatefulWidget {
|
||||||
|
|
|
@ -80,7 +80,7 @@ class AvesVideoState extends State<AvesVideo> {
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
);
|
);
|
||||||
|
|
||||||
final degree = entry.catalogMetadata?.rotationDegrees ?? 0;
|
final degree = entry.rotationDegrees ?? 0;
|
||||||
if (degree != 0) {
|
if (degree != 0) {
|
||||||
child = RotatedBox(
|
child = RotatedBox(
|
||||||
quarterTurns: degree ~/ 90,
|
quarterTurns: degree ~/ 90,
|
||||||
|
@ -102,6 +102,7 @@ class AvesVideoState extends State<AvesVideo> {
|
||||||
uri: entry.uri,
|
uri: entry.uri,
|
||||||
mimeType: entry.mimeType,
|
mimeType: entry.mimeType,
|
||||||
rotationDegrees: entry.rotationDegrees,
|
rotationDegrees: entry.rotationDegrees,
|
||||||
|
isFlipped: entry.isFlipped,
|
||||||
expectedContentLength: entry.sizeBytes,
|
expectedContentLength: entry.sizeBytes,
|
||||||
),
|
),
|
||||||
width: entry.width.toDouble(),
|
width: entry.width.toDouble(),
|
||||||
|
|
|
@ -46,13 +46,13 @@ class _GrantedDirectoriesState extends State<GrantedDirectories> {
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text(path, style: textTheme.caption)),
|
Expanded(child: Text(path, style: textTheme.caption)),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
OutlineButton(
|
OutlinedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await AndroidFileService.revokeDirectoryAccess(path);
|
await AndroidFileService.revokeDirectoryAccess(path);
|
||||||
_load();
|
_load();
|
||||||
setState(() {});
|
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 videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/')));
|
||||||
final mimeDonuts = Wrap(
|
final mimeDonuts = Wrap(
|
||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes),
|
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes),
|
||||||
_buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'video', other: 'videos'), videoByMimeTypes),
|
_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'),
|
key: Key('continue-button'),
|
||||||
child: Text('Continue'),
|
child: Text('Continue'),
|
||||||
onPressed: _hasAcceptedTerms
|
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.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# 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):
|
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
||||||
# - does not support content URIs (by default, but trivial by fork)
|
# - does not support content URIs (by default, but trivial by fork)
|
||||||
|
|