diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java index c8119a736..d2b3cf639 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageFileHandler.java @@ -9,6 +9,8 @@ import androidx.annotation.NonNull; import com.bumptech.glide.Glide; +import org.jetbrains.annotations.NotNull; + import java.util.List; import java.util.Map; @@ -70,7 +72,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { String uri = call.argument("uri"); String mimeType = call.argument("mimeType"); - Number dateModifiedSecs = (Number)call.argument("dateModifiedSecs"); + Number dateModifiedSecs = call.argument("dateModifiedSecs"); Integer rotationDegrees = call.argument("rotationDegrees"); Boolean isFlipped = call.argument("isFlipped"); Double widthDip = call.argument("widthDip"); @@ -116,13 +118,12 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } provider.fetchSingle(activity, uri, mimeType, new ImageProvider.ImageOpCallback() { - @Override - public void onSuccess(Map entry) { - result.success(entry); + public void onSuccess(@NotNull Map fields) { + result.success(fields); } @Override - public void onFailure(Throwable throwable) { + public void onFailure(@NotNull Throwable throwable) { result.error("getImageEntry-failure", "failed to get entry for uri=" + uri, throwable.getMessage()); } }); @@ -138,6 +139,10 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { Uri uri = Uri.parse((String) entryMap.get("uri")); String path = (String) entryMap.get("path"); String mimeType = (String) entryMap.get("mimeType"); + if (path == null || mimeType == null) { + result.error("rename-args", "failed because entry fields are missing", null); + return; + } ImageProvider provider = ImageProviderFactory.getProvider(uri); if (provider == null) { @@ -146,12 +151,12 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.ImageOpCallback() { @Override - public void onSuccess(Map newFields) { + public void onSuccess(@NotNull Map newFields) { new Handler(Looper.getMainLooper()).post(() -> result.success(newFields)); } @Override - public void onFailure(Throwable throwable) { + public void onFailure(@NotNull Throwable throwable) { new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", throwable.getMessage())); } }); @@ -167,6 +172,10 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { Uri uri = Uri.parse((String) entryMap.get("uri")); String path = (String) entryMap.get("path"); String mimeType = (String) entryMap.get("mimeType"); + if (path == null || mimeType == null) { + result.error("rotate-args", "failed because entry fields are missing", null); + return; + } ImageProvider provider = ImageProviderFactory.getProvider(uri); if (provider == null) { @@ -176,12 +185,12 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { ExifOrientationOp op = clockwise ? ExifOrientationOp.ROTATE_CW : ExifOrientationOp.ROTATE_CCW; provider.changeOrientation(activity, path, uri, mimeType, op, new ImageProvider.ImageOpCallback() { @Override - public void onSuccess(Map newFields) { + public void onSuccess(@NotNull Map newFields) { new Handler(Looper.getMainLooper()).post(() -> result.success(newFields)); } @Override - public void onFailure(Throwable throwable) { + public void onFailure(@NotNull Throwable throwable) { new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", throwable.getMessage())); } }); @@ -196,6 +205,10 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { Uri uri = Uri.parse((String) entryMap.get("uri")); String path = (String) entryMap.get("path"); String mimeType = (String) entryMap.get("mimeType"); + if (path == null || mimeType == null) { + result.error("flip-args", "failed because entry fields are missing", null); + return; + } ImageProvider provider = ImageProviderFactory.getProvider(uri); if (provider == null) { @@ -204,12 +217,12 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } provider.changeOrientation(activity, path, uri, mimeType, ExifOrientationOp.FLIP, new ImageProvider.ImageOpCallback() { @Override - public void onSuccess(Map newFields) { + public void onSuccess(@NotNull Map newFields) { new Handler(Looper.getMainLooper()).post(() -> result.success(newFields)); } @Override - public void onFailure(Throwable throwable) { + public void onFailure(@NotNull Throwable throwable) { new Handler(Looper.getMainLooper()).post(() -> result.error("flip-failure", "failed to flip", throwable.getMessage())); } }); 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 b942f556c..4775b2568 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 @@ -6,6 +6,8 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import org.jetbrains.annotations.NotNull; + import java.io.File; import java.util.ArrayList; import java.util.HashMap; @@ -64,7 +66,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { } // {String uri, bool success, [Map newFields]} - private void success(final Map result) { + private void success(final @NotNull Map result) { handler.post(() -> eventSink.success(result)); } @@ -102,12 +104,12 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { List entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList()); provider.moveMultiple(context, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { @Override - public void onSuccess(Map fields) { + public void onSuccess(@NotNull Map fields) { success(fields); } @Override - public void onFailure(Throwable throwable) { + public void onFailure(@NotNull Throwable throwable) { error("move-failure", "failed to move entries", throwable); } }); @@ -138,7 +140,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { put("uri", uriString); }}; try { - provider.delete(context, path, uri).get(); + provider.delete(context, uri, path).get(); result.put("success", true); } catch (ExecutionException | InterruptedException e) { Log.w(LOG_TAG, "failed to delete entry with path=" + path, e); 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 deleted file mode 100644 index 3ecaeb767..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java +++ /dev/null @@ -1,53 +0,0 @@ -package deckers.thibault.aves.model.provider; - -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.provider.MediaStore; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.HashMap; -import java.util.Map; - -import deckers.thibault.aves.model.SourceImageEntry; - -class ContentImageProvider extends ImageProvider { - @Override - public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @Nullable final String mimeType, @NonNull final ImageOpCallback callback) { - if (mimeType == null) { - callback.onFailure(new Exception("MIME type is null for uri=" + uri)); - return; - } - - Map map = new HashMap<>(); - map.put("uri", uri.toString()); - map.put("sourceMimeType", mimeType); - - String[] projection = { - MediaStore.MediaColumns.SIZE, - MediaStore.MediaColumns.DISPLAY_NAME, - }; - try { - Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null); - if (cursor != null) { - if (cursor.moveToNext()) { - map.put("sizeBytes", cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE))); - map.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))); - } - cursor.close(); - } - } catch (Exception e) { - callback.onFailure(e); - return; - } - - SourceImageEntry entry = new SourceImageEntry(map).fillPreCatalogMetadata(context); - if (entry.isSized() || entry.isSvg()) { - callback.onSuccess(entry.toMap()); - } else { - 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 deleted file mode 100644 index 5d6bc46b0..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java +++ /dev/null @@ -1,42 +0,0 @@ -package deckers.thibault.aves.model.provider; - -import android.content.Context; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.File; - -import deckers.thibault.aves.model.SourceImageEntry; - -class FileImageProvider extends ImageProvider { - @Override - public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @Nullable final String mimeType, @NonNull final ImageOpCallback callback) { - if (mimeType == null) { - callback.onFailure(new Exception("MIME type is null for uri=" + uri)); - return; - } - - SourceImageEntry entry = new SourceImageEntry(uri, mimeType); - - String path = uri.getPath(); - if (path != null) { - try { - File file = new File(path); - if (file.exists()) { - entry.initFromFile(path, file.getName(), file.length(), file.lastModified() / 1000); - } - } catch (SecurityException e) { - callback.onFailure(e); - } - } - entry.fillPreCatalogMetadata(context); - - if (entry.isSized() || entry.isSvg()) { - callback.onSuccess(entry.toMap()); - } else { - 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 deleted file mode 100644 index 41c83f18c..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ /dev/null @@ -1,227 +0,0 @@ -package deckers.thibault.aves.model.provider; - -import android.content.ContentUris; -import android.content.Context; -import android.database.Cursor; -import android.media.MediaScannerConnection; -import android.net.Uri; -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.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import deckers.thibault.aves.model.AvesImageEntry; -import deckers.thibault.aves.model.ExifOrientationOp; -import deckers.thibault.aves.utils.LogUtils; -import deckers.thibault.aves.utils.MimeTypes; -import deckers.thibault.aves.utils.StorageUtils; - -// *** about file access to write/rename/delete -// * primary volume -// until 28/Pie, use `File` -// on 29/Q, use `File` after setting `requestLegacyExternalStorage` flag in the manifest -// from 30/R, use `DocumentFile` (not `File`) after requesting permission to the volume root??? -// * non primary volumes -// on 19/KitKat, use `DocumentFile` (not `File`) after getting permission for each file -// from 21/Lollipop, use `DocumentFile` (not `File`) after getting permission to the volume root - -public abstract class ImageProvider { - private static final String LOG_TAG = LogUtils.createTag(ImageProvider.class); - - public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @Nullable final String mimeType, @NonNull final ImageOpCallback callback) { - callback.onFailure(new UnsupportedOperationException()); - } - - public ListenableFuture delete(final Context context, final String path, final Uri uri) { - return Futures.immediateFailedFuture(new UnsupportedOperationException()); - } - - public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { - callback.onFailure(new UnsupportedOperationException()); - } - - public void rename(final Context context, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) { - if (oldPath == null) { - callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri)); - return; - } - - File oldFile = new File(oldPath); - File newFile = new File(oldFile.getParent(), newFilename); - if (oldFile.equals(newFile)) { - Log.w(LOG_TAG, "new name and old name are the same, path=" + oldPath); - callback.onSuccess(new HashMap<>()); - return; - } - - DocumentFileCompat df = StorageUtils.getDocumentFile(context, oldPath, oldMediaUri); - try { - boolean renamed = df != null && df.renameTo(newFilename); - if (!renamed) { - callback.onFailure(new Exception("failed to rename entry at path=" + oldPath)); - return; - } - } catch (FileNotFoundException e) { - callback.onFailure(e); - return; - } - - MediaScannerConnection.scanFile(context, new String[]{oldPath}, new String[]{mimeType}, null); - scanNewPath(context, newFile.getPath(), mimeType, callback); - } - - // support for writing EXIF - // as of androidx.exifinterface:exifinterface:1.3.0 - private boolean canEditExif(@NonNull String mimeType) { - switch (mimeType) { - case "image/jpeg": - case "image/png": - case "image/webp": - return true; - default: - return false; - } - } - - public void changeOrientation(final Context context, final String path, final Uri uri, final String mimeType, final ExifOrientationOp op, final ImageOpCallback callback) { - if (!canEditExif(mimeType)) { - callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType)); - return; - } - - final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri); - if (originalDocumentFile == null) { - callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri)); - return; - } - - // copy original file to a temporary file for editing - final String editablePath = StorageUtils.copyFileToTemp(originalDocumentFile, path); - if (editablePath == null) { - callback.onFailure(new Exception("failed to create a temporary file for path=" + path)); - return; - } - - Map newFields = new HashMap<>(); - try { - ExifInterface exif = new ExifInterface(editablePath); - // when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)` - // in that case we explicitely set it to `normal` first - // because ExifInterface fails to rotate an image with undefined orientation - // as of androidx.exifinterface:exifinterface:1.3.0 - int currentOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) { - exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL)); - } - switch (op) { - case ROTATE_CW: - exif.rotate(90); - break; - case ROTATE_CCW: - exif.rotate(-90); - break; - case FLIP: - exif.flipHorizontally(); - break; - } - exif.saveAttributes(); - - // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile); - - newFields.put("rotationDegrees", exif.getRotationDegrees()); - newFields.put("isFlipped", exif.isFlipped()); - } catch (IOException e) { - callback.onFailure(e); - return; - } - - MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> { - String[] projection = {MediaStore.MediaColumns.DATE_MODIFIED}; - try { - Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null); - if (cursor != null) { - if (cursor.moveToNext()) { - newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED))); - } - cursor.close(); - } - } catch (Exception e) { - callback.onFailure(e); - return; - } - callback.onSuccess(newFields); - }); - } - - protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) { - MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> { - long contentId = 0; - Uri contentUri = null; - if (newUri != null) { - // newURI is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") - // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") - contentId = ContentUris.parseId(newUri); - if (MimeTypes.isImage(mimeType)) { - contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId); - } else if (MimeTypes.isVideo(mimeType)) { - contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId); - } - } - if (contentUri == null) { - callback.onFailure(new Exception("failed to get content URI of item at path=" + path)); - return; - } - - Map newFields = new HashMap<>(); - // we retrieve updated fields as the renamed/moved file became a new entry in the Media Store - String[] projection = { - MediaStore.MediaColumns.DISPLAY_NAME, - MediaStore.MediaColumns.TITLE, - MediaStore.MediaColumns.DATE_MODIFIED, - }; - try { - Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null); - if (cursor != null) { - if (cursor.moveToNext()) { - newFields.put("uri", contentUri.toString()); - newFields.put("contentId", contentId); - newFields.put("path", path); - newFields.put("displayName", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME))); - newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE))); - newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED))); - } - cursor.close(); - } - } catch (Exception e) { - callback.onFailure(e); - return; - } - - if (newFields.isEmpty()) { - callback.onFailure(new Exception("failed to get item details from provider at contentUri=" + contentUri)); - } else { - callback.onSuccess(newFields); - } - }); - } - - public interface ImageOpCallback { - void onSuccess(Map fields); - - void onFailure(Throwable throwable); - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java deleted file mode 100644 index 140c871ba..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java +++ /dev/null @@ -1,28 +0,0 @@ -package deckers.thibault.aves.model.provider; - -import android.content.ContentResolver; -import android.net.Uri; -import android.provider.MediaStore; - -import androidx.annotation.NonNull; - -public class ImageProviderFactory { - public static ImageProvider getProvider(@NonNull Uri uri) { - String scheme = uri.getScheme(); - - if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(scheme)) { - // a URI's authority is [userinfo@]host[:port] - // but we only want the host when comparing to Media Store's "authority" - if (MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost())) { - return new MediaStoreImageProvider(); - } - return new ContentImageProvider(); - } - - if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(scheme)) { - return new FileImageProvider(); - } - - return null; - } -} 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 deleted file mode 100644 index c1335add8..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ /dev/null @@ -1,443 +0,0 @@ -package deckers.thibault.aves.model.provider; - -import android.annotation.SuppressLint; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build; -import android.os.storage.StorageManager; -import android.os.storage.StorageVolume; -import android.provider.MediaStore; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; - -import com.commonsware.cwac.document.DocumentFileCompat; -import com.google.common.util.concurrent.ListenableFuture; -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.List; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import deckers.thibault.aves.model.AvesImageEntry; -import deckers.thibault.aves.model.SourceImageEntry; -import deckers.thibault.aves.utils.LogUtils; -import deckers.thibault.aves.utils.MimeTypes; -import deckers.thibault.aves.utils.StorageUtils; - -public class MediaStoreImageProvider extends ImageProvider { - private static final String LOG_TAG = LogUtils.createTag(MediaStoreImageProvider.class); - - private static final String[] BASE_PROJECTION = { - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATA, - MediaStore.MediaColumns.MIME_TYPE, - MediaStore.MediaColumns.SIZE, - // TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`? - MediaStore.MediaColumns.TITLE, - MediaStore.MediaColumns.WIDTH, - MediaStore.MediaColumns.HEIGHT, - MediaStore.MediaColumns.DATE_MODIFIED, - }; - - @SuppressLint("InlinedApi") - private static final String[] IMAGE_PROJECTION = Stream.of(BASE_PROJECTION, new String[]{ - // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q - MediaStore.Images.Media.DATE_TAKEN, - MediaStore.Images.Media.ORIENTATION, - }).flatMap(Stream::of).toArray(String[]::new); - - @SuppressLint("InlinedApi") - private static final String[] VIDEO_PROJECTION = Stream.of(BASE_PROJECTION, new String[]{ - // uses MediaStore.Video.Media instead of MediaStore.MediaColumns for APIs < Q - MediaStore.Video.Media.DATE_TAKEN, - MediaStore.Video.Media.DURATION, - }, (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ? - new String[]{ - MediaStore.Video.Media.ORIENTATION, - } : new String[0]).flatMap(Stream::of).toArray(String[]::new); - - public void fetchAll(Context context, Map knownEntries, NewEntryHandler newEntryHandler) { - NewEntryChecker isModified = (contentId, dateModifiedSecs) -> { - final Integer knownDate = knownEntries.get(contentId); - return knownDate == null || knownDate < dateModifiedSecs; - }; - fetchFrom(context, isModified, newEntryHandler, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION); - fetchFrom(context, isModified, newEntryHandler, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION); - } - - @Override - public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @Nullable final String mimeType, @NonNull final ImageOpCallback callback) { - long id = ContentUris.parseId(uri); - NewEntryHandler onSuccess = (entry) -> { - entry.put("uri", uri.toString()); - callback.onSuccess(entry); - }; - NewEntryChecker alwaysValid = (contentId, dateModifiedSecs) -> true; - if (mimeType == null || MimeTypes.isImage(mimeType)) { - Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); - if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION) > 0) return; - } - if (mimeType == null || MimeTypes.isVideo(mimeType)) { - Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); - if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return; - } - callback.onFailure(new Exception("failed to fetch entry at uri=" + uri)); - } - - public List getObsoleteContentIds(Context context, List knownContentIds) { - final ArrayList current = new ArrayList<>(); - current.addAll(getContentIdList(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)); - current.addAll(getContentIdList(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)); - return knownContentIds.stream().filter(id -> !current.contains(id)).collect(Collectors.toList()); - } - - private List getContentIdList(Context context, Uri contentUri) { - final ArrayList foundContentIds = new ArrayList<>(); - try { - Cursor cursor = context.getContentResolver().query(contentUri, new String[]{MediaStore.MediaColumns._ID}, null, null, null); - if (cursor != null) { - int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); - while (cursor.moveToNext()) { - foundContentIds.add(cursor.getInt(idColumn)); - } - cursor.close(); - } - } catch (Exception e) { - Log.e(LOG_TAG, "failed to get content IDs for contentUri=" + contentUri, e); - } - return foundContentIds; - } - - @SuppressLint("InlinedApi") - private int fetchFrom(final Context context, NewEntryChecker newEntryChecker, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) { - int newEntryCount = 0; - final String orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC"; - - final boolean needDuration = projection == VIDEO_PROJECTION; - - try { - Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, orderBy); - if (cursor != null) { - // image & video - int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); - int pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA); - int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE); - int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE); - int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE); - int widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH); - int heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT); - int dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED); - int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN); - - // image & video for API >= Q, only for images for API < Q - int orientationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.ORIENTATION); - - // video only - int durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION); - - while (cursor.moveToNext()) { - final int contentId = cursor.getInt(idColumn); - final int dateModifiedSecs = cursor.getInt(dateModifiedColumn); - if (newEntryChecker.where(contentId, dateModifiedSecs)) { - // this is fine if `contentUri` does not already contain the ID - final Uri itemUri = ContentUris.withAppendedId(contentUri, contentId); - final String path = cursor.getString(pathColumn); - final String mimeType = cursor.getString(mimeTypeColumn); - int width = cursor.getInt(widthColumn); - int height = cursor.getInt(heightColumn); - final long durationMillis = durationColumn != -1 ? cursor.getLong(durationColumn) : 0; - - // check whether the field may be `null` to distinguish it from a legitimate `0` - // this can happen for specific formats (e.g. for PNG, WEBP) - // or for JPEG that were not properly registered - - Map entryMap = new HashMap() {{ - put("uri", itemUri.toString()); - put("path", path); - put("sourceMimeType", mimeType); - put("sourceRotationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0); - put("sizeBytes", cursor.getLong(sizeColumn)); - put("title", cursor.getString(titleColumn)); - put("dateModifiedSecs", dateModifiedSecs); - put("sourceDateTakenMillis", cursor.getLong(dateTakenColumn)); - // only for map export - put("contentId", contentId); - }}; - entryMap.put("width", width); - entryMap.put("height", height); - entryMap.put("durationMillis", durationMillis); - - 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 - SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context); - entryMap = entry.toMap(); - } - - newEntryHandler.handleEntry(entryMap); - if (newEntryCount % 30 == 0) { - Thread.sleep(10); - } - newEntryCount++; - } - } - cursor.close(); - } - } catch (Exception e) { - Log.e(LOG_TAG, "failed to get entries", e); - } - return newEntryCount; - } - - private boolean needSize(String mimeType) { - return !MimeTypes.SVG.equals(mimeType); - } - - @Override - public ListenableFuture delete(final Context context, final String path, final Uri mediaUri) { - SettableFuture future = SettableFuture.create(); - - if (StorageUtils.requireAccessPermission(path)) { - // if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store - // but it doesn't delete the file, even if the app has the permission - try { - DocumentFileCompat df = StorageUtils.getDocumentFile(context, 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) { - future.setException(e); - } - return future; - } - - try { - if (context.getContentResolver().delete(mediaUri, null, null) > 0) { - future.set(null); - } else { - future.setException(new Exception("failed to delete row from content provider")); - } - } catch (Exception e) { - Log.e(LOG_TAG, "failed to delete entry", e); - future.setException(e); - } - return future; - } - - private String getVolumeNameForMediaStore(@NonNull Context context, @NonNull String anyPath) { - String volumeName = "external"; - StorageManager sm = context.getSystemService(StorageManager.class); - if (sm != null) { - StorageVolume volume = sm.getStorageVolume(new File(anyPath)); - if (volume != null && !volume.isPrimary()) { - String uuid = volume.getUuid(); - if (uuid != null) { - // the UUID returned may be uppercase - // but it should be lowercase to work with the MediaStore - volumeName = uuid.toLowerCase(); - } - } - } - return volumeName; - } - - @Override - public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { - DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, destinationDir); - if (destinationDirDocFile == null) { - callback.onFailure(new Exception("failed to create directory at path=" + destinationDir)); - return; - } - - MediaStoreMoveDestination destination = new MediaStoreMoveDestination(context, destinationDir); - if (destination.volumePath == null) { - callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir)); - return; - } - - for (AvesImageEntry entry : entries) { - Uri sourceUri = entry.uri; - String sourcePath = entry.path; - String mimeType = entry.mimeType; - - Map result = new HashMap() {{ - put("uri", sourceUri.toString()); - }}; - - // on API 30 we cannot get access granted directly to a volume root from its document tree, - // but it is still less constraining to use tree document files than to rely on the Media Store - try { - ListenableFuture> newFieldsFuture; -// if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { -// newFieldsFuture = moveSingleByMediaStoreInsert(context, sourcePath, sourceUri, destination, mimeType, copy); -// } else { - newFieldsFuture = moveSingleByTreeDocAndScan(context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy); -// } - Map newFields = newFieldsFuture.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); - } - } - - // 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 - // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage - @RequiresApi(api = Build.VERSION_CODES.Q) - private ListenableFuture> moveSingleByMediaStoreInsert(final Context context, final String sourcePath, final Uri sourceUri, - final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) { - SettableFuture> future = SettableFuture.create(); - - try { - String displayName = new File(sourcePath).getName(); - String destinationFilePath = destination.fullPath + displayName; - - ContentValues contentValues = new ContentValues(); - contentValues.put(MediaStore.MediaColumns.DATA, destinationFilePath); - contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); - // from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device) - contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath); - contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); - String volumeName = destination.volumeNameForMediaStore; - Uri tableUrl = MimeTypes.isVideo(mimeType) ? - MediaStore.Video.Media.getContentUri(volumeName) : - MediaStore.Images.Media.getContentUri(volumeName); - Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues); - if (destinationUri == null) { - future.setException(new Exception("failed to insert row to content resolver")); - } else { - DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(context, sourceUri); - DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(context, destinationUri); - sourceFile.copyTo(destinationFile); - - boolean deletedSource = false; - if (!copy) { - // delete original entry - try { - delete(context, sourcePath, sourceUri).get(); - deletedSource = true; - } catch (ExecutionException | InterruptedException e) { - Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e); - } - } - - Map newFields = new HashMap<>(); - newFields.put("uri", destinationUri.toString()); - newFields.put("contentId", ContentUris.parseId(destinationUri)); - newFields.put("path", destinationFilePath); - newFields.put("deletedSource", deletedSource); - future.set(newFields); - } - } catch (Exception e) { - Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e); - future.setException(e); - } - - return future; - } - - // We can create an item via `DocumentFile.createFile()`, but: - // - we need to scan the file to get the Media Store content URI - // - the underlying document provider controls the new file name - private ListenableFuture> moveSingleByTreeDocAndScan(final Context context, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { - SettableFuture> future = SettableFuture.create(); - - try { - String sourceFileName = new File(sourcePath).getName(); - String desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$", ""); - - // 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 - // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension); - DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.getUri()); - - // `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` - DocumentFileCompat source = DocumentFileCompat.fromSingleUri(context, sourceUri); - source.copyTo(destinationDocFile); - - // the source file name and the created document file name can be different when: - // - a file with the same name already exists, so the name gets a suffix like ` (1)` - // - the original extension does not match the extension appended used by the underlying provider - String fileName = destinationDocFile.getName(); - String destinationFullPath = destinationDir + fileName; - - boolean deletedSource = false; - if (!copy) { - // delete original entry - try { - delete(context, sourcePath, sourceUri).get(); - deletedSource = true; - } catch (ExecutionException | InterruptedException e) { - Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e); - } - } - - boolean finalDeletedSource = deletedSource; - scanNewPath(context, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() { - @Override - public void onSuccess(Map newFields) { - newFields.put("deletedSource", finalDeletedSource); - future.set(newFields); - } - - @Override - public void onFailure(Throwable throwable) { - future.setException(throwable); - } - }); - } 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); - } - - public interface NewEntryChecker { - boolean where(int contentId, int dateModifiedSecs); - } - - class MediaStoreMoveDestination { - final String volumeNameForMediaStore; - final String volumePath; - final String relativePath; - final String fullPath; - - MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) { - fullPath = destinationDir; - volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir); - volumePath = StorageUtils.getVolumePath(context, destinationDir); - relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null; - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index 0f71b94d6..68cba2cc4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.streams import android.content.Context import android.os.Handler import android.os.Looper +import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.provider.MediaStoreImageProvider import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink @@ -10,12 +11,12 @@ import io.flutter.plugin.common.EventChannel.EventSink class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler { private lateinit var eventSink: EventSink private lateinit var handler: Handler - private var knownEntries: Map? = null + private var knownEntries: Map? = null init { if (arguments is Map<*, *>) { @Suppress("UNCHECKED_CAST") - knownEntries = arguments["knownEntries"] as Map? + knownEntries = arguments["knownEntries"] as Map? } } @@ -27,7 +28,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E override fun onCancel(arguments: Any?) {} - private fun success(result: Map) { + private fun success(result: FieldMap) { handler.post { eventSink.success(result) } } @@ -36,7 +37,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E } private fun fetchAll() { - MediaStoreImageProvider().fetchAll(context, knownEntries) { success(it) } + MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) } endOfStream() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index d50f3acab..0f8ae49f7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -24,6 +24,7 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong +import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils import java.io.IOException @@ -48,7 +49,7 @@ class SourceImageEntry { this.sourceMimeType = sourceMimeType } - constructor(map: Map) { + constructor(map: FieldMap) { uri = Uri.parse(map["uri"] as String) path = map["path"] as String? sourceMimeType = map["sourceMimeType"] as String @@ -69,7 +70,7 @@ class SourceImageEntry { this.dateModifiedSecs = dateModifiedSecs } - fun toMap(): Map { + fun toMap(): FieldMap { return hashMapOf( "uri" to uri.toString(), "path" to path, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt new file mode 100644 index 000000000..99b0f168e --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt @@ -0,0 +1,45 @@ +package deckers.thibault.aves.model.provider + +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import deckers.thibault.aves.model.SourceImageEntry + +internal class ContentImageProvider : ImageProvider() { + override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) { + if (mimeType == null) { + callback.onFailure(Exception("MIME type is null for uri=$uri")) + return + } + + val map = hashMapOf( + "uri" to uri.toString(), + "sourceMimeType" to mimeType, + ) + try { + val cursor = context.contentResolver.query(uri, projection, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) } + cursor.close() + } + } catch (e: Exception) { + callback.onFailure(e) + return + } + + val entry = SourceImageEntry(map).fillPreCatalogMetadata(context) + if (entry.isSized || entry.isSvg) { + callback.onSuccess(entry.toMap()) + } else { + callback.onFailure(Exception("entry has no size")) + } + } + + companion object { + private val projection = arrayOf( + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.DISPLAY_NAME + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt new file mode 100644 index 000000000..49faa641f --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -0,0 +1,36 @@ +package deckers.thibault.aves.model.provider + +import android.content.Context +import android.net.Uri +import deckers.thibault.aves.model.SourceImageEntry +import java.io.File + +internal class FileImageProvider : ImageProvider() { + override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) { + if (mimeType == null) { + callback.onFailure(Exception("MIME type is null for uri=$uri")) + return + } + + val entry = SourceImageEntry(uri, mimeType) + + val path = uri.path + if (path != null) { + try { + val file = File(path) + if (file.exists()) { + entry.initFromFile(path, file.name, file.length(), file.lastModified() / 1000) + } + } catch (e: SecurityException) { + callback.onFailure(e) + } + } + entry.fillPreCatalogMetadata(context) + + if (entry.isSized || entry.isSvg) { + callback.onSuccess(entry.toMap()) + } else { + callback.onFailure(Exception("entry has no size")) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt new file mode 100644 index 000000000..b3eb3c03b --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -0,0 +1,194 @@ +package deckers.thibault.aves.model.provider + +import android.content.ContentUris +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.provider.MediaStore +import android.util.Log +import androidx.exifinterface.media.ExifInterface +import com.commonsware.cwac.document.DocumentFileCompat +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import deckers.thibault.aves.model.AvesImageEntry +import deckers.thibault.aves.model.ExifOrientationOp +import deckers.thibault.aves.utils.LogUtils.createTag +import deckers.thibault.aves.utils.MimeTypes.isImage +import deckers.thibault.aves.utils.MimeTypes.isVideo +import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp +import deckers.thibault.aves.utils.StorageUtils.getDocumentFile +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.util.* + +abstract class ImageProvider { + open fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) { + callback.onFailure(UnsupportedOperationException()) + } + + open fun delete(context: Context, uri: Uri, path: String?): ListenableFuture { + return Futures.immediateFailedFuture(UnsupportedOperationException()) + } + + open fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List, callback: ImageOpCallback) { + callback.onFailure(UnsupportedOperationException()) + } + + fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { + val oldFile = File(oldPath) + val newFile = File(oldFile.parent, newFilename) + if (oldFile == newFile) { + Log.w(LOG_TAG, "new name and old name are the same, path=$oldPath") + callback.onSuccess(HashMap()) + return + } + + val df = getDocumentFile(context, oldPath, oldMediaUri) + try { + val renamed = df != null && df.renameTo(newFilename) + if (!renamed) { + callback.onFailure(Exception("failed to rename entry at path=$oldPath")) + return + } + } catch (e: FileNotFoundException) { + callback.onFailure(e) + return + } + + MediaScannerConnection.scanFile(context, arrayOf(oldPath), arrayOf(mimeType), null) + scanNewPath(context, newFile.path, mimeType, callback) + } + + fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) { + if (!canEditExif(mimeType)) { + callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) + return + } + + val originalDocumentFile = getDocumentFile(context, path, uri) + if (originalDocumentFile == null) { + callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) + return + } + + // copy original file to a temporary file for editing + val editablePath = copyFileToTemp(originalDocumentFile, path) + if (editablePath == null) { + callback.onFailure(Exception("failed to create a temporary file for path=$path")) + return + } + + val newFields = HashMap() + try { + val exif = ExifInterface(editablePath) + // when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)` + // in that case we explicitely set it to `normal` first + // because ExifInterface fails to rotate an image with undefined orientation + // as of androidx.exifinterface:exifinterface:1.3.0 + val currentOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) { + exif.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL.toString()) + } + when (op) { + ExifOrientationOp.ROTATE_CW -> exif.rotate(90) + ExifOrientationOp.ROTATE_CCW -> exif.rotate(-90) + ExifOrientationOp.FLIP -> exif.flipHorizontally() + } + exif.saveAttributes() + + // copy the edited temporary file back to the original + DocumentFileCompat.fromFile(File(editablePath)).copyTo(originalDocumentFile) + + newFields["rotationDegrees"] = exif.rotationDegrees + newFields["isFlipped"] = exif.isFlipped + } catch (e: IOException) { + callback.onFailure(e) + return + } + + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ -> + val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED) + try { + val cursor = context.contentResolver.query(uri, projection, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } + cursor.close() + } + } catch (e: Exception) { + callback.onFailure(e) + return@scanFile + } + callback.onSuccess(newFields) + } + } + + // support for writing EXIF + // as of androidx.exifinterface:exifinterface:1.3.0 + private fun canEditExif(mimeType: String): Boolean { + return when (mimeType) { + "image/jpeg", "image/png", "image/webp" -> true + else -> false + } + } + + protected fun scanNewPath(context: Context, path: String, mimeType: String, callback: ImageOpCallback) { + MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? -> + var contentId: Long = 0 + var contentUri: Uri? = null + if (newUri != null) { + // `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") + // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") + contentId = ContentUris.parseId(newUri) + if (isImage(mimeType)) { + contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) + } else if (isVideo(mimeType)) { + contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId) + } + } + if (contentUri == null) { + callback.onFailure(Exception("failed to get content URI of item at path=$path")) + return@scanFile + } + + val newFields = HashMap() + // we retrieve updated fields as the renamed/moved file became a new entry in the Media Store + val projection = arrayOf( + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.TITLE, + ) + try { + val cursor = context.contentResolver.query(contentUri, projection, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + newFields["uri"] = contentUri.toString() + newFields["contentId"] = contentId + newFields["path"] = path + cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) } + cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) } + cursor.close() + } + } catch (e: Exception) { + callback.onFailure(e) + return@scanFile + } + if (newFields.isEmpty()) { + callback.onFailure(Exception("failed to get item details from provider at contentUri=$contentUri")) + } else { + callback.onSuccess(newFields) + } + } + } + + interface ImageOpCallback { + fun onSuccess(fields: FieldMap) + fun onFailure(throwable: Throwable) + } + + companion object { + private val LOG_TAG = createTag(ImageProvider::class.java) + } +} + +typealias FieldMap = MutableMap diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt new file mode 100644 index 000000000..9e4906c3e --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt @@ -0,0 +1,24 @@ +package deckers.thibault.aves.model.provider + +import android.content.ContentResolver +import android.net.Uri +import android.provider.MediaStore +import java.util.* + +object ImageProviderFactory { + @JvmStatic + fun getProvider(uri: Uri): ImageProvider? { + return when (uri.scheme?.toLowerCase(Locale.ROOT)) { + ContentResolver.SCHEME_CONTENT -> { + // a URI's authority is [userinfo@]host[:port] + // but we only want the host when comparing to Media Store's "authority" + return when (uri.host?.toLowerCase(Locale.ROOT)) { + MediaStore.AUTHORITY -> MediaStoreImageProvider() + else -> ContentImageProvider() + } + } + ContentResolver.SCHEME_FILE -> FileImageProvider() + else -> null + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt new file mode 100644 index 000000000..cbed845c9 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -0,0 +1,359 @@ +package deckers.thibault.aves.model.provider + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Log +import com.commonsware.cwac.document.DocumentFileCompat +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import deckers.thibault.aves.model.AvesImageEntry +import deckers.thibault.aves.model.SourceImageEntry +import deckers.thibault.aves.utils.LogUtils.createTag +import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.MimeTypes.isImage +import deckers.thibault.aves.utils.MimeTypes.isVideo +import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent +import deckers.thibault.aves.utils.StorageUtils.getDocumentFile +import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission +import java.io.File +import java.io.FileNotFoundException +import java.util.* +import java.util.concurrent.ExecutionException + +class MediaStoreImageProvider : ImageProvider() { + fun fetchAll(context: Context, knownEntries: Map, handleNewEntry: NewEntryHandler) { + val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean { + val knownDate = knownEntries[contentId] + return knownDate == null || knownDate < dateModifiedSecs + } + fetchFrom(context, isModified, handleNewEntry, IMAGE_CONTENT_URI, IMAGE_PROJECTION) + fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION) + } + + override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) { + val id = ContentUris.parseId(uri) + val onSuccess = fun(entry: FieldMap) { + entry["uri"] = uri.toString() + callback.onSuccess(entry) + } + val alwaysValid = { _: Int, _: Int -> true } + if (mimeType == null || isImage(mimeType)) { + val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id) + if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION) > 0) return + } + if (mimeType == null || isVideo(mimeType)) { + val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id) + if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return + } + callback.onFailure(Exception("failed to fetch entry at uri=$uri")) + } + + fun getObsoleteContentIds(context: Context, knownContentIds: List): List { + val current = arrayListOf().apply { + addAll(getContentIdList(context, IMAGE_CONTENT_URI)) + addAll(getContentIdList(context, VIDEO_CONTENT_URI)) + } + return knownContentIds.filter { id: Int -> !current.contains(id) }.toList() + } + + private fun getContentIdList(context: Context, contentUri: Uri): List { + val foundContentIds = ArrayList() + val projection = arrayOf(MediaStore.MediaColumns._ID) + try { + val cursor = context.contentResolver.query(contentUri, projection, null, null, null) + if (cursor != null) { + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + while (cursor.moveToNext()) { + foundContentIds.add(cursor.getInt(idColumn)) + } + cursor.close() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to get content IDs for contentUri=$contentUri", e) + } + return foundContentIds + } + + private fun fetchFrom( + context: Context, + isValidEntry: NewEntryChecker, + handleNewEntry: NewEntryHandler, + contentUri: Uri, + projection: Array, + ): Int { + var newEntryCount = 0 + val orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC" + try { + val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy) + if (cursor != null) { + // image & video + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA) + val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE) + val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) + val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE) + val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH) + val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT) + val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED) + val dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN) + + // image & video for API >= Q, only for images for API < Q + val orientationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.ORIENTATION) + + // video only + val durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION) + val needDuration = projection.contentEquals(VIDEO_PROJECTION) + + while (cursor.moveToNext()) { + val contentId = cursor.getInt(idColumn) + val dateModifiedSecs = cursor.getInt(dateModifiedColumn) + if (isValidEntry(contentId, dateModifiedSecs)) { + // building `itemUri` this way is fine if `contentUri` does not already contain the ID + val itemUri = ContentUris.withAppendedId(contentUri, contentId.toLong()) + val mimeType = cursor.getString(mimeTypeColumn) + val width = cursor.getInt(widthColumn) + val height = cursor.getInt(heightColumn) + val durationMillis = if (durationColumn != -1) cursor.getLong(durationColumn) else 0L + + var entryMap: FieldMap = hashMapOf( + "uri" to itemUri.toString(), + "path" to cursor.getString(pathColumn), + "sourceMimeType" to mimeType, + "width" to width, + "height" to height, + "sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0, + "sizeBytes" to cursor.getLong(sizeColumn), + "title" to cursor.getString(titleColumn), + "dateModifiedSecs" to dateModifiedSecs, + "sourceDateTakenMillis" to cursor.getLong(dateTakenColumn), + "durationMillis" to durationMillis, + // only for map export + "contentId" to contentId, + ) + + if ((width <= 0 || height <= 0) && needSize(mimeType) + || durationMillis == 0L && needDuration + ) { + // some images are incorrectly registered in the Media Store, + // they are valid but miss some attributes, such as width, height, orientation + val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context) + entryMap = entry.toMap() + } + + handleNewEntry(entryMap) + // TODO TLAD is this necessary? + if (newEntryCount % 30 == 0) { + Thread.sleep(10) + } + newEntryCount++ + } + } + cursor.close() + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to get entries", e) + } + return newEntryCount + } + + private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType + + // `uri` is a media URI, not a document URI + override fun delete(context: Context, uri: Uri, path: String?): ListenableFuture { + val future = SettableFuture.create() + + if (path == null) { + future.setException(Exception("failed to delete file because path is null")) + return future + } + + if (requireAccessPermission(path)) { + // if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store + // but it doesn't delete the file, even if the app has the permission + try { + val df = getDocumentFile(context, path, uri) + if (df != null && df.delete()) { + future.set(null) + } else { + future.setException(Exception("failed to delete file with df=$df")) + } + } catch (e: FileNotFoundException) { + future.setException(e) + } + return future + } + + try { + if (context.contentResolver.delete(uri, null, null) > 0) { + future.set(null) + } else { + future.setException(Exception("failed to delete row from content provider")) + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to delete entry", e) + future.setException(e) + } + return future + } + + override fun moveMultiple( + context: Context, + copy: Boolean, + destinationDir: String, + entries: List, + callback: ImageOpCallback, + ) { + val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) + if (destinationDirDocFile == null) { + callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + return + } + + for (entry in entries) { + val sourceUri = entry.uri + val sourcePath = entry.path + val mimeType = entry.mimeType + + val result = hashMapOf( + "uri" to sourceUri.toString(), + "success" to false, + ) + + if (sourcePath != null) { + // on API 30 we cannot get access granted directly to a volume root from its document tree, + // but it is still less constraining to use tree document files than to rely on the Media Store + // + // Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but: + // - we need to scan the file to get the Media Store content URI + // - the underlying document provider controls the new file name + // + // Relying on the Media Store, 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 + // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage + try { + val newFieldsFuture = moveSingleByTreeDocAndScan( + context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, + ) + result["newFields"] = newFieldsFuture.get() + result["success"] = true + } catch (e: ExecutionException) { + Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e) + } catch (e: InterruptedException) { + Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e) + } + } + callback.onSuccess(result) + } + } + + private fun moveSingleByTreeDocAndScan( + context: Context, + sourcePath: String, + sourceUri: Uri, + destinationDir: String, + destinationDirDocFile: DocumentFileCompat, + mimeType: String, + copy: Boolean, + ): ListenableFuture { + val future = SettableFuture.create() + + try { + val sourceFileName = File(sourcePath).name + val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") + + // 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 + // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first + val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension) + val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) + + // `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` + val source = DocumentFileCompat.fromSingleUri(context, sourceUri) + source.copyTo(destinationDocFile) + + // the source file name and the created document file name can be different when: + // - a file with the same name already exists, so the name gets a suffix like ` (1)` + // - the original extension does not match the extension added by the underlying provider + val fileName = destinationDocFile.name + val destinationFullPath = destinationDir + fileName + + var deletedSource = false + if (!copy) { + // delete original entry + try { + delete(context, sourceUri, sourcePath).get() + deletedSource = true + } catch (e: ExecutionException) { + Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) + } catch (e: InterruptedException) { + Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) + } + } + + scanNewPath(context, destinationFullPath, mimeType, object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) { + fields["deletedSource"] = deletedSource + future.set(fields) + } + + override fun onFailure(throwable: Throwable) { + future.setException(throwable) + } + }) + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to " + (if (copy) "copy" else "move") + " entry", e) + future.setException(e) + } + + return future + } + + companion object { + private val LOG_TAG = createTag(MediaStoreImageProvider::class.java) + + private val IMAGE_CONTENT_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + private val VIDEO_CONTENT_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI + + private val BASE_PROJECTION = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DATA, + MediaStore.MediaColumns.MIME_TYPE, + MediaStore.MediaColumns.SIZE, // TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`? + MediaStore.MediaColumns.TITLE, + MediaStore.MediaColumns.WIDTH, + MediaStore.MediaColumns.HEIGHT, + MediaStore.MediaColumns.DATE_MODIFIED + ) + + private val IMAGE_PROJECTION = arrayOf( + *BASE_PROJECTION, + // uses `MediaStore.Images.Media` instead of `MediaStore.MediaColumns` for APIs < Q + MediaStore.Images.Media.DATE_TAKEN, + MediaStore.Images.Media.ORIENTATION + ) + + private val VIDEO_PROJECTION = arrayOf( + *BASE_PROJECTION, + // uses `MediaStore.Video.Media` instead of `MediaStore.MediaColumns` for APIs < Q + MediaStore.Video.Media.DATE_TAKEN, + MediaStore.Video.Media.DURATION, + *if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf( + MediaStore.Video.Media.ORIENTATION + ) else emptyArray() + ) + } +} + +typealias NewEntryHandler = (entry: FieldMap) -> Unit + +private typealias NewEntryChecker = (contentId: Int, dateModifiedSecs: Int) -> Boolean \ No newline at end of file