From e7b48ad136abc9258d3f31fab085cd0488675666 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 21 Jun 2020 21:14:15 +0900 Subject: [PATCH] catalogue mime type, platform: distinguish source entry from dart call entry, move/copy alternate method for older devices --- .../aves/channel/calls/ImageDecodeTask.java | 12 +- .../aves/channel/calls/ImageFileHandler.java | 4 +- .../aves/channel/calls/MetadataHandler.java | 23 +++- .../channel/streams/ImageOpStreamHandler.java | 4 +- .../thibault/aves/model/AvesImageEntry.java | 41 ++++++ ...{ImageEntry.java => SourceImageEntry.java} | 32 ++--- .../model/provider/ContentImageProvider.java | 6 +- .../model/provider/FileImageProvider.java | 6 +- .../aves/model/provider/ImageProvider.java | 40 +----- .../provider/MediaStoreImageProvider.java | 128 +++++++++++++++--- .../thibault/aves/utils/StorageUtils.java | 39 ++++-- lib/model/image_entry.dart | 16 ++- lib/model/image_metadata.dart | 8 +- lib/model/metadata_db.dart | 3 +- lib/services/image_file_service.dart | 22 ++- lib/services/metadata_service.dart | 1 + .../selection_action_delegate.dart | 49 ++++--- lib/widgets/fullscreen/debug.dart | 2 + lib/widgets/stats/stats.dart | 2 +- 19 files changed, 305 insertions(+), 133 deletions(-) create mode 100644 android/app/src/main/java/deckers/thibault/aves/model/AvesImageEntry.java rename android/app/src/main/java/deckers/thibault/aves/model/{ImageEntry.java => SourceImageEntry.java} (92%) diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java index f86cea884..6e9924c7e 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java @@ -27,7 +27,7 @@ import java.io.IOException; import java.util.concurrent.ExecutionException; 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 io.flutter.plugin.common.MethodChannel; @@ -35,11 +35,11 @@ public class ImageDecodeTask extends AsyncTask> 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); } - Map videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri); - if (!videoDir.isEmpty()) { - metadataMap.put("Video", videoDir); + if (isVideo(mimeType)) { + Map videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri); + if (!videoDir.isEmpty()) { + metadataMap.put("Video", videoDir); + } } if (metadataMap.isEmpty()) { @@ -215,6 +220,18 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) { 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 putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java index 1399bc3d5..7d13671b5 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java @@ -13,7 +13,7 @@ import java.util.Map; import java.util.concurrent.ExecutionException; 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.ImageProviderFactory; import deckers.thibault.aves.utils.Utils; @@ -94,7 +94,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { String destinationDir = (String) argMap.get("destinationPath"); if (copy == null || destinationDir == null) return; - List entries = entryMapList.stream().map(ImageEntry::new).collect(Collectors.toList()); + List entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList()); provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { @Override public void onSuccess(Map fields) { diff --git a/android/app/src/main/java/deckers/thibault/aves/model/AvesImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/AvesImageEntry.java new file mode 100644 index 000000000..715883e0f --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/model/AvesImageEntry.java @@ -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 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; + } +} diff --git a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java similarity index 92% rename from android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java rename to android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java index e6bbb6f98..50067895b 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java @@ -32,11 +32,11 @@ import deckers.thibault.aves.utils.StorageUtils; import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode; -public class ImageEntry { +public class SourceImageEntry { public Uri uri; // content or file URI public String path; // best effort to get local path - public String mimeType; + public String sourceMimeType; @Nullable public String title; @Nullable @@ -50,13 +50,13 @@ public class ImageEntry { @Nullable private Long durationMillis; - public ImageEntry() { + public SourceImageEntry() { } - public ImageEntry(Map map) { + public SourceImageEntry(Map map) { this.uri = Uri.parse((String) map.get("uri")); 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.height = (int) map.get("height"); this.orientationDegrees = (int) map.get("orientationDegrees"); @@ -71,7 +71,7 @@ public class ImageEntry { return new HashMap() {{ put("uri", uri.toString()); put("path", path); - put("mimeType", mimeType); + put("sourceMimeType", sourceMimeType); put("width", width); put("height", height); put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0); @@ -106,22 +106,22 @@ public class ImageEntry { } private boolean isImage() { - return mimeType.startsWith(MimeTypes.IMAGE); + return sourceMimeType.startsWith(MimeTypes.IMAGE); } public boolean isSvg() { - return mimeType.equals(MimeTypes.SVG); + return sourceMimeType.equals(MimeTypes.SVG); } - public boolean isVideo() { - return mimeType.startsWith(MimeTypes.VIDEO); + private boolean isVideo() { + return sourceMimeType.startsWith(MimeTypes.VIDEO); } // metadata retrieval // expects entry with: uri, mimeType // finds: width, height, orientation/rotation, date, title, duration - public ImageEntry fillPreCatalogMetadata(Context context) { + public SourceImageEntry fillPreCatalogMetadata(Context context) { fillByMediaMetadataRetriever(context); if (hasSize() && (!isVideo() || hasDuration())) return this; fillByMetadataExtractor(context); @@ -183,12 +183,12 @@ public class ImageEntry { // expects entry with: uri, mimeType // finds: width, height, orientation, date private void fillByMetadataExtractor(Context context) { - if (MimeTypes.SVG.equals(mimeType)) return; + if (isSvg()) return; try (InputStream is = StorageUtils.openInputStream(context, uri)) { Metadata metadata = ImageMetadataReader.readMetadata(is); - if (MimeTypes.JPEG.equals(mimeType)) { + if (MimeTypes.JPEG.equals(sourceMimeType)) { JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class); if (jpegDir != null) { if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) { @@ -207,7 +207,7 @@ public class ImageEntry { 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); if (mp4VideoDir != null) { if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) { @@ -223,7 +223,7 @@ public class ImageEntry { durationMillis = mp4Dir.getLong(Mp4Directory.TAG_DURATION); } } - } else if (MimeTypes.AVI.equals(mimeType)) { + } else if (MimeTypes.AVI.equals(sourceMimeType)) { AviDirectory aviDir = metadata.getFirstDirectoryOfType(AviDirectory.class); if (aviDir != null) { if (aviDir.containsTag(AviDirectory.TAG_WIDTH)) { @@ -245,7 +245,7 @@ public class ImageEntry { // expects entry with: uri // finds: width, height private void fillByBitmapDecode(Context context) { - if (MimeTypes.SVG.equals(mimeType)) return; + if (isSvg()) return; try (InputStream is = StorageUtils.openInputStream(context, uri)) { BitmapFactory.Options options = new BitmapFactory.Options(); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java index 0e3512a14..d6c0d3541 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java @@ -5,14 +5,14 @@ import android.net.Uri; import androidx.annotation.NonNull; -import deckers.thibault.aves.model.ImageEntry; +import deckers.thibault.aves.model.SourceImageEntry; class ContentImageProvider extends ImageProvider { @Override public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) { - ImageEntry entry = new ImageEntry(); + SourceImageEntry entry = new SourceImageEntry(); entry.uri = uri; - entry.mimeType = mimeType; + entry.sourceMimeType = mimeType; entry.fillPreCatalogMetadata(context); if (entry.hasSize() || entry.isSvg()) { diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java index 5abe778fe..21d5d2ff3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java @@ -7,15 +7,15 @@ import androidx.annotation.NonNull; import java.io.File; -import deckers.thibault.aves.model.ImageEntry; +import deckers.thibault.aves.model.SourceImageEntry; import deckers.thibault.aves.utils.FileUtils; class FileImageProvider extends ImageProvider { @Override public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) { - ImageEntry entry = new ImageEntry(); + SourceImageEntry entry = new SourceImageEntry(); entry.uri = uri; - entry.mimeType = mimeType; + entry.sourceMimeType = mimeType; String path = FileUtils.getPathFromUri(context, uri); if (path != null) { diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 4dae163fb..7f019525d 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -17,14 +17,9 @@ import android.provider.MediaStore; import android.util.Log; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.exifinterface.media.ExifInterface; 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.ListenableFuture; @@ -32,12 +27,11 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; import java.util.HashMap; import java.util.List; 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.MetadataHelper; import deckers.thibault.aves.utils.MimeTypes; @@ -65,7 +59,7 @@ public abstract class ImageProvider { return Futures.immediateFailedFuture(new UnsupportedOperationException()); } - public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List entries, @NonNull ImageOpCallback callback) { + public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List entries, @NonNull ImageOpCallback callback) { 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) { - // the reported `mimeType` (e.g. from Media Store) is sometimes incorrect - // so we retrieve it again from the file metadata - String actualMimeType = getMimeType(activity, uri); - if (actualMimeType == null) { - actualMimeType = mimeType; - } - switch (actualMimeType) { + switch (mimeType) { case MimeTypes.JPEG: rotateJpeg(activity, path, uri, clockwise, callback); break; @@ -167,7 +135,7 @@ public abstract class ImageProvider { rotatePng(activity, path, uri, clockwise, callback); break; default: - callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + actualMimeType)); + callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType)); } } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index c6899fa5d..077c169c0 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -6,6 +6,7 @@ import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; import android.os.Handler; @@ -31,7 +32,8 @@ import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; 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.MimeTypes; import deckers.thibault.aves.utils.PermissionManager; @@ -165,7 +167,7 @@ public class MediaStoreImageProvider extends ImageProvider { Map entryMap = new HashMap() {{ put("uri", itemUri.toString()); put("path", path); - put("mimeType", mimeType); + put("sourceMimeType", mimeType); put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0); put("sizeBytes", cursor.getLong(sizeColumn)); put("title", cursor.getString(titleColumn)); @@ -181,7 +183,7 @@ public class MediaStoreImageProvider extends ImageProvider { if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) { // some images are incorrectly registered in the Media Store, // 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(); width = entry.width != null ? entry.width : 0; height = entry.height != null ? entry.height : 0; @@ -235,6 +237,7 @@ public class MediaStoreImageProvider extends ImageProvider { DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri); if (df != null && df.delete()) { future.set(null); + } else { future.setException(new Exception("failed to delete file with df=" + df)); } } catch (FileNotFoundException e) { @@ -253,16 +256,14 @@ public class MediaStoreImageProvider extends ImageProvider { Log.e(LOG_TAG, "failed to delete entry", e); future.setException(e); } - return future; } - @Override - public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List entries, ImageOpCallback callback) { + private String getVolumeName(final Activity activity, String path) { String volumeName = "external"; StorageManager sm = activity.getSystemService(StorageManager.class); if (sm != null) { - StorageVolume volume = sm.getStorageVolume(new File(destinationDir)); + StorageVolume volume = sm.getStorageVolume(new File(path)); if (volume != null && !volume.isPrimary()) { String uuid = volume.getUuid(); 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 entries, @NonNull ImageOpCallback callback) { + DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir); + if (destinationDirDocFile == null) { callback.onFailure(new Exception("failed to create directory at path=" + destinationDir)); return; } - for (ImageEntry entry : entries) { + String volumeName = null; + + for (AvesImageEntry entry : entries) { Uri sourceUri = entry.uri; String sourcePath = entry.path; String mimeType = entry.mimeType; @@ -287,7 +295,16 @@ public class MediaStoreImageProvider extends ImageProvider { put("uri", sourceUri.toString()); }}; try { - Map newFields = moveSingle(activity, volumeName, sourcePath, sourceUri, destinationDir, mimeType, copy).get(); + ListenableFuture> 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 newFields = newFieldsFuture.get(); result.put("success", true); result.put("newFields", newFields); } catch (ExecutionException | InterruptedException e) { @@ -298,17 +315,19 @@ public class MediaStoreImageProvider extends ImageProvider { } } - private ListenableFuture> 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> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final String volumeName, final String mimeType, final boolean copy) { SettableFuture> future = SettableFuture.create(); 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 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.put(MediaStore.MediaColumns.DATA, destinationPath); @@ -324,10 +343,12 @@ public class MediaStoreImageProvider extends ImageProvider { DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri); source.copyTo(destination); + 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); } @@ -337,6 +358,7 @@ public class MediaStoreImageProvider extends ImageProvider { newFields.put("uri", destinationUri.toString()); newFields.put("contentId", ContentUris.parseId(destinationUri)); newFields.put("path", destinationPath); + newFields.put("deletedSource", deletedSource); future.set(newFields); } } catch (Exception e) { @@ -347,6 +369,80 @@ public class MediaStoreImageProvider extends ImageProvider { 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> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { + SettableFuture> 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 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 { void handleEntry(Map entry); } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java index 3ea02d339..579e8e4ba 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -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 getSdCardDocumentFile(Context context, Uri rootTreeUri, String[] storageVolumeRoots, String path) { if (rootTreeUri == null || storageVolumeRoots == null || path == null) { return Optional.empty(); @@ -187,7 +197,7 @@ public class StorageUtils { // follow the entry path down the document tree Iterator pathIterator = getPathStepIterator(storageVolumeRoots, path); while (pathIterator.hasNext()) { - documentFile = documentFile.findFile(pathIterator.next()); + documentFile = findFileIgnoreCase(documentFile, pathIterator.next()); if (documentFile == null) { 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)) { Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity); DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri); - if (parentFile == null) return false; + if (parentFile == null) return null; String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity); if (!directoryPath.endsWith(File.separator)) { @@ -237,24 +249,31 @@ public class StorageUtils { Iterator pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath); while (pathIterator.hasNext()) { String dirName = pathIterator.next(); - DocumentFileCompat dirFile = parentFile.findFile(dirName); + DocumentFileCompat dirFile = findFileIgnoreCase(parentFile, dirName); if (dirFile == null || !dirFile.exists()) { try { dirFile = parentFile.createDirectory(dirName); - if (dirFile != null) { - parentFile = dirFile; + 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 false; + return null; } } + parentFile = dirFile; } - return true; + return parentFile; } else { File directory = new File(directoryPath); - if (directory.exists()) return true; - return directory.mkdirs(); + if (!directory.exists()) { + if (!directory.mkdirs()) { + Log.e(LOG_TAG, "failed to create directories at path=" + directoryPath); + return null; + } + } + return DocumentFileCompat.fromFile(directory); } } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 51bffc20d..8c62c9d2d 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -21,7 +21,7 @@ class ImageEntry { String _directory; String _filename; int contentId; - final String mimeType; + final String sourceMimeType; int width; int height; int orientationDegrees; @@ -40,7 +40,7 @@ class ImageEntry { this.uri, String path, this.contentId, - this.mimeType, + this.sourceMimeType, this.width, this.height, this.orientationDegrees, @@ -63,7 +63,7 @@ class ImageEntry { uri: uri ?? uri, path: path ?? this.path, contentId: copyContentId, - mimeType: mimeType, + sourceMimeType: sourceMimeType, width: width, height: height, orientationDegrees: orientationDegrees, @@ -79,12 +79,13 @@ class ImageEntry { return copied; } + // from DB or platform source entry factory ImageEntry.fromMap(Map map) { return ImageEntry( uri: map['uri'] as String, path: map['path'] as String, contentId: map['contentId'] as int, - mimeType: map['mimeType'] as String, + sourceMimeType: map['sourceMimeType'] as String, width: map['width'] as int, height: map['height'] as int, orientationDegrees: map['orientationDegrees'] as int, @@ -96,12 +97,13 @@ class ImageEntry { ); } + // for DB only Map toMap() { return { 'uri': uri, 'path': path, 'contentId': contentId, - 'mimeType': mimeType, + 'sourceMimeType': sourceMimeType, 'width': width, 'height': height, 'orientationDegrees': orientationDegrees, @@ -142,6 +144,10 @@ class ImageEntry { 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('/.*'), '/*'); bool get isFavourite => favourites.isFavourite(this); diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index e8f65e56e..fae1031d6 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -30,12 +30,13 @@ class DateMetadata { class CatalogMetadata { final int contentId, dateMillis, videoRotation; final bool isAnimated; - final String xmpSubjects, xmpTitleDescription; + final String mimeType, xmpSubjects, xmpTitleDescription; final double latitude, longitude; Address address; CatalogMetadata({ this.contentId, + this.mimeType, this.dateMillis, this.isAnimated, this.videoRotation, @@ -53,6 +54,7 @@ class CatalogMetadata { }) { return CatalogMetadata( contentId: contentId ?? this.contentId, + mimeType: mimeType, dateMillis: dateMillis, isAnimated: isAnimated, videoRotation: videoRotation, @@ -67,6 +69,7 @@ class CatalogMetadata { final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); return CatalogMetadata( contentId: map['contentId'], + mimeType: map['mimeType'], dateMillis: map['dateMillis'] ?? 0, isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, videoRotation: map['videoRotation'] ?? 0, @@ -79,6 +82,7 @@ class CatalogMetadata { Map toMap({bool boolAsInteger = false}) => { 'contentId': contentId, + 'mimeType': mimeType, 'dateMillis': dateMillis, 'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, 'videoRotation': videoRotation, @@ -90,7 +94,7 @@ class CatalogMetadata { @override 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}'; } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index b6119f301..441fba32c 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -30,7 +30,7 @@ class MetadataDb { 'contentId INTEGER PRIMARY KEY' ', uri TEXT' ', path TEXT' - ', mimeType TEXT' + ', sourceMimeType TEXT' ', width INTEGER' ', height INTEGER' ', orientationDegrees INTEGER' @@ -46,6 +46,7 @@ class MetadataDb { ')'); await db.execute('CREATE TABLE $metadataTable(' 'contentId INTEGER PRIMARY KEY' + ', mimeType TEXT' ', dateMillis INTEGER' ', isAnimated INTEGER' ', videoRotation INTEGER' diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index d5045fba9..a2ffda260 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -16,6 +16,18 @@ class ImageFileService { static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); static const double thumbnailDefaultSize = 64.0; + static Map _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 static Stream getImageEntries(Map knownEntries) { try { @@ -95,7 +107,7 @@ class ImageFileService { () async { try { final result = await platform.invokeMethod('getThumbnail', { - 'entry': entry.toMap(), + 'entry': _toPlatformEntryMap(entry), 'widthDip': width, 'heightDip': height, 'defaultSizeDip': thumbnailDefaultSize, @@ -128,7 +140,7 @@ class ImageFileService { try { return opChannel.receiveBroadcastStream({ 'op': 'delete', - 'entries': entries.map((e) => e.toMap()).toList(), + 'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(), }).map((event) => ImageOpEvent.fromMap(event)); } on PlatformException catch (e) { debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}'); @@ -141,7 +153,7 @@ class ImageFileService { try { return opChannel.receiveBroadcastStream({ 'op': 'move', - 'entries': entries.map((e) => e.toMap()).toList(), + 'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(), 'copy': copy, 'destinationPath': destinationAlbum, }).map((event) => MoveOpEvent.fromMap(event)); @@ -155,7 +167,7 @@ class ImageFileService { try { // return map with: 'contentId' 'path' 'title' 'uri' (all optional) final result = await platform.invokeMethod('rename', { - 'entry': entry.toMap(), + 'entry': _toPlatformEntryMap(entry), 'newName': newName, }) as Map; return result; @@ -169,7 +181,7 @@ class ImageFileService { try { // return map with: 'width' 'height' 'orientationDegrees' (all optional) final result = await platform.invokeMethod('rotate', { - 'entry': entry.toMap(), + 'entry': _toPlatformEntryMap(entry), 'clockwise': clockwise, }) as Map; return result; diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 16cea61ad..eb571e857 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -29,6 +29,7 @@ class MetadataService { final call = () async { try { // return map with: + // 'mimeType': MIME type as reported by metadata extractors, not Media Store (string) // 'dateMillis': date taken in milliseconds since Epoch (long) // 'isAnimated': animated gif/webp (bool) // 'latitude': latitude (double) diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index 7096fe3a5..f80e3af18 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -251,34 +251,39 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed)); opStream.listen( (event) => processed.add(event), - onError: (error) => onComplete(), + onError: (error) { + debugPrint('_showOpReport error=$error'); + onComplete(); + }, onDone: onComplete, ); _opReportOverlayEntry = OverlayEntry( builder: (context) { - return StreamBuilder( - stream: opStream, - builder: (context, snapshot) { - Widget child = const SizedBox.shrink(); - if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) { - final percent = processed.length.toDouble() / selection.length; - child = CircularPercentIndicator( - percent: percent, - lineWidth: 16, - radius: 160, - backgroundColor: Colors.white24, - progressColor: Theme.of(context).accentColor, - animation: true, - center: Text(NumberFormat.percentPattern().format(percent)), - animateFromLastPercent: true, + return AbsorbPointer( + child: StreamBuilder( + stream: opStream, + builder: (context, snapshot) { + Widget child = const SizedBox.shrink(); + if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) { + final percent = processed.length.toDouble() / selection.length; + child = CircularPercentIndicator( + percent: percent, + lineWidth: 16, + radius: 160, + backgroundColor: Colors.white24, + progressColor: Theme.of(context).accentColor, + animation: 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); diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index 1b8cf398b..827e0b4a8 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -92,6 +92,7 @@ class _FullscreenDebugPageState extends State { Text('DB metadata:${data == null ? ' no row' : ''}'), if (data != null) InfoRowGroup({ + 'mimeType': '${data.mimeType}', 'dateMillis': '${data.dateMillis}', 'isAnimated': '${data.isAnimated}', 'videoRotation': '${data.videoRotation}', @@ -132,6 +133,7 @@ class _FullscreenDebugPageState extends State { if (catalog != null) InfoRowGroup({ 'contentId': '${catalog.contentId}', + 'mimeType': '${catalog.mimeType}', 'dateMillis': '${catalog.dateMillis}', 'isAnimated': '${catalog.isAnimated}', 'videoRotation': '${catalog.videoRotation}', diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 3030a1b4f..7decb89bb 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -113,7 +113,7 @@ class StatsPage extends StatelessWidget { } String _cleanMime(String mime) { - mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', ''); + mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', ''); return mime; }