catalogue mime type, platform: distinguish source entry from dart call entry, move/copy alternate method for older devices

This commit is contained in:
Thibault Deckers 2020-06-21 21:14:15 +09:00
parent 3af37951fc
commit e7b48ad136
19 changed files with 305 additions and 133 deletions

View file

@ -27,7 +27,7 @@ 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.ImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
@ -35,11 +35,11 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class); private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class);
static class Params { static class Params {
ImageEntry entry; AvesImageEntry entry;
Integer width, height, defaultSize; Integer width, height, defaultSize;
MethodChannel.Result result; MethodChannel.Result result;
Params(ImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) { Params(AvesImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
this.entry = entry; this.entry = entry;
this.width = width; this.width = width;
this.height = height; this.height = height;
@ -116,7 +116,7 @@ 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 {
ImageEntry entry = params.entry; AvesImageEntry entry = params.entry;
Integer width = params.width; Integer width = params.width;
Integer height = params.height; Integer height = params.height;
// Log.d(LOG_TAG, "getThumbnailBytesByResolver width=" + width + ", path=" + entry.path); // Log.d(LOG_TAG, "getThumbnailBytesByResolver width=" + width + ", path=" + entry.path);
@ -126,7 +126,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
} }
private Bitmap getThumbnailBytesByMediaStore(Params params) { private Bitmap getThumbnailBytesByMediaStore(Params params) {
ImageEntry entry = params.entry; AvesImageEntry entry = params.entry;
long contentId = ContentUris.parseId(entry.uri); long contentId = ContentUris.parseId(entry.uri);
ContentResolver resolver = activity.getContentResolver(); ContentResolver resolver = activity.getContentResolver();
@ -148,7 +148,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
} }
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException { private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
ImageEntry entry = params.entry; AvesImageEntry entry = params.entry;
int width = params.width; int width = params.width;
int height = params.height; int height = params.height;
// Log.d(LOG_TAG, "getThumbnailByGlide width=" + width + ", path=" + entry.path); // Log.d(LOG_TAG, "getThumbnailByGlide width=" + width + ", path=" + entry.path);

View file

@ -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.ImageEntry; 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.model.provider.MediaStoreImageProvider; import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
@ -80,7 +80,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);
ImageEntry entry = new ImageEntry(entryMap); AvesImageEntry entry = new AvesImageEntry(entryMap);
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result)); new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result));
} }

View file

