Kotlin migration (WIP)

This commit is contained in:
Thibault Deckers 2020-10-20 10:58:47 +09:00
parent 175318058b
commit 8fc366cd89
14 changed files with 696 additions and 814 deletions

View file

@ -9,6 +9,8 @@ import androidx.annotation.NonNull;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import org.jetbrains.annotations.NotNull;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -70,7 +72,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String uri = call.argument("uri"); String uri = call.argument("uri");
String mimeType = call.argument("mimeType"); String mimeType = call.argument("mimeType");
Number dateModifiedSecs = (Number)call.argument("dateModifiedSecs"); Number dateModifiedSecs = call.argument("dateModifiedSecs");
Integer rotationDegrees = call.argument("rotationDegrees"); Integer rotationDegrees = call.argument("rotationDegrees");
Boolean isFlipped = call.argument("isFlipped"); Boolean isFlipped = call.argument("isFlipped");
Double widthDip = call.argument("widthDip"); Double widthDip = call.argument("widthDip");
@ -116,13 +118,12 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
} }
provider.fetchSingle(activity, uri, mimeType, new ImageProvider.ImageOpCallback() { provider.fetchSingle(activity, uri, mimeType, new ImageProvider.ImageOpCallback() {
@Override public void onSuccess(@NotNull Map<String, Object> fields) {
public void onSuccess(Map<String, Object> entry) { result.success(fields);
result.success(entry);
} }
@Override @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()); 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")); Uri uri = Uri.parse((String) entryMap.get("uri"));
String path = (String) entryMap.get("path"); String path = (String) entryMap.get("path");
String mimeType = (String) entryMap.get("mimeType"); 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); ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) { if (provider == null) {
@ -146,12 +151,12 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
} }
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.ImageOpCallback() { provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.ImageOpCallback() {
@Override @Override
public void onSuccess(Map<String, Object> newFields) { public void onSuccess(@NotNull Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields)); new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
} }
@Override @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())); 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")); Uri uri = Uri.parse((String) entryMap.get("uri"));
String path = (String) entryMap.get("path"); String path = (String) entryMap.get("path");
String mimeType = (String) entryMap.get("mimeType"); 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); ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) { if (provider == null) {
@ -176,12 +185,12 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
ExifOrientationOp op = clockwise ? ExifOrientationOp.ROTATE_CW : ExifOrientationOp.ROTATE_CCW; ExifOrientationOp op = clockwise ? ExifOrientationOp.ROTATE_CW : ExifOrientationOp.ROTATE_CCW;
provider.changeOrientation(activity, path, uri, mimeType, op, new ImageProvider.ImageOpCallback() { provider.changeOrientation(activity, path, uri, mimeType, op, new ImageProvider.ImageOpCallback() {
@Override @Override
public void onSuccess(Map<String, Object> newFields) { public void onSuccess(@NotNull Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields)); new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
} }
@Override @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())); 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")); Uri uri = Uri.parse((String) entryMap.get("uri"));
String path = (String) entryMap.get("path"); String path = (String) entryMap.get("path");
String mimeType = (String) entryMap.get("mimeType"); 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); ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) { if (provider == null) {
@ -204,12 +217,12 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
} }
provider.changeOrientation(activity, path, uri, mimeType, ExifOrientationOp.FLIP, new ImageProvider.ImageOpCallback() { provider.changeOrientation(activity, path, uri, mimeType, ExifOrientationOp.FLIP, new ImageProvider.ImageOpCallback() {
@Override @Override
public void onSuccess(Map<String, Object> newFields) { public void onSuccess(@NotNull Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields)); new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
} }
@Override @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())); new Handler(Looper.getMainLooper()).post(() -> result.error("flip-failure", "failed to flip", throwable.getMessage()));
} }
}); });

View file

