diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java index 3f83a0803..1877c31e3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java @@ -85,8 +85,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } @Override - public void onFailure() { - result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, null); + public void onFailure(Throwable throwable) { + result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, throwable); } }); } @@ -114,8 +114,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } @Override - public void onFailure() { - new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", null)); + public void onFailure(Throwable throwable) { + new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", throwable)); } }); } @@ -143,8 +143,8 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } @Override - public void onFailure() { - new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", null)); + public void onFailure(Throwable throwable) { + new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", throwable)); } }); } diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java index c46df0b14..cfb47bd65 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageOpStreamHandler.java @@ -11,7 +11,9 @@ import java.util.HashMap; import java.util.List; 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.provider.ImageProvider; import deckers.thibault.aves.model.provider.ImageProviderFactory; import deckers.thibault.aves.utils.Utils; @@ -91,25 +93,18 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { String destinationDir = (String) argMap.get("destinationPath"); if (copy == null || destinationDir == null) return; - for (Map entryMap : entryMapList) { - String uriString = (String) entryMap.get("uri"); - Uri sourceUri = Uri.parse(uriString); - String sourcePath = (String) entryMap.get("path"); - String mimeType = (String) entryMap.get("mimeType"); - - Map result = new HashMap() {{ - put("uri", uriString); - }}; - try { - Map newFields = provider.move(activity, sourcePath, sourceUri, destinationDir, mimeType, copy).get(); - result.put("success", true); - result.put("newFields", newFields); - } catch (ExecutionException | InterruptedException e) { - Log.w(LOG_TAG, "failed to move to destinationDir=" + destinationDir + " entry with sourcePath=" + sourcePath, e); - result.put("success", false); + ArrayList entries = entryMapList.stream().map(ImageEntry::new).collect(Collectors.toCollection(ArrayList::new)); + provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { + @Override + public void onSuccess(Map fields) { + success(fields); } - success(result); - } + + @Override + public void onFailure(Throwable throwable) { + error("move-failure", "failed to move entries", throwable); + } + }); endOfStream(); } 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 c3e73c7d3..0e3512a14 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 @@ -18,7 +18,7 @@ class ContentImageProvider extends ImageProvider { if (entry.hasSize() || entry.isSvg()) { callback.onSuccess(entry.toMap()); } else { - callback.onFailure(); + callback.onFailure(new Exception("entry has no size")); } } } 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 87a339f63..5abe778fe 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 @@ -2,7 +2,6 @@ package deckers.thibault.aves.model.provider; import android.content.Context; import android.net.Uri; -import android.util.Log; import androidx.annotation.NonNull; @@ -10,11 +9,8 @@ import java.io.File; import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.utils.FileUtils; -import deckers.thibault.aves.utils.Utils; class FileImageProvider extends ImageProvider { - private static final String LOG_TAG = Utils.createLogTag(FileImageProvider.class); - @Override public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) { ImageEntry entry = new ImageEntry(); @@ -32,8 +28,7 @@ class FileImageProvider extends ImageProvider { entry.dateModifiedSecs = file.lastModified() / 1000; } } catch (SecurityException e) { - Log.w(LOG_TAG, "failed to get path from file at uri=" + uri); - callback.onFailure(); + callback.onFailure(e); } } entry.fillPreCatalogMetadata(context); @@ -41,7 +36,7 @@ class FileImageProvider extends ImageProvider { if (entry.hasSize() || entry.isSvg()) { callback.onSuccess(entry.toMap()); } else { - callback.onFailure(); + callback.onFailure(new Exception("entry has no size")); } } } \ No newline at end of file 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 091d5ef41..66c59debb 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 @@ -33,9 +33,11 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; import java.util.HashMap; import java.util.Map; +import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MimeTypes; @@ -56,21 +58,20 @@ public abstract class ImageProvider { private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class); public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) { - callback.onFailure(); + callback.onFailure(new UnsupportedOperationException()); } public ListenableFuture delete(final Activity activity, final String path, final Uri uri) { return Futures.immediateFailedFuture(new UnsupportedOperationException()); } - public ListenableFuture> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) { - return Futures.immediateFailedFuture(new UnsupportedOperationException()); + public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, ArrayList entries, @NonNull ImageOpCallback callback) { + callback.onFailure(new UnsupportedOperationException()); } public void rename(final Activity activity, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) { if (oldPath == null) { - Log.w(LOG_TAG, "entry does not have a path, uri=" + oldMediaUri); - callback.onFailure(); + callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri)); return; } @@ -96,13 +97,11 @@ public abstract class ImageProvider { try { boolean renamed = df != null && df.renameTo(newFilename); if (!renamed) { - Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath); - callback.onFailure(); + callback.onFailure(new Exception("failed to rename entry at path=" + oldPath)); return; } } catch (FileNotFoundException e) { - Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath, e); - callback.onFailure(); + callback.onFailure(e); return; } @@ -125,8 +124,7 @@ public abstract class ImageProvider { cursor.close(); } } catch (Exception e) { - Log.w(LOG_TAG, "failed to update Media Store after renaming entry at path=" + oldPath, e); - callback.onFailure(); + callback.onFailure(e); return; } } @@ -157,8 +155,11 @@ public abstract class ImageProvider { 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 metadataMimeType = getMimeType(activity, uri); - switch (metadataMimeType != null ? metadataMimeType : mimeType) { + String actualMimeType = getMimeType(activity, uri); + if (actualMimeType == null) { + actualMimeType = mimeType; + } + switch (actualMimeType) { case MimeTypes.JPEG: rotateJpeg(activity, path, uri, clockwise, callback); break; @@ -166,7 +167,7 @@ public abstract class ImageProvider { rotatePng(activity, path, uri, clockwise, callback); break; default: - callback.onFailure(); + callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + actualMimeType)); } } @@ -182,8 +183,7 @@ public abstract class ImageProvider { } } - boolean rotated = false; - int newOrientationCode = 0; + int newOrientationCode; try { ExifInterface exif = new ExifInterface(editablePath); switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { @@ -205,12 +205,8 @@ public abstract class ImageProvider { // copy the edited temporary file to the original DocumentFile DocumentFileCompat.fromFile(new File(editablePath)).copyTo(DocumentFileCompat.fromSingleUri(activity, uri)); - rotated = true; } catch (IOException e) { - Log.w(LOG_TAG, "failed to edit EXIF to rotate image at path=" + path, e); - } - if (!rotated) { - callback.onFailure(); + callback.onFailure(e); return; } @@ -253,8 +249,7 @@ public abstract class ImageProvider { Bitmap originalImage = BitmapFactory.decodeFile(path); if (originalImage == null) { - Log.e(LOG_TAG, "failed to decode image at path=" + path); - callback.onFailure(); + callback.onFailure(new Exception("failed to decode image at path=" + path)); return; } Matrix matrix = new Matrix(); @@ -263,18 +258,13 @@ public abstract class ImageProvider { matrix.setRotate(clockwise ? 90 : -90, originalWidth >> 1, originalHeight >> 1); Bitmap rotatedImage = Bitmap.createBitmap(originalImage, 0, 0, originalWidth, originalHeight, matrix, true); - boolean rotated = false; try (FileOutputStream fos = new FileOutputStream(editablePath)) { rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos); // copy the edited temporary file to the original DocumentFile DocumentFileCompat.fromFile(new File(editablePath)).copyTo(DocumentFileCompat.fromSingleUri(activity, uri)); - rotated = true; } catch (IOException e) { - Log.e(LOG_TAG, "failed to save rotated image to path=" + path, e); - } - if (!rotated) { - callback.onFailure(); + callback.onFailure(e); return; } @@ -306,8 +296,8 @@ public abstract class ImageProvider { } public interface ImageOpCallback { - void onSuccess(Map newFields); + void onSuccess(Map fields); - void onFailure(); + void onFailure(Throwable throwable); } } 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 b2a450247..aa4fd1ad1 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 @@ -23,6 +23,7 @@ import com.google.common.util.concurrent.SettableFuture; import java.io.File; import java.io.FileNotFoundException; +import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutionException; @@ -88,7 +89,7 @@ public class MediaStoreImageProvider extends ImageProvider { entryCount = fetchFrom(context, onSuccess, contentUri, VIDEO_PROJECTION); } if (entryCount == 0) { - callback.onFailure(); + callback.onFailure(new Exception("failed to fetch entry at uri=" + uri)); } } @@ -224,9 +225,7 @@ public class MediaStoreImageProvider extends ImageProvider { } @Override - public ListenableFuture> move(final Activity activity, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) { - SettableFuture> future = SettableFuture.create(); - + public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, ArrayList entries, ImageOpCallback callback) { String volumeName = "external"; StorageManager sm = activity.getSystemService(StorageManager.class); if (sm != null) { @@ -241,6 +240,34 @@ public class MediaStoreImageProvider extends ImageProvider { } } + if (!StorageUtils.createDirectoryIfAbsent(activity, destinationDir)) { + callback.onFailure(new Exception("failed to create directory at path=" + destinationDir)); + return; + } + + for (ImageEntry entry : entries) { + Uri sourceUri = entry.uri; + String sourcePath = entry.path; + String mimeType = entry.mimeType; + + Map result = new HashMap() {{ + put("uri", sourceUri.toString()); + }}; + try { + Map newFields = moveSingle(activity, volumeName, sourcePath, sourceUri, destinationDir, mimeType, copy).get(); + result.put("success", true); + result.put("newFields", newFields); + } catch (ExecutionException | InterruptedException e) { + Log.w(LOG_TAG, "failed to move to destinationDir=" + destinationDir + " entry with sourcePath=" + sourcePath, e); + result.put("success", false); + } + callback.onSuccess(result); + } + } + + private ListenableFuture> moveSingle(final Activity activity, final String volumeName, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) { + SettableFuture> future = SettableFuture.create(); + try { // 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(...)` 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 801812076..b6dcb4096 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 @@ -59,20 +59,24 @@ public class StorageUtils { public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri, String path) { MediaMetadataRetriever retriever = new MediaMetadataRetriever(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // we get a permission denial if we require original from a provider other than the media store - if (isMediaStoreContentUri(uri)) { - uri = MediaStore.setRequireOriginal(uri); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // we get a permission denial if we require original from a provider other than the media store + if (isMediaStoreContentUri(uri)) { + uri = MediaStore.setRequireOriginal(uri); + } + retriever.setDataSource(context, uri); + return retriever; } - retriever.setDataSource(context, uri); - return retriever; - } - // on Android