Kotlin migration (WIP)
This commit is contained in:
parent
175318058b
commit
8fc366cd89
14 changed files with 696 additions and 814 deletions
|
@ -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()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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?>
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
Loading…
Reference in a new issue