@ -6,6 +6,8 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import org.jetbrains.annotations.NotNull;
import java.io.File; import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@ -64,7 +66,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
} }
// {String uri, bool success, [Map<String, Object> newFields]} // {String uri, bool success, [Map<String, Object> newFields]}
private void success(final Map<String, Object> result) { private void success(final @NotNull Map<String, ?> result) {
handler.post(() -> eventSink.success(result)); handler.post(() -> eventSink.success(result));
} }
@ -102,12 +104,12 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList()); List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList());
provider.moveMultiple(context, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { provider.moveMultiple(context, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
@Override @Override
public void onSuccess(Map<String, Object> fields) { public void onSuccess(@NotNull Map<String, Object> fields) {
success(fields); success(fields);
} }
@Override @Override
public void onFailure(Throwable throwable) { public void onFailure(@NotNull Throwable throwable) {
error("move-failure", "failed to move entries", throwable); error("move-failure", "failed to move entries", throwable);
} }
}); });
@ -138,7 +140,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
put("uri", uriString); put("uri", uriString);
}}; }};
try { try {
provider.delete(context, path, uri).get(); provider.delete(context, uri, path).get();
result.put("success", true); result.put("success", true);
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + path, e); Log.w(LOG_TAG, "failed to delete entry with path=" + path, e);

View file

@ -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<String, Object> 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"));
}
}
}

View file

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

View file

@ -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<Object> 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<AvesImageEntry> 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<String, Object> 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<String, Object> 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<String, Object> fields);
void onFailure(Throwable throwable);
}
}

View file

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

View file

@ -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<Integer, Integer> 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<Integer> getObsoleteContentIds(Context context, List<Integer> knownContentIds) {
final ArrayList<Integer> 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<Integer> getContentIdList(Context context, Uri contentUri) {
final ArrayList<Integer> 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<String, Object> entryMap = new HashMap<String, Object>() {{
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<Object> delete(final Context context, final String path, final Uri mediaUri) {
SettableFuture<Object> 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<AvesImageEntry> 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<String, Object> result = new HashMap<String, Object>() {{
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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> moveSingleByMediaStoreInsert(final Context context, final String sourcePath, final Uri sourceUri,
final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) {
SettableFuture<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> moveSingleByTreeDocAndScan(final Context context, 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 {
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<String, Object> 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<String, Object> 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;
}
}
}

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.streams
import android.content.Context import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink 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 { class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink private lateinit var eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
private var knownEntries: Map<Int, Int>? = null private var knownEntries: Map<Int, Int?>? = null
init { init {
if (arguments is Map<*, *>) { if (arguments is Map<*, *>) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
knownEntries = arguments["knownEntries"] as Map<Int, Int>? knownEntries = arguments["knownEntries"] as Map<Int, Int?>?
} }
} }
@ -27,7 +28,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
override fun onCancel(arguments: Any?) {} override fun onCancel(arguments: Any?) {}
private fun success(result: Map<String, Any>) { private fun success(result: FieldMap) {
handler.post { eventSink.success(result) } handler.post { eventSink.success(result) }
} }
@ -36,7 +37,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
} }
private fun fetchAll() { private fun fetchAll() {
MediaStoreImageProvider().fetchAll(context, knownEntries) { success(it) } MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) }
endOfStream() endOfStream()
} }

View file

@ -24,6 +24,7 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong 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.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException import java.io.IOException
@ -48,7 +49,7 @@ class SourceImageEntry {
this.sourceMimeType = sourceMimeType this.sourceMimeType = sourceMimeType
} }
constructor(map: Map<String, Any?>) { constructor(map: FieldMap) {
uri = Uri.parse(map["uri"] as String) uri = Uri.parse(map["uri"] as String)
path = map["path"] as String? path = map["path"] as String?
sourceMimeType = map["sourceMimeType"] as String sourceMimeType = map["sourceMimeType"] as String
@ -69,7 +70,7 @@ class SourceImageEntry {
this.dateModifiedSecs = dateModifiedSecs this.dateModifiedSecs = dateModifiedSecs
} }
fun toMap(): Map<String, Any?> { fun toMap(): FieldMap {
return hashMapOf( return hashMapOf(
"uri" to uri.toString(), "uri" to uri.toString(),
"path" to path, "path" to path,

View file

@ -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<String, Any?>(
"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
)
}
}

View file

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

View file

@ -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<Any?> {
return Futures.immediateFailedFuture(UnsupportedOperationException())
}
open fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesImageEntry>, 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<String, Any?>()
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<String, Any?>()
// 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<String, Any?>

View file

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

View file

@ -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<Int, Int?>, 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<Int>): List<Int> {
val current = arrayListOf<Int>().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<Int> {
val foundContentIds = ArrayList<Int>()
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<String>,
): 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<Any?> {
val future = SettableFuture.create<Any?>()
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<AvesImageEntry>,
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<String, Any?>(
"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<FieldMap> {
val future = SettableFuture.create<FieldMap>()
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