@ -26,6 +26,7 @@ import com.drew.metadata.Tag;
import com.drew.metadata.exif.ExifIFD0Directory; import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifSubIFDDirectory; import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.GpsDirectory; import com.drew.metadata.exif.GpsDirectory;
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;
@ -51,6 +52,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/metadata"; public static final String CHANNEL = "deckers.thibault/aves/metadata";
// catalog metadata // catalog metadata
private static final String KEY_MIME_TYPE = "mimeType";
private static final String KEY_DATE_MILLIS = "dateMillis"; private static final String KEY_DATE_MILLIS = "dateMillis";
private static final String KEY_IS_ANIMATED = "isAnimated"; private static final String KEY_IS_ANIMATED = "isAnimated";
private static final String KEY_LATITUDE = "latitude"; private static final String KEY_LATITUDE = "latitude";
@ -111,6 +113,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
} }
private void getAllMetadata(MethodCall call, MethodChannel.Result result) { private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
String uri = call.argument("uri"); String uri = call.argument("uri");
Map<String, Map<String, String>> metadataMap = new HashMap<>(); Map<String, Map<String, String>> metadataMap = new HashMap<>();
@ -150,9 +153,11 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Log.w(LOG_TAG, "failed to get video metadata by ImageMetadataReader for uri=" + uri, e); Log.w(LOG_TAG, "failed to get video metadata by ImageMetadataReader for uri=" + uri, e);
} }
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri); if (isVideo(mimeType)) {
if (!videoDir.isEmpty()) { Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
metadataMap.put("Video", videoDir); if (!videoDir.isEmpty()) {
metadataMap.put("Video", videoDir);
}
} }
if (metadataMap.isEmpty()) { if (metadataMap.isEmpty()) {
@ -215,6 +220,18 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) { try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
// File type
FileTypeDirectory fileTypeDir = metadata.getFirstDirectoryOfType(FileTypeDirectory.class);
if (fileTypeDir != null) {
// the reported `mimeType` (e.g. from Media Store) is sometimes incorrect
// file extension is unreliable
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (fileTypeDir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
metadataMap.put(KEY_MIME_TYPE, fileTypeDir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE));
}
}
// EXIF // EXIF
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {

View file

@ -13,7 +13,7 @@ import java.util.Map;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import deckers.thibault.aves.model.ImageEntry; 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.Utils;
@ -94,7 +94,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
String destinationDir = (String) argMap.get("destinationPath"); String destinationDir = (String) argMap.get("destinationPath");
if (copy == null || destinationDir == null) return; if (copy == null || destinationDir == null) return;
List<ImageEntry> entries = entryMapList.stream().map(ImageEntry::new).collect(Collectors.toList()); List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList());
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
@Override @Override
public void onSuccess(Map<String, Object> fields) { public void onSuccess(Map<String, Object> fields) {

View file

@ -0,0 +1,41 @@
package deckers.thibault.aves.model;
import android.net.Uri;
import androidx.annotation.Nullable;
import java.util.Map;
import deckers.thibault.aves.utils.MimeTypes;
public class AvesImageEntry {
public Uri uri; // content or file URI
public String path; // best effort to get local path
public String mimeType;
@Nullable
public Integer width, height, orientationDegrees;
@Nullable
public Long dateModifiedSecs;
public AvesImageEntry(Map<String, Object> map) {
this.uri = Uri.parse((String) map.get("uri"));
this.path = (String) map.get("path");
this.mimeType = (String) map.get("mimeType");
this.width = (int) map.get("width");
this.height = (int) map.get("height");
this.orientationDegrees = (int) map.get("orientationDegrees");
this.dateModifiedSecs = toLong(map.get("dateModifiedSecs"));
}
public boolean isVideo() {
return mimeType.startsWith(MimeTypes.VIDEO);
}
// convenience method
private static Long toLong(Object o) {
if (o == null) return null;
if (o instanceof Integer) return Long.valueOf((Integer) o);
return (long) o;
}
}

View file

@ -32,11 +32,11 @@ import deckers.thibault.aves.utils.StorageUtils;
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode; import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
public class ImageEntry { public class SourceImageEntry {
public Uri uri; // content or file URI public Uri uri; // content or file URI
public String path; // best effort to get local path public String path; // best effort to get local path
public String mimeType; public String sourceMimeType;
@Nullable @Nullable
public String title; public String title;
@Nullable @Nullable
@ -50,13 +50,13 @@ public class ImageEntry {
@Nullable @Nullable
private Long durationMillis; private Long durationMillis;
public ImageEntry() { public SourceImageEntry() {
} }
public ImageEntry(Map map) { public SourceImageEntry(Map<String, Object> map) {
this.uri = Uri.parse((String) map.get("uri")); this.uri = Uri.parse((String) map.get("uri"));
this.path = (String) map.get("path"); this.path = (String) map.get("path");
this.mimeType = (String) map.get("mimeType"); this.sourceMimeType = (String) map.get("sourceMimeType");
this.width = (int) map.get("width"); this.width = (int) map.get("width");
this.height = (int) map.get("height"); this.height = (int) map.get("height");
this.orientationDegrees = (int) map.get("orientationDegrees"); this.orientationDegrees = (int) map.get("orientationDegrees");
@ -71,7 +71,7 @@ public class ImageEntry {
return new HashMap<String, Object>() {{ return new HashMap<String, Object>() {{
put("uri", uri.toString()); put("uri", uri.toString());
put("path", path); put("path", path);
put("mimeType", mimeType); put("sourceMimeType", sourceMimeType);
put("width", width); put("width", width);
put("height", height); put("height", height);
put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0); put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0);
@ -106,22 +106,22 @@ public class ImageEntry {
} }
private boolean isImage() { private boolean isImage() {
return mimeType.startsWith(MimeTypes.IMAGE); return sourceMimeType.startsWith(MimeTypes.IMAGE);
} }
public boolean isSvg() { public boolean isSvg() {
return mimeType.equals(MimeTypes.SVG); return sourceMimeType.equals(MimeTypes.SVG);
} }
public boolean isVideo() { private boolean isVideo() {
return mimeType.startsWith(MimeTypes.VIDEO); return sourceMimeType.startsWith(MimeTypes.VIDEO);
} }
// metadata retrieval // metadata retrieval
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration // finds: width, height, orientation/rotation, date, title, duration
public ImageEntry fillPreCatalogMetadata(Context context) { public SourceImageEntry fillPreCatalogMetadata(Context context) {
fillByMediaMetadataRetriever(context); fillByMediaMetadataRetriever(context);
if (hasSize() && (!isVideo() || hasDuration())) return this; if (hasSize() && (!isVideo() || hasDuration())) return this;
fillByMetadataExtractor(context); fillByMetadataExtractor(context);
@ -183,12 +183,12 @@ public class ImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation, date // finds: width, height, orientation, date
private void fillByMetadataExtractor(Context context) { private void fillByMetadataExtractor(Context context) {
if (MimeTypes.SVG.equals(mimeType)) return; if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is); Metadata metadata = ImageMetadataReader.readMetadata(is);
if (MimeTypes.JPEG.equals(mimeType)) { if (MimeTypes.JPEG.equals(sourceMimeType)) {
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class); JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
if (jpegDir != null) { if (jpegDir != null) {
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) { if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
@ -207,7 +207,7 @@ public class ImageEntry {
sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime(); sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
} }
} }
} else if (MimeTypes.MP4.equals(mimeType)) { } else if (MimeTypes.MP4.equals(sourceMimeType)) {
Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class); Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class);
if (mp4VideoDir != null) { if (mp4VideoDir != null) {
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) { if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
@ -223,7 +223,7 @@ public class ImageEntry {
durationMillis = mp4Dir.getLong(Mp4Directory.TAG_DURATION); durationMillis = mp4Dir.getLong(Mp4Directory.TAG_DURATION);
} }
} }
} else if (MimeTypes.AVI.equals(mimeType)) { } else if (MimeTypes.AVI.equals(sourceMimeType)) {
AviDirectory aviDir = metadata.getFirstDirectoryOfType(AviDirectory.class); AviDirectory aviDir = metadata.getFirstDirectoryOfType(AviDirectory.class);
if (aviDir != null) { if (aviDir != null) {
if (aviDir.containsTag(AviDirectory.TAG_WIDTH)) { if (aviDir.containsTag(AviDirectory.TAG_WIDTH)) {
@ -245,7 +245,7 @@ public class ImageEntry {
// expects entry with: uri // expects entry with: uri
// finds: width, height // finds: width, height
private void fillByBitmapDecode(Context context) { private void fillByBitmapDecode(Context context) {
if (MimeTypes.SVG.equals(mimeType)) return; if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
BitmapFactory.Options options = new BitmapFactory.Options(); BitmapFactory.Options options = new BitmapFactory.Options();

View file

@ -5,14 +5,14 @@ import android.net.Uri;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import deckers.thibault.aves.model.ImageEntry; 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) {
ImageEntry entry = new ImageEntry(); SourceImageEntry entry = new SourceImageEntry();
entry.uri = uri; entry.uri = uri;
entry.mimeType = mimeType; entry.sourceMimeType = mimeType;
entry.fillPreCatalogMetadata(context); entry.fillPreCatalogMetadata(context);
if (entry.hasSize() || entry.isSvg()) { if (entry.hasSize() || entry.isSvg()) {

View file

@ -7,15 +7,15 @@ import androidx.annotation.NonNull;
import java.io.File; import java.io.File;
import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.FileUtils; 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) {
ImageEntry entry = new ImageEntry(); SourceImageEntry entry = new SourceImageEntry();
entry.uri = uri; entry.uri = uri;
entry.mimeType = mimeType; entry.sourceMimeType = mimeType;
String path = FileUtils.getPathFromUri(context, uri); String path = FileUtils.getPathFromUri(context, uri);
if (path != null) { if (path != null) {

View file

@ -17,14 +17,9 @@ import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface; import androidx.exifinterface.media.ExifInterface;
import com.commonsware.cwac.document.DocumentFileCompat; import com.commonsware.cwac.document.DocumentFileCompat;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.file.FileTypeDirectory;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
@ -32,12 +27,11 @@ import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
@ -65,7 +59,7 @@ public abstract class ImageProvider {
return Futures.immediateFailedFuture(new UnsupportedOperationException()); return Futures.immediateFailedFuture(new UnsupportedOperationException());
} }
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<ImageEntry> entries, @NonNull ImageOpCallback callback) { public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException()); callback.onFailure(new UnsupportedOperationException());
} }
@ -132,34 +126,8 @@ public abstract class ImageProvider {
}); });
} }
// file extension is unreliable
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
// so we check with `metadata-extractor`
@Nullable
private String getMimeType(@NonNull final Context context, @NonNull final Uri uri) {
try (InputStream is = context.getContentResolver().openInputStream(uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
FileTypeDirectory fileTypeDir = metadata.getFirstDirectoryOfType(FileTypeDirectory.class);
if (fileTypeDir != null) {
if (fileTypeDir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
return fileTypeDir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE);
}
}
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to get mime type from metadata for uri=" + uri, e);
}
return null;
}
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
// the reported `mimeType` (e.g. from Media Store) is sometimes incorrect switch (mimeType) {
// so we retrieve it again from the file metadata
String actualMimeType = getMimeType(activity, uri);
if (actualMimeType == null) {
actualMimeType = mimeType;
}
switch (actualMimeType) {
case MimeTypes.JPEG: case MimeTypes.JPEG:
rotateJpeg(activity, path, uri, clockwise, callback); rotateJpeg(activity, path, uri, clockwise, callback);
break; break;
@ -167,7 +135,7 @@ public abstract class ImageProvider {
rotatePng(activity, path, uri, clockwise, callback); rotatePng(activity, path, uri, clockwise, callback);
break; break;
default: default:
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + actualMimeType)); callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
} }
} }

View file

@ -6,6 +6,7 @@ import android.content.ContentUris;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.media.MediaScannerConnection;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Handler; import android.os.Handler;
@ -31,7 +32,8 @@ import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.PermissionManager;
@ -165,7 +167,7 @@ public class MediaStoreImageProvider extends ImageProvider {
Map<String, Object> entryMap = new HashMap<String, Object>() {{ Map<String, Object> entryMap = new HashMap<String, Object>() {{
put("uri", itemUri.toString()); put("uri", itemUri.toString());
put("path", path); put("path", path);
put("mimeType", mimeType); put("sourceMimeType", mimeType);
put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0); put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
put("sizeBytes", cursor.getLong(sizeColumn)); put("sizeBytes", cursor.getLong(sizeColumn));
put("title", cursor.getString(titleColumn)); put("title", cursor.getString(titleColumn));
@ -181,7 +183,7 @@ public class MediaStoreImageProvider extends ImageProvider {
if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) { if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) {
// some images are incorrectly registered in the Media Store, // some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation // they are valid but miss some attributes, such as width, height, orientation
ImageEntry entry = new ImageEntry(entryMap).fillPreCatalogMetadata(context); SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context);
entryMap = entry.toMap(); entryMap = entry.toMap();
width = entry.width != null ? entry.width : 0; width = entry.width != null ? entry.width : 0;
height = entry.height != null ? entry.height : 0; height = entry.height != null ? entry.height : 0;
@ -235,6 +237,7 @@ public class MediaStoreImageProvider extends ImageProvider {
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri); DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri);
if (df != null && df.delete()) { if (df != null && df.delete()) {
future.set(null); future.set(null);
} else {
future.setException(new Exception("failed to delete file with df=" + df)); future.setException(new Exception("failed to delete file with df=" + df));
} }
} catch (FileNotFoundException e) { } catch (FileNotFoundException e) {
@ -253,16 +256,14 @@ public class MediaStoreImageProvider extends ImageProvider {
Log.e(LOG_TAG, "failed to delete entry", e); Log.e(LOG_TAG, "failed to delete entry", e);
future.setException(e); future.setException(e);
} }
return future; return future;
} }
@Override private String getVolumeName(final Activity activity, String path) {
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<ImageEntry> entries, ImageOpCallback callback) {
String volumeName = "external"; String volumeName = "external";
StorageManager sm = activity.getSystemService(StorageManager.class); StorageManager sm = activity.getSystemService(StorageManager.class);
if (sm != null) { if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(destinationDir)); StorageVolume volume = sm.getStorageVolume(new File(path));
if (volume != null && !volume.isPrimary()) { if (volume != null && !volume.isPrimary()) {
String uuid = volume.getUuid(); String uuid = volume.getUuid();
if (uuid != null) { if (uuid != null) {
@ -272,13 +273,20 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
} }
} }
return volumeName;
}
if (!StorageUtils.createDirectoryIfAbsent(activity, destinationDir)) { @Override
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir);
if (destinationDirDocFile == null) {
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir)); callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
return; return;
} }
for (ImageEntry entry : entries) { String volumeName = null;
for (AvesImageEntry entry : entries) {
Uri sourceUri = entry.uri; Uri sourceUri = entry.uri;
String sourcePath = entry.path; String sourcePath = entry.path;
String mimeType = entry.mimeType; String mimeType = entry.mimeType;
@ -287,7 +295,16 @@ public class MediaStoreImageProvider extends ImageProvider {
put("uri", sourceUri.toString()); put("uri", sourceUri.toString());
}}; }};
try { try {
Map<String, Object> newFields = moveSingle(activity, volumeName, sourcePath, sourceUri, destinationDir, mimeType, copy).get(); ListenableFuture<Map<String, Object>> newFieldsFuture;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (volumeName == null) {
volumeName = getVolumeName(activity, destinationDir);
}
newFieldsFuture = moveSingleByMediaStoreInsert(activity, sourcePath, sourceUri, destinationDir, volumeName, mimeType, copy);
} else {
newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
}
Map<String, Object> newFields = newFieldsFuture.get();
result.put("success", true); result.put("success", true);
result.put("newFields", newFields); result.put("newFields", newFields);
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
@ -298,17 +315,19 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
} }
private ListenableFuture<Map<String, Object>> moveSingle(final Activity activity, final String volumeName, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) { // We can create an item via `ContentResolver.insert()` with a path, and retrieve its content URI, but:
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
// - there is no documentation regarding support for usage with removable storage
private ListenableFuture<Map<String, Object>> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final String volumeName, final String mimeType, final boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create(); SettableFuture<Map<String, Object>> future = SettableFuture.create();
try { try {
String destinationPath = destinationDir + File.separator + new File(sourcePath).getName();
// from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device) // from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device)
// from API 26, retrieve document URI from mediastore URI with `MediaStore.getDocumentUri(...)` // from API 26, retrieve document URI from mediastore URI with `MediaStore.getDocumentUri(...)`
// DocumentFile.getUri() is same as original uri: "content://media/external/images/media/58457"
// DocumentFile.getParentFile() is null without picking a tree first
// DocumentsContract.copyDocument() and moveDocument() need parent doc uri
String destinationPath = destinationDir + File.separator + new File(sourcePath).getName();
ContentValues contentValues = new ContentValues(); ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath); contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
@ -324,10 +343,12 @@ public class MediaStoreImageProvider extends ImageProvider {
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri); DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
source.copyTo(destination); source.copyTo(destination);
boolean deletedSource = false;
if (!copy) { if (!copy) {
// delete original entry // delete original entry
try { try {
delete(activity, sourcePath, sourceUri).get(); delete(activity, sourcePath, sourceUri).get();
deletedSource = true;
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e); Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e);
} }
@ -337,6 +358,7 @@ public class MediaStoreImageProvider extends ImageProvider {
newFields.put("uri", destinationUri.toString()); newFields.put("uri", destinationUri.toString());
newFields.put("contentId", ContentUris.parseId(destinationUri)); newFields.put("contentId", ContentUris.parseId(destinationUri));
newFields.put("path", destinationPath); newFields.put("path", destinationPath);
newFields.put("deletedSource", deletedSource);
future.set(newFields); future.set(newFields);
} }
} catch (Exception e) { } catch (Exception e) {
@ -347,6 +369,80 @@ public class MediaStoreImageProvider extends ImageProvider {
return future; return future;
} }
// We can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI
// - there is no control on the filename (derived from the display name, MIME type)
private ListenableFuture<Map<String, Object>> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create();
try {
// TODO TLAD more robust `destinationPath`, as it could be broken:
// - if a file with the same name already exists, and the name gets appended ` (1)`
// - if the original extension does not match the appended extension from the provided MIME type
final String fileName = new File(sourcePath).getName();
final String displayName = fileName.replaceFirst("[.][^.]+$", "");
String destinationPath = destinationDir + File.separator + fileName;
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, displayName);
DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.getUri());
// `DocumentFile.getParentFile()` is null without picking a tree first
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
source.copyTo(destinationDocFile);
boolean deletedSource = false;
if (!copy) {
// delete original entry
try {
delete(activity, sourcePath, sourceUri).get();
deletedSource = true;
} catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e);
}
}
boolean finalDeletedSource = deletedSource;
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
Map<String, Object> newFields = new HashMap<>();
if (newUri != null) {
// we retrieve updated fields as the moved file became a new entry in the Media Store
String[] projection = {MediaStore.MediaColumns._ID};
try {
Cursor cursor = activity.getContentResolver().query(newUri, projection, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
newFields.put("uri", newUri.toString());
newFields.put("contentId", contentId);
newFields.put("path", destinationPath);
newFields.put("deletedSource", finalDeletedSource);
}
cursor.close();
}
} catch (Exception e) {
future.setException(e);
return;
}
}
if (newFields.isEmpty()) {
future.setException(new Exception("failed to scan moved item at path=" + destinationPath));
} else {
future.set(newFields);
}
});
} catch (Exception e) {
Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e);
future.setException(e);
}
return future;
}
public interface NewEntryHandler { public interface NewEntryHandler {
void handleEntry(Map<String, Object> entry); void handleEntry(Map<String, Object> entry);
} }

View file

@ -174,6 +174,16 @@ public class StorageUtils {
}; };
} }
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
static private DocumentFileCompat findFileIgnoreCase(DocumentFileCompat documentFile, String displayName) {
for (DocumentFileCompat doc : documentFile.listFiles()) {
if (displayName.equalsIgnoreCase(doc.getName())) {
return doc;
}
}
return null;
}
private static Optional<DocumentFileCompat> getSdCardDocumentFile(Context context, Uri rootTreeUri, String[] storageVolumeRoots, String path) { private static Optional<DocumentFileCompat> getSdCardDocumentFile(Context context, Uri rootTreeUri, String[] storageVolumeRoots, String path) {
if (rootTreeUri == null || storageVolumeRoots == null || path == null) { if (rootTreeUri == null || storageVolumeRoots == null || path == null) {
return Optional.empty(); return Optional.empty();
@ -187,7 +197,7 @@ public class StorageUtils {
// follow the entry path down the document tree // follow the entry path down the document tree
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, path); Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, path);
while (pathIterator.hasNext()) { while (pathIterator.hasNext()) {
documentFile = documentFile.findFile(pathIterator.next()); documentFile = findFileIgnoreCase(documentFile, pathIterator.next());
if (documentFile == null) { if (documentFile == null) {
return Optional.empty(); return Optional.empty();
} }
@ -224,11 +234,13 @@ public class StorageUtils {
} }
} }
public static boolean createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) { // 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 Activity activity, @NonNull String directoryPath) {
if (Env.requireAccessPermission(directoryPath)) { if (Env.requireAccessPermission(directoryPath)) {
Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity); Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity);
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri); DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
if (parentFile == null) return false; if (parentFile == null) return null;
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity); String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
if (!directoryPath.endsWith(File.separator)) { if (!directoryPath.endsWith(File.separator)) {
@ -237,24 +249,31 @@ public class StorageUtils {
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath); Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath);
while (pathIterator.hasNext()) { while (pathIterator.hasNext()) {
String dirName = pathIterator.next(); String dirName = pathIterator.next();
DocumentFileCompat dirFile = parentFile.findFile(dirName); DocumentFileCompat dirFile = findFileIgnoreCase(parentFile, dirName);
if (dirFile == null || !dirFile.exists()) { if (dirFile == null || !dirFile.exists()) {
try { try {
dirFile = parentFile.createDirectory(dirName); dirFile = parentFile.createDirectory(dirName);
if (dirFile != null) { if (dirFile == null) {
parentFile = dirFile; Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile);
return null;
} }
} catch (FileNotFoundException e) { } catch (FileNotFoundException e) {
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, e); Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, e);
return false; return null;
} }
} }
parentFile = dirFile;
} }
return true; return parentFile;
} else { } else {
File directory = new File(directoryPath); File directory = new File(directoryPath);
if (directory.exists()) return true; if (!directory.exists()) {
return directory.mkdirs(); if (!directory.mkdirs()) {
Log.e(LOG_TAG, "failed to create directories at path=" + directoryPath);
return null;
}
}
return DocumentFileCompat.fromFile(directory);
} }
} }

View file

@ -21,7 +21,7 @@ class ImageEntry {
String _directory; String _directory;
String _filename; String _filename;
int contentId; int contentId;
final String mimeType; final String sourceMimeType;
int width; int width;
int height; int height;
int orientationDegrees; int orientationDegrees;
@ -40,7 +40,7 @@ class ImageEntry {
this.uri, this.uri,
String path, String path,
this.contentId, this.contentId,
this.mimeType, this.sourceMimeType,
this.width, this.width,
this.height, this.height,
this.orientationDegrees, this.orientationDegrees,
@ -63,7 +63,7 @@ class ImageEntry {
uri: uri ?? uri, uri: uri ?? uri,
path: path ?? this.path, path: path ?? this.path,
contentId: copyContentId, contentId: copyContentId,
mimeType: mimeType, sourceMimeType: sourceMimeType,
width: width, width: width,
height: height, height: height,
orientationDegrees: orientationDegrees, orientationDegrees: orientationDegrees,
@ -79,12 +79,13 @@ class ImageEntry {
return copied; return copied;
} }
// from DB or platform source entry
factory ImageEntry.fromMap(Map map) { factory ImageEntry.fromMap(Map map) {
return ImageEntry( return ImageEntry(
uri: map['uri'] as String, uri: map['uri'] as String,
path: map['path'] as String, path: map['path'] as String,
contentId: map['contentId'] as int, contentId: map['contentId'] as int,
mimeType: map['mimeType'] as String, sourceMimeType: map['sourceMimeType'] as String,
width: map['width'] as int, width: map['width'] as int,
height: map['height'] as int, height: map['height'] as int,
orientationDegrees: map['orientationDegrees'] as int, orientationDegrees: map['orientationDegrees'] as int,
@ -96,12 +97,13 @@ class ImageEntry {
); );
} }
// for DB only
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'uri': uri, 'uri': uri,
'path': path, 'path': path,
'contentId': contentId, 'contentId': contentId,
'mimeType': mimeType, 'sourceMimeType': sourceMimeType,
'width': width, 'width': width,
'height': height, 'height': height,
'orientationDegrees': orientationDegrees, 'orientationDegrees': orientationDegrees,
@ -142,6 +144,10 @@ class ImageEntry {
return _filename; return _filename;
} }
// the MIME type reported by the Media Store is unreliable
// so we use the one found during cataloguing if possible
String get mimeType => catalogMetadata?.mimeType ?? sourceMimeType;
String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*'); String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
bool get isFavourite => favourites.isFavourite(this); bool get isFavourite => favourites.isFavourite(this);

View file

@ -30,12 +30,13 @@ class DateMetadata {
class CatalogMetadata { class CatalogMetadata {
final int contentId, dateMillis, videoRotation; final int contentId, dateMillis, videoRotation;
final bool isAnimated; final bool isAnimated;
final String xmpSubjects, xmpTitleDescription; final String mimeType, xmpSubjects, xmpTitleDescription;
final double latitude, longitude; final double latitude, longitude;
Address address; Address address;
CatalogMetadata({ CatalogMetadata({
this.contentId, this.contentId,
this.mimeType,
this.dateMillis, this.dateMillis,
this.isAnimated, this.isAnimated,
this.videoRotation, this.videoRotation,
@ -53,6 +54,7 @@ class CatalogMetadata {
}) { }) {
return CatalogMetadata( return CatalogMetadata(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,
mimeType: mimeType,
dateMillis: dateMillis, dateMillis: dateMillis,
isAnimated: isAnimated, isAnimated: isAnimated,
videoRotation: videoRotation, videoRotation: videoRotation,
@ -67,6 +69,7 @@ class CatalogMetadata {
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
return CatalogMetadata( return CatalogMetadata(
contentId: map['contentId'], contentId: map['contentId'],
mimeType: map['mimeType'],
dateMillis: map['dateMillis'] ?? 0, dateMillis: map['dateMillis'] ?? 0,
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
videoRotation: map['videoRotation'] ?? 0, videoRotation: map['videoRotation'] ?? 0,
@ -79,6 +82,7 @@ class CatalogMetadata {
Map<String, dynamic> toMap({bool boolAsInteger = false}) => { Map<String, dynamic> toMap({bool boolAsInteger = false}) => {
'contentId': contentId, 'contentId': contentId,
'mimeType': mimeType,
'dateMillis': dateMillis, 'dateMillis': dateMillis,
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, 'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
'videoRotation': videoRotation, 'videoRotation': videoRotation,
@ -90,7 +94,7 @@ class CatalogMetadata {
@override @override
String toString() { String toString() {
return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
} }
} }

View file

@ -30,7 +30,7 @@ class MetadataDb {
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
', uri TEXT' ', uri TEXT'
', path TEXT' ', path TEXT'
', mimeType TEXT' ', sourceMimeType TEXT'
', width INTEGER' ', width INTEGER'
', height INTEGER' ', height INTEGER'
', orientationDegrees INTEGER' ', orientationDegrees INTEGER'
@ -46,6 +46,7 @@ class MetadataDb {
')'); ')');
await db.execute('CREATE TABLE $metadataTable(' await db.execute('CREATE TABLE $metadataTable('
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER' ', dateMillis INTEGER'
', isAnimated INTEGER' ', isAnimated INTEGER'
', videoRotation INTEGER' ', videoRotation INTEGER'

View file

@ -16,6 +16,18 @@ class ImageFileService {
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
static const double thumbnailDefaultSize = 64.0; static const double thumbnailDefaultSize = 64.0;
static Map<String, dynamic> _toPlatformEntryMap(ImageEntry entry) {
return {
'uri': entry.uri,
'path': entry.path,
'mimeType': entry.mimeType,
'width': entry.width,
'height': entry.height,
'orientationDegrees': entry.orientationDegrees,
'dateModifiedSecs': entry.dateModifiedSecs,
};
}
// knownEntries: map of contentId -> dateModifiedSecs // knownEntries: map of contentId -> dateModifiedSecs
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) { static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) {
try { try {
@ -95,7 +107,7 @@ class ImageFileService {
() async { () async {
try { try {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{ final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'entry': entry.toMap(), 'entry': _toPlatformEntryMap(entry),
'widthDip': width, 'widthDip': width,
'heightDip': height, 'heightDip': height,
'defaultSizeDip': thumbnailDefaultSize, 'defaultSizeDip': thumbnailDefaultSize,
@ -128,7 +140,7 @@ class ImageFileService {
try { try {
return opChannel.receiveBroadcastStream(<String, dynamic>{ return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete', 'op': 'delete',
'entries': entries.map((e) => e.toMap()).toList(), 'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(),
}).map((event) => ImageOpEvent.fromMap(event)); }).map((event) => ImageOpEvent.fromMap(event));
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}');
@ -141,7 +153,7 @@ class ImageFileService {
try { try {
return opChannel.receiveBroadcastStream(<String, dynamic>{ return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move', 'op': 'move',
'entries': entries.map((e) => e.toMap()).toList(), 'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(),
'copy': copy, 'copy': copy,
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,
}).map((event) => MoveOpEvent.fromMap(event)); }).map((event) => MoveOpEvent.fromMap(event));
@ -155,7 +167,7 @@ class ImageFileService {
try { try {
// return map with: 'contentId' 'path' 'title' 'uri' (all optional) // return map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{ final result = await platform.invokeMethod('rename', <String, dynamic>{
'entry': entry.toMap(), 'entry': _toPlatformEntryMap(entry),
'newName': newName, 'newName': newName,
}) as Map; }) as Map;
return result; return result;
@ -169,7 +181,7 @@ class ImageFileService {
try { try {
// return map with: 'width' 'height' 'orientationDegrees' (all optional) // return map with: 'width' 'height' 'orientationDegrees' (all optional)
final result = await platform.invokeMethod('rotate', <String, dynamic>{ final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': entry.toMap(), 'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise, 'clockwise': clockwise,
}) as Map; }) as Map;
return result; return result;

View file

@ -29,6 +29,7 @@ class MetadataService {
final call = () async { final call = () async {
try { try {
// return map with: // return map with:
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
// 'dateMillis': date taken in milliseconds since Epoch (long) // 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isAnimated': animated gif/webp (bool) // 'isAnimated': animated gif/webp (bool)
// 'latitude': latitude (double) // 'latitude': latitude (double)

View file

@ -251,34 +251,39 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed)); final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed));
opStream.listen( opStream.listen(
(event) => processed.add(event), (event) => processed.add(event),
onError: (error) => onComplete(), onError: (error) {
debugPrint('_showOpReport error=$error');
onComplete();
},
onDone: onComplete, onDone: onComplete,
); );
_opReportOverlayEntry = OverlayEntry( _opReportOverlayEntry = OverlayEntry(
builder: (context) { builder: (context) {
return StreamBuilder<T>( return AbsorbPointer(
stream: opStream, child: StreamBuilder<T>(
builder: (context, snapshot) { stream: opStream,
Widget child = const SizedBox.shrink(); builder: (context, snapshot) {
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) { Widget child = const SizedBox.shrink();
final percent = processed.length.toDouble() / selection.length; if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
child = CircularPercentIndicator( final percent = processed.length.toDouble() / selection.length;
percent: percent, child = CircularPercentIndicator(
lineWidth: 16, percent: percent,
radius: 160, lineWidth: 16,
backgroundColor: Colors.white24, radius: 160,
progressColor: Theme.of(context).accentColor, backgroundColor: Colors.white24,
animation: true, progressColor: Theme.of(context).accentColor,
center: Text(NumberFormat.percentPattern().format(percent)), animation: true,
animateFromLastPercent: true, center: Text(NumberFormat.percentPattern().format(percent)),
animateFromLastPercent: true,
);
}
return AnimatedSwitcher(
duration: Durations.collectionOpOverlayAnimation,
child: child,
); );
} }),
return AnimatedSwitcher( );
duration: Durations.collectionOpOverlayAnimation,
child: child,
);
});
}, },
); );
Overlay.of(context).insert(_opReportOverlayEntry); Overlay.of(context).insert(_opReportOverlayEntry);

View file

@ -92,6 +92,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Text('DB metadata:${data == null ? ' no row' : ''}'), Text('DB metadata:${data == null ? ' no row' : ''}'),
if (data != null) if (data != null)
InfoRowGroup({ InfoRowGroup({
'mimeType': '${data.mimeType}',
'dateMillis': '${data.dateMillis}', 'dateMillis': '${data.dateMillis}',
'isAnimated': '${data.isAnimated}', 'isAnimated': '${data.isAnimated}',
'videoRotation': '${data.videoRotation}', 'videoRotation': '${data.videoRotation}',
@ -132,6 +133,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
if (catalog != null) if (catalog != null)
InfoRowGroup({ InfoRowGroup({
'contentId': '${catalog.contentId}', 'contentId': '${catalog.contentId}',
'mimeType': '${catalog.mimeType}',
'dateMillis': '${catalog.dateMillis}', 'dateMillis': '${catalog.dateMillis}',
'isAnimated': '${catalog.isAnimated}', 'isAnimated': '${catalog.isAnimated}',
'videoRotation': '${catalog.videoRotation}', 'videoRotation': '${catalog.videoRotation}',

View file

@ -113,7 +113,7 @@ class StatsPage extends StatelessWidget {
} }
String _cleanMime(String mime) { String _cleanMime(String mime) {
mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', ''); mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', '');
return mime; return mime;
} }