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 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<ImageDecodeTask.Params, Void, Ima
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class);
static class Params {
ImageEntry entry;
AvesImageEntry entry;
Integer width, height, defaultSize;
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.width = width;
this.height = height;
@ -116,7 +116,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
@RequiresApi(api = Build.VERSION_CODES.Q)
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
ImageEntry entry = params.entry;
AvesImageEntry entry = params.entry;
Integer width = params.width;
Integer height = params.height;
// 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) {
ImageEntry entry = params.entry;
AvesImageEntry entry = params.entry;
long contentId = ContentUris.parseId(entry.uri);
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 {
ImageEntry entry = params.entry;
AvesImageEntry entry = params.entry;
int width = params.width;
int height = params.height;
// 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.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.ImageProviderFactory;
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 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));
}

View file

@ -26,6 +26,7 @@ import com.drew.metadata.Tag;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.GpsDirectory;
import com.drew.metadata.file.FileTypeDirectory;
import com.drew.metadata.gif.GifAnimationDirectory;
import com.drew.metadata.webp.WebpDirectory;
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";
// catalog metadata
private static final String KEY_MIME_TYPE = "mimeType";
private static final String KEY_DATE_MILLIS = "dateMillis";
private static final String KEY_IS_ANIMATED = "isAnimated";
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) {
String mimeType = call.argument("mimeType");
String uri = call.argument("uri");
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);
}
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
if (!videoDir.isEmpty()) {
metadataMap.put("Video", videoDir);
if (isVideo(mimeType)) {
Map<String, String> 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)) {

View file

@ -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<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() {
@Override
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;
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<String, Object> 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<String, Object>() {{
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();

View file

@ -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()) {

View file

@ -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) {

View file

@ -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<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());
}
@ -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));
}
}

View file

@ -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<String, Object> entryMap = new HashMap<String, Object>() {{
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<ImageEntry> 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<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));
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<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("newFields", newFields);
} 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();
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<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 {
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) {
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<String> 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<String> 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);
}
}

View file

@ -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<String, dynamic> 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);

View file

@ -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<String, dynamic> 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}';
}
}

View file

@ -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'

View file

@ -16,6 +16,18 @@ class ImageFileService {
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
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
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) {
try {
@ -95,7 +107,7 @@ class ImageFileService {
() async {
try {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'entry': entry.toMap(),
'entry': _toPlatformEntryMap(entry),
'widthDip': width,
'heightDip': height,
'defaultSizeDip': thumbnailDefaultSize,
@ -128,7 +140,7 @@ class ImageFileService {
try {
return opChannel.receiveBroadcastStream(<String, dynamic>{
'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(<String, dynamic>{
'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', <String, dynamic>{
'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', <String, dynamic>{
'entry': entry.toMap(),
'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise,
}) as Map;
return result;

View file

@ -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)

View file

@ -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<T>(
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<T>(
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);

View file

@ -92,6 +92,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
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<FullscreenDebugPage> {
if (catalog != null)
InfoRowGroup({
'contentId': '${catalog.contentId}',
'mimeType': '${catalog.mimeType}',
'dateMillis': '${catalog.dateMillis}',
'isAnimated': '${catalog.isAnimated}',
'videoRotation': '${catalog.videoRotation}',

View file

@ -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;
}