catalogue mime type, platform: distinguish source entry from dart call entry, move/copy alternate method for older devices
This commit is contained in:
parent
3af37951fc
commit
e7b48ad136
19 changed files with 305 additions and 133 deletions
|
@ -27,7 +27,7 @@ import java.io.IOException;
|
|||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail;
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
|
@ -35,11 +35,11 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class);
|
||||
|
||||
static class Params {
|
||||
ImageEntry entry;
|
||||
AvesImageEntry entry;
|
||||
Integer width, height, defaultSize;
|
||||
MethodChannel.Result result;
|
||||
|
||||
Params(ImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
|
||||
Params(AvesImageEntry entry, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
|
||||
this.entry = entry;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
@ -116,7 +116,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
|
||||
ImageEntry entry = params.entry;
|
||||
AvesImageEntry entry = params.entry;
|
||||
Integer width = params.width;
|
||||
Integer height = params.height;
|
||||
// Log.d(LOG_TAG, "getThumbnailBytesByResolver width=" + width + ", path=" + entry.path);
|
||||
|
@ -126,7 +126,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
}
|
||||
|
||||
private Bitmap getThumbnailBytesByMediaStore(Params params) {
|
||||
ImageEntry entry = params.entry;
|
||||
AvesImageEntry entry = params.entry;
|
||||
long contentId = ContentUris.parseId(entry.uri);
|
||||
|
||||
ContentResolver resolver = activity.getContentResolver();
|
||||
|
@ -148,7 +148,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
}
|
||||
|
||||
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
|
||||
ImageEntry entry = params.entry;
|
||||
AvesImageEntry entry = params.entry;
|
||||
int width = params.width;
|
||||
int height = params.height;
|
||||
// Log.d(LOG_TAG, "getThumbnailByGlide width=" + width + ", path=" + entry.path);
|
||||
|
|
|
@ -12,7 +12,7 @@ import com.bumptech.glide.Glide;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.model.provider.ImageProvider;
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
|
||||
|
@ -80,7 +80,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
|||
int height = (int) Math.round(heightDip * density);
|
||||
int defaultSize = (int) Math.round(defaultSizeDip * density);
|
||||
|
||||
ImageEntry entry = new ImageEntry(entryMap);
|
||||
AvesImageEntry entry = new AvesImageEntry(entryMap);
|
||||
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(entry, width, height, defaultSize, result));
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ import com.drew.metadata.Tag;
|
|||
import com.drew.metadata.exif.ExifIFD0Directory;
|
||||
import com.drew.metadata.exif.ExifSubIFDDirectory;
|
||||
import com.drew.metadata.exif.GpsDirectory;
|
||||
import com.drew.metadata.file.FileTypeDirectory;
|
||||
import com.drew.metadata.gif.GifAnimationDirectory;
|
||||
import com.drew.metadata.webp.WebpDirectory;
|
||||
import com.drew.metadata.xmp.XmpDirectory;
|
||||
|
@ -51,6 +52,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
public static final String CHANNEL = "deckers.thibault/aves/metadata";
|
||||
|
||||
// catalog metadata
|
||||
private static final String KEY_MIME_TYPE = "mimeType";
|
||||
private static final String KEY_DATE_MILLIS = "dateMillis";
|
||||
private static final String KEY_IS_ANIMATED = "isAnimated";
|
||||
private static final String KEY_LATITUDE = "latitude";
|
||||
|
@ -111,6 +113,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
|
||||
private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
|
||||
String mimeType = call.argument("mimeType");
|
||||
String uri = call.argument("uri");
|
||||
|
||||
Map<String, Map<String, String>> metadataMap = new HashMap<>();
|
||||
|
@ -150,9 +153,11 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
Log.w(LOG_TAG, "failed to get video metadata by ImageMetadataReader for uri=" + uri, e);
|
||||
}
|
||||
|
||||
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
|
||||
if (!videoDir.isEmpty()) {
|
||||
metadataMap.put("Video", videoDir);
|
||||
if (isVideo(mimeType)) {
|
||||
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
|
||||
if (!videoDir.isEmpty()) {
|
||||
metadataMap.put("Video", videoDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (metadataMap.isEmpty()) {
|
||||
|
@ -215,6 +220,18 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
|
||||
// File type
|
||||
FileTypeDirectory fileTypeDir = metadata.getFirstDirectoryOfType(FileTypeDirectory.class);
|
||||
if (fileTypeDir != null) {
|
||||
// the reported `mimeType` (e.g. from Media Store) is sometimes incorrect
|
||||
// file extension is unreliable
|
||||
// `context.getContentResolver().getType()` sometimes return incorrect value
|
||||
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
|
||||
if (fileTypeDir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
|
||||
metadataMap.put(KEY_MIME_TYPE, fileTypeDir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE));
|
||||
}
|
||||
}
|
||||
|
||||
// EXIF
|
||||
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
|
|
|
@ -13,7 +13,7 @@ import java.util.Map;
|
|||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.model.provider.ImageProvider;
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
@ -94,7 +94,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
String destinationDir = (String) argMap.get("destinationPath");
|
||||
if (copy == null || destinationDir == null) return;
|
||||
|
||||
List<ImageEntry> entries = entryMapList.stream().map(ImageEntry::new).collect(Collectors.toList());
|
||||
List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList());
|
||||
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<String, Object> fields) {
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
package deckers.thibault.aves.model;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
|
||||
public class AvesImageEntry {
|
||||
public Uri uri; // content or file URI
|
||||
public String path; // best effort to get local path
|
||||
public String mimeType;
|
||||
@Nullable
|
||||
public Integer width, height, orientationDegrees;
|
||||
@Nullable
|
||||
public Long dateModifiedSecs;
|
||||
|
||||
public AvesImageEntry(Map<String, Object> map) {
|
||||
this.uri = Uri.parse((String) map.get("uri"));
|
||||
this.path = (String) map.get("path");
|
||||
this.mimeType = (String) map.get("mimeType");
|
||||
this.width = (int) map.get("width");
|
||||
this.height = (int) map.get("height");
|
||||
this.orientationDegrees = (int) map.get("orientationDegrees");
|
||||
this.dateModifiedSecs = toLong(map.get("dateModifiedSecs"));
|
||||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return mimeType.startsWith(MimeTypes.VIDEO);
|
||||
}
|
||||
|
||||
// convenience method
|
||||
|
||||
private static Long toLong(Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof Integer) return Long.valueOf((Integer) o);
|
||||
return (long) o;
|
||||
}
|
||||
}
|
|
@ -32,11 +32,11 @@ import deckers.thibault.aves.utils.StorageUtils;
|
|||
|
||||
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
|
||||
|
||||
public class ImageEntry {
|
||||
public class SourceImageEntry {
|
||||
public Uri uri; // content or file URI
|
||||
public String path; // best effort to get local path
|
||||
|
||||
public String mimeType;
|
||||
public String sourceMimeType;
|
||||
@Nullable
|
||||
public String title;
|
||||
@Nullable
|
||||
|
@ -50,13 +50,13 @@ public class ImageEntry {
|
|||
@Nullable
|
||||
private Long durationMillis;
|
||||
|
||||
public ImageEntry() {
|
||||
public SourceImageEntry() {
|
||||
}
|
||||
|
||||
public ImageEntry(Map map) {
|
||||
public SourceImageEntry(Map<String, Object> map) {
|
||||
this.uri = Uri.parse((String) map.get("uri"));
|
||||
this.path = (String) map.get("path");
|
||||
this.mimeType = (String) map.get("mimeType");
|
||||
this.sourceMimeType = (String) map.get("sourceMimeType");
|
||||
this.width = (int) map.get("width");
|
||||
this.height = (int) map.get("height");
|
||||
this.orientationDegrees = (int) map.get("orientationDegrees");
|
||||
|
@ -71,7 +71,7 @@ public class ImageEntry {
|
|||
return new HashMap<String, Object>() {{
|
||||
put("uri", uri.toString());
|
||||
put("path", path);
|
||||
put("mimeType", mimeType);
|
||||
put("sourceMimeType", sourceMimeType);
|
||||
put("width", width);
|
||||
put("height", height);
|
||||
put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0);
|
||||
|
@ -106,22 +106,22 @@ public class ImageEntry {
|
|||
}
|
||||
|
||||
private boolean isImage() {
|
||||
return mimeType.startsWith(MimeTypes.IMAGE);
|
||||
return sourceMimeType.startsWith(MimeTypes.IMAGE);
|
||||
}
|
||||
|
||||
public boolean isSvg() {
|
||||
return mimeType.equals(MimeTypes.SVG);
|
||||
return sourceMimeType.equals(MimeTypes.SVG);
|
||||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return mimeType.startsWith(MimeTypes.VIDEO);
|
||||
private boolean isVideo() {
|
||||
return sourceMimeType.startsWith(MimeTypes.VIDEO);
|
||||
}
|
||||
|
||||
// metadata retrieval
|
||||
|
||||
// expects entry with: uri, mimeType
|
||||
// finds: width, height, orientation/rotation, date, title, duration
|
||||
public ImageEntry fillPreCatalogMetadata(Context context) {
|
||||
public SourceImageEntry fillPreCatalogMetadata(Context context) {
|
||||
fillByMediaMetadataRetriever(context);
|
||||
if (hasSize() && (!isVideo() || hasDuration())) return this;
|
||||
fillByMetadataExtractor(context);
|
||||
|
@ -183,12 +183,12 @@ public class ImageEntry {
|
|||
// expects entry with: uri, mimeType
|
||||
// finds: width, height, orientation, date
|
||||
private void fillByMetadataExtractor(Context context) {
|
||||
if (MimeTypes.SVG.equals(mimeType)) return;
|
||||
if (isSvg()) return;
|
||||
|
||||
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
|
||||
if (MimeTypes.JPEG.equals(mimeType)) {
|
||||
if (MimeTypes.JPEG.equals(sourceMimeType)) {
|
||||
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
|
||||
if (jpegDir != null) {
|
||||
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
|
||||
|
@ -207,7 +207,7 @@ public class ImageEntry {
|
|||
sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
|
||||
}
|
||||
}
|
||||
} else if (MimeTypes.MP4.equals(mimeType)) {
|
||||
} else if (MimeTypes.MP4.equals(sourceMimeType)) {
|
||||
Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class);
|
||||
if (mp4VideoDir != null) {
|
||||
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
|
||||
|
@ -223,7 +223,7 @@ public class ImageEntry {
|
|||
durationMillis = mp4Dir.getLong(Mp4Directory.TAG_DURATION);
|
||||
}
|
||||
}
|
||||
} else if (MimeTypes.AVI.equals(mimeType)) {
|
||||
} else if (MimeTypes.AVI.equals(sourceMimeType)) {
|
||||
AviDirectory aviDir = metadata.getFirstDirectoryOfType(AviDirectory.class);
|
||||
if (aviDir != null) {
|
||||
if (aviDir.containsTag(AviDirectory.TAG_WIDTH)) {
|
||||
|
@ -245,7 +245,7 @@ public class ImageEntry {
|
|||
// expects entry with: uri
|
||||
// finds: width, height
|
||||
private void fillByBitmapDecode(Context context) {
|
||||
if (MimeTypes.SVG.equals(mimeType)) return;
|
||||
if (isSvg()) return;
|
||||
|
||||
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
|
@ -5,14 +5,14 @@ import android.net.Uri;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.SourceImageEntry;
|
||||
|
||||
class ContentImageProvider extends ImageProvider {
|
||||
@Override
|
||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||
ImageEntry entry = new ImageEntry();
|
||||
SourceImageEntry entry = new SourceImageEntry();
|
||||
entry.uri = uri;
|
||||
entry.mimeType = mimeType;
|
||||
entry.sourceMimeType = mimeType;
|
||||
entry.fillPreCatalogMetadata(context);
|
||||
|
||||
if (entry.hasSize() || entry.isSvg()) {
|
||||
|
|
|
@ -7,15 +7,15 @@ import androidx.annotation.NonNull;
|
|||
|
||||
import java.io.File;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.SourceImageEntry;
|
||||
import deckers.thibault.aves.utils.FileUtils;
|
||||
|
||||
class FileImageProvider extends ImageProvider {
|
||||
@Override
|
||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
||||
ImageEntry entry = new ImageEntry();
|
||||
SourceImageEntry entry = new SourceImageEntry();
|
||||
entry.uri = uri;
|
||||
entry.mimeType = mimeType;
|
||||
entry.sourceMimeType = mimeType;
|
||||
|
||||
String path = FileUtils.getPathFromUri(context, uri);
|
||||
if (path != null) {
|
||||
|
|
|
@ -17,14 +17,9 @@ import android.provider.MediaStore;
|
|||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.exifinterface.media.ExifInterface;
|
||||
|
||||
import com.commonsware.cwac.document.DocumentFileCompat;
|
||||
import com.drew.imaging.ImageMetadataReader;
|
||||
import com.drew.imaging.ImageProcessingException;
|
||||
import com.drew.metadata.Metadata;
|
||||
import com.drew.metadata.file.FileTypeDirectory;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
|
@ -32,12 +27,11 @@ import java.io.File;
|
|||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.MetadataHelper;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
|
@ -65,7 +59,7 @@ public abstract class ImageProvider {
|
|||
return Futures.immediateFailedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
|
||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<ImageEntry> entries, @NonNull ImageOpCallback callback) {
|
||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
|
||||
callback.onFailure(new UnsupportedOperationException());
|
||||
}
|
||||
|
||||
|
@ -132,34 +126,8 @@ public abstract class ImageProvider {
|
|||
});
|
||||
}
|
||||
|
||||
// file extension is unreliable
|
||||
// `context.getContentResolver().getType()` sometimes return incorrect value
|
||||
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
|
||||
// so we check with `metadata-extractor`
|
||||
@Nullable
|
||||
private String getMimeType(@NonNull final Context context, @NonNull final Uri uri) {
|
||||
try (InputStream is = context.getContentResolver().openInputStream(uri)) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
FileTypeDirectory fileTypeDir = metadata.getFirstDirectoryOfType(FileTypeDirectory.class);
|
||||
if (fileTypeDir != null) {
|
||||
if (fileTypeDir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
|
||||
return fileTypeDir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE);
|
||||
}
|
||||
}
|
||||
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
|
||||
Log.w(LOG_TAG, "failed to get mime type from metadata for uri=" + uri, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
|
||||
// the reported `mimeType` (e.g. from Media Store) is sometimes incorrect
|
||||
// so we retrieve it again from the file metadata
|
||||
String actualMimeType = getMimeType(activity, uri);
|
||||
if (actualMimeType == null) {
|
||||
actualMimeType = mimeType;
|
||||
}
|
||||
switch (actualMimeType) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.JPEG:
|
||||
rotateJpeg(activity, path, uri, clockwise, callback);
|
||||
break;
|
||||
|
@ -167,7 +135,7 @@ public abstract class ImageProvider {
|
|||
rotatePng(activity, path, uri, clockwise, callback);
|
||||
break;
|
||||
default:
|
||||
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + actualMimeType));
|
||||
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import android.content.ContentUris;
|
|||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
|
@ -31,7 +32,8 @@ import java.util.concurrent.ExecutionException;
|
|||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.model.SourceImageEntry;
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
|
@ -165,7 +167,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
Map<String, Object> entryMap = new HashMap<String, Object>() {{
|
||||
put("uri", itemUri.toString());
|
||||
put("path", path);
|
||||
put("mimeType", mimeType);
|
||||
put("sourceMimeType", mimeType);
|
||||
put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
|
||||
put("sizeBytes", cursor.getLong(sizeColumn));
|
||||
put("title", cursor.getString(titleColumn));
|
||||
|
@ -181,7 +183,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) {
|
||||
// some images are incorrectly registered in the Media Store,
|
||||
// they are valid but miss some attributes, such as width, height, orientation
|
||||
ImageEntry entry = new ImageEntry(entryMap).fillPreCatalogMetadata(context);
|
||||
SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context);
|
||||
entryMap = entry.toMap();
|
||||
width = entry.width != null ? entry.width : 0;
|
||||
height = entry.height != null ? entry.height : 0;
|
||||
|
@ -235,6 +237,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri);
|
||||
if (df != null && df.delete()) {
|
||||
future.set(null);
|
||||
} else {
|
||||
future.setException(new Exception("failed to delete file with df=" + df));
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
|
@ -253,16 +256,14 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
Log.e(LOG_TAG, "failed to delete entry", e);
|
||||
future.setException(e);
|
||||
}
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<ImageEntry> entries, ImageOpCallback callback) {
|
||||
private String getVolumeName(final Activity activity, String path) {
|
||||
String volumeName = "external";
|
||||
StorageManager sm = activity.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(destinationDir));
|
||||
StorageVolume volume = sm.getStorageVolume(new File(path));
|
||||
if (volume != null && !volume.isPrimary()) {
|
||||
String uuid = volume.getUuid();
|
||||
if (uuid != null) {
|
||||
|
@ -272,13 +273,20 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}
|
||||
}
|
||||
}
|
||||
return volumeName;
|
||||
}
|
||||
|
||||
if (!StorageUtils.createDirectoryIfAbsent(activity, destinationDir)) {
|
||||
@Override
|
||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
|
||||
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir);
|
||||
if (destinationDirDocFile == null) {
|
||||
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
||||
return;
|
||||
}
|
||||
|
||||
for (ImageEntry entry : entries) {
|
||||
String volumeName = null;
|
||||
|
||||
for (AvesImageEntry entry : entries) {
|
||||
Uri sourceUri = entry.uri;
|
||||
String sourcePath = entry.path;
|
||||
String mimeType = entry.mimeType;
|
||||
|
@ -287,7 +295,16 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
put("uri", sourceUri.toString());
|
||||
}};
|
||||
try {
|
||||
Map<String, Object> newFields = moveSingle(activity, volumeName, sourcePath, sourceUri, destinationDir, mimeType, copy).get();
|
||||
ListenableFuture<Map<String, Object>> newFieldsFuture;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (volumeName == null) {
|
||||
volumeName = getVolumeName(activity, destinationDir);
|
||||
}
|
||||
newFieldsFuture = moveSingleByMediaStoreInsert(activity, sourcePath, sourceUri, destinationDir, volumeName, mimeType, copy);
|
||||
} else {
|
||||
newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
|
||||
}
|
||||
Map<String, Object> newFields = newFieldsFuture.get();
|
||||
result.put("success", true);
|
||||
result.put("newFields", newFields);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
|
@ -298,17 +315,19 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private ListenableFuture<Map<String, Object>> moveSingle(final Activity activity, final String volumeName, final String sourcePath, final Uri sourceUri, String destinationDir, String mimeType, boolean copy) {
|
||||
// We can create an item via `ContentResolver.insert()` with a path, and retrieve its content URI, but:
|
||||
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
||||
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
||||
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
||||
// - there is no documentation regarding support for usage with removable storage
|
||||
private ListenableFuture<Map<String, Object>> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final String volumeName, final String mimeType, final boolean copy) {
|
||||
SettableFuture<Map<String, Object>> future = SettableFuture.create();
|
||||
|
||||
try {
|
||||
String destinationPath = destinationDir + File.separator + new File(sourcePath).getName();
|
||||
|
||||
// from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device)
|
||||
// from API 26, retrieve document URI from mediastore URI with `MediaStore.getDocumentUri(...)`
|
||||
// DocumentFile.getUri() is same as original uri: "content://media/external/images/media/58457"
|
||||
// DocumentFile.getParentFile() is null without picking a tree first
|
||||
// DocumentsContract.copyDocument() and moveDocument() need parent doc uri
|
||||
|
||||
String destinationPath = destinationDir + File.separator + new File(sourcePath).getName();
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
||||
|
@ -324,10 +343,12 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
||||
source.copyTo(destination);
|
||||
|
||||
boolean deletedSource = false;
|
||||
if (!copy) {
|
||||
// delete original entry
|
||||
try {
|
||||
delete(activity, sourcePath, sourceUri).get();
|
||||
deletedSource = true;
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e);
|
||||
}
|
||||
|
@ -337,6 +358,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
newFields.put("uri", destinationUri.toString());
|
||||
newFields.put("contentId", ContentUris.parseId(destinationUri));
|
||||
newFields.put("path", destinationPath);
|
||||
newFields.put("deletedSource", deletedSource);
|
||||
future.set(newFields);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
@ -347,6 +369,80 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
return future;
|
||||
}
|
||||
|
||||
// We can create an item via `DocumentFile.createFile()`, but:
|
||||
// - we need to scan the file to get the Media Store content URI
|
||||
// - there is no control on the filename (derived from the display name, MIME type)
|
||||
private ListenableFuture<Map<String, Object>> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) {
|
||||
SettableFuture<Map<String, Object>> future = SettableFuture.create();
|
||||
|
||||
try {
|
||||
// TODO TLAD more robust `destinationPath`, as it could be broken:
|
||||
// - if a file with the same name already exists, and the name gets appended ` (1)`
|
||||
// - if the original extension does not match the appended extension from the provided MIME type
|
||||
final String fileName = new File(sourcePath).getName();
|
||||
final String displayName = fileName.replaceFirst("[.][^.]+$", "");
|
||||
String destinationPath = destinationDir + File.separator + fileName;
|
||||
|
||||
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
||||
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
|
||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||
// through a document URI, not a tree URI
|
||||
DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, displayName);
|
||||
DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.getUri());
|
||||
// `DocumentFile.getParentFile()` is null without picking a tree first
|
||||
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
|
||||
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
|
||||
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
|
||||
source.copyTo(destinationDocFile);
|
||||
|
||||
boolean deletedSource = false;
|
||||
if (!copy) {
|
||||
// delete original entry
|
||||
try {
|
||||
delete(activity, sourcePath, sourceUri).get();
|
||||
deletedSource = true;
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
boolean finalDeletedSource = deletedSource;
|
||||
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||
Map<String, Object> newFields = new HashMap<>();
|
||||
if (newUri != null) {
|
||||
// we retrieve updated fields as the moved file became a new entry in the Media Store
|
||||
String[] projection = {MediaStore.MediaColumns._ID};
|
||||
try {
|
||||
Cursor cursor = activity.getContentResolver().query(newUri, projection, null, null, null);
|
||||
if (cursor != null) {
|
||||
if (cursor.moveToNext()) {
|
||||
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
|
||||
newFields.put("uri", newUri.toString());
|
||||
newFields.put("contentId", contentId);
|
||||
newFields.put("path", destinationPath);
|
||||
newFields.put("deletedSource", finalDeletedSource);
|
||||
}
|
||||
cursor.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
future.setException(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (newFields.isEmpty()) {
|
||||
future.setException(new Exception("failed to scan moved item at path=" + destinationPath));
|
||||
} else {
|
||||
future.set(newFields);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e);
|
||||
future.setException(e);
|
||||
}
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public interface NewEntryHandler {
|
||||
void handleEntry(Map<String, Object> entry);
|
||||
}
|
||||
|
|
|
@ -174,6 +174,16 @@ public class StorageUtils {
|
|||
};
|
||||
}
|
||||
|
||||
// variation on `DocumentFileCompat.findFile()` to allow case insensitive search
|
||||
static private DocumentFileCompat findFileIgnoreCase(DocumentFileCompat documentFile, String displayName) {
|
||||
for (DocumentFileCompat doc : documentFile.listFiles()) {
|
||||
if (displayName.equalsIgnoreCase(doc.getName())) {
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Optional<DocumentFileCompat> getSdCardDocumentFile(Context context, Uri rootTreeUri, String[] storageVolumeRoots, String path) {
|
||||
if (rootTreeUri == null || storageVolumeRoots == null || path == null) {
|
||||
return Optional.empty();
|
||||
|
@ -187,7 +197,7 @@ public class StorageUtils {
|
|||
// follow the entry path down the document tree
|
||||
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, path);
|
||||
while (pathIterator.hasNext()) {
|
||||
documentFile = documentFile.findFile(pathIterator.next());
|
||||
documentFile = findFileIgnoreCase(documentFile, pathIterator.next());
|
||||
if (documentFile == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
@ -224,11 +234,13 @@ public class StorageUtils {
|
|||
}
|
||||
}
|
||||
|
||||
public static boolean createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) {
|
||||
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
|
||||
// returns null if directory does not exist and could not be created
|
||||
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) {
|
||||
if (Env.requireAccessPermission(directoryPath)) {
|
||||
Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
|
||||
if (parentFile == null) return false;
|
||||
if (parentFile == null) return null;
|
||||
|
||||
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
|
||||
if (!directoryPath.endsWith(File.separator)) {
|
||||
|
@ -237,24 +249,31 @@ public class StorageUtils {
|
|||
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath);
|
||||
while (pathIterator.hasNext()) {
|
||||
String dirName = pathIterator.next();
|
||||
DocumentFileCompat dirFile = parentFile.findFile(dirName);
|
||||
DocumentFileCompat dirFile = findFileIgnoreCase(parentFile, dirName);
|
||||
if (dirFile == null || !dirFile.exists()) {
|
||||
try {
|
||||
dirFile = parentFile.createDirectory(dirName);
|
||||
if (dirFile != null) {
|
||||
parentFile = dirFile;
|
||||
if (dirFile == null) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile);
|
||||
return null;
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, e);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
parentFile = dirFile;
|
||||
}
|
||||
return true;
|
||||
return parentFile;
|
||||
} else {
|
||||
File directory = new File(directoryPath);
|
||||
if (directory.exists()) return true;
|
||||
return directory.mkdirs();
|
||||
if (!directory.exists()) {
|
||||
if (!directory.mkdirs()) {
|
||||
Log.e(LOG_TAG, "failed to create directories at path=" + directoryPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return DocumentFileCompat.fromFile(directory);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ class ImageEntry {
|
|||
String _directory;
|
||||
String _filename;
|
||||
int contentId;
|
||||
final String mimeType;
|
||||
final String sourceMimeType;
|
||||
int width;
|
||||
int height;
|
||||
int orientationDegrees;
|
||||
|
@ -40,7 +40,7 @@ class ImageEntry {
|
|||
this.uri,
|
||||
String path,
|
||||
this.contentId,
|
||||
this.mimeType,
|
||||
this.sourceMimeType,
|
||||
this.width,
|
||||
this.height,
|
||||
this.orientationDegrees,
|
||||
|
@ -63,7 +63,7 @@ class ImageEntry {
|
|||
uri: uri ?? uri,
|
||||
path: path ?? this.path,
|
||||
contentId: copyContentId,
|
||||
mimeType: mimeType,
|
||||
sourceMimeType: sourceMimeType,
|
||||
width: width,
|
||||
height: height,
|
||||
orientationDegrees: orientationDegrees,
|
||||
|
@ -79,12 +79,13 @@ class ImageEntry {
|
|||
return copied;
|
||||
}
|
||||
|
||||
// from DB or platform source entry
|
||||
factory ImageEntry.fromMap(Map map) {
|
||||
return ImageEntry(
|
||||
uri: map['uri'] as String,
|
||||
path: map['path'] as String,
|
||||
contentId: map['contentId'] as int,
|
||||
mimeType: map['mimeType'] as String,
|
||||
sourceMimeType: map['sourceMimeType'] as String,
|
||||
width: map['width'] as int,
|
||||
height: map['height'] as int,
|
||||
orientationDegrees: map['orientationDegrees'] as int,
|
||||
|
@ -96,12 +97,13 @@ class ImageEntry {
|
|||
);
|
||||
}
|
||||
|
||||
// for DB only
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'uri': uri,
|
||||
'path': path,
|
||||
'contentId': contentId,
|
||||
'mimeType': mimeType,
|
||||
'sourceMimeType': sourceMimeType,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'orientationDegrees': orientationDegrees,
|
||||
|
@ -142,6 +144,10 @@ class ImageEntry {
|
|||
return _filename;
|
||||
}
|
||||
|
||||
// the MIME type reported by the Media Store is unreliable
|
||||
// so we use the one found during cataloguing if possible
|
||||
String get mimeType => catalogMetadata?.mimeType ?? sourceMimeType;
|
||||
|
||||
String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
|
||||
|
||||
bool get isFavourite => favourites.isFavourite(this);
|
||||
|
|
|
@ -30,12 +30,13 @@ class DateMetadata {
|
|||
class CatalogMetadata {
|
||||
final int contentId, dateMillis, videoRotation;
|
||||
final bool isAnimated;
|
||||
final String xmpSubjects, xmpTitleDescription;
|
||||
final String mimeType, xmpSubjects, xmpTitleDescription;
|
||||
final double latitude, longitude;
|
||||
Address address;
|
||||
|
||||
CatalogMetadata({
|
||||
this.contentId,
|
||||
this.mimeType,
|
||||
this.dateMillis,
|
||||
this.isAnimated,
|
||||
this.videoRotation,
|
||||
|
@ -53,6 +54,7 @@ class CatalogMetadata {
|
|||
}) {
|
||||
return CatalogMetadata(
|
||||
contentId: contentId ?? this.contentId,
|
||||
mimeType: mimeType,
|
||||
dateMillis: dateMillis,
|
||||
isAnimated: isAnimated,
|
||||
videoRotation: videoRotation,
|
||||
|
@ -67,6 +69,7 @@ class CatalogMetadata {
|
|||
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
|
||||
return CatalogMetadata(
|
||||
contentId: map['contentId'],
|
||||
mimeType: map['mimeType'],
|
||||
dateMillis: map['dateMillis'] ?? 0,
|
||||
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
|
||||
videoRotation: map['videoRotation'] ?? 0,
|
||||
|
@ -79,6 +82,7 @@ class CatalogMetadata {
|
|||
|
||||
Map<String, dynamic> toMap({bool boolAsInteger = false}) => {
|
||||
'contentId': contentId,
|
||||
'mimeType': mimeType,
|
||||
'dateMillis': dateMillis,
|
||||
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
|
||||
'videoRotation': videoRotation,
|
||||
|
@ -90,7 +94,7 @@ class CatalogMetadata {
|
|||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ class MetadataDb {
|
|||
'contentId INTEGER PRIMARY KEY'
|
||||
', uri TEXT'
|
||||
', path TEXT'
|
||||
', mimeType TEXT'
|
||||
', sourceMimeType TEXT'
|
||||
', width INTEGER'
|
||||
', height INTEGER'
|
||||
', orientationDegrees INTEGER'
|
||||
|
@ -46,6 +46,7 @@ class MetadataDb {
|
|||
')');
|
||||
await db.execute('CREATE TABLE $metadataTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', mimeType TEXT'
|
||||
', dateMillis INTEGER'
|
||||
', isAnimated INTEGER'
|
||||
', videoRotation INTEGER'
|
||||
|
|
|
@ -16,6 +16,18 @@ class ImageFileService {
|
|||
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
|
||||
static const double thumbnailDefaultSize = 64.0;
|
||||
|
||||
static Map<String, dynamic> _toPlatformEntryMap(ImageEntry entry) {
|
||||
return {
|
||||
'uri': entry.uri,
|
||||
'path': entry.path,
|
||||
'mimeType': entry.mimeType,
|
||||
'width': entry.width,
|
||||
'height': entry.height,
|
||||
'orientationDegrees': entry.orientationDegrees,
|
||||
'dateModifiedSecs': entry.dateModifiedSecs,
|
||||
};
|
||||
}
|
||||
|
||||
// knownEntries: map of contentId -> dateModifiedSecs
|
||||
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) {
|
||||
try {
|
||||
|
@ -95,7 +107,7 @@ class ImageFileService {
|
|||
() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
|
||||
'entry': entry.toMap(),
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'widthDip': width,
|
||||
'heightDip': height,
|
||||
'defaultSizeDip': thumbnailDefaultSize,
|
||||
|
@ -128,7 +140,7 @@ class ImageFileService {
|
|||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'delete',
|
||||
'entries': entries.map((e) => e.toMap()).toList(),
|
||||
'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(),
|
||||
}).map((event) => ImageOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
|
@ -141,7 +153,7 @@ class ImageFileService {
|
|||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'move',
|
||||
'entries': entries.map((e) => e.toMap()).toList(),
|
||||
'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(),
|
||||
'copy': copy,
|
||||
'destinationPath': destinationAlbum,
|
||||
}).map((event) => MoveOpEvent.fromMap(event));
|
||||
|
@ -155,7 +167,7 @@ class ImageFileService {
|
|||
try {
|
||||
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
||||
'entry': entry.toMap(),
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'newName': newName,
|
||||
}) as Map;
|
||||
return result;
|
||||
|
@ -169,7 +181,7 @@ class ImageFileService {
|
|||
try {
|
||||
// return map with: 'width' 'height' 'orientationDegrees' (all optional)
|
||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||
'entry': entry.toMap(),
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'clockwise': clockwise,
|
||||
}) as Map;
|
||||
return result;
|
||||
|
|
|
@ -29,6 +29,7 @@ class MetadataService {
|
|||
final call = () async {
|
||||
try {
|
||||
// return map with:
|
||||
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
|
||||
// 'dateMillis': date taken in milliseconds since Epoch (long)
|
||||
// 'isAnimated': animated gif/webp (bool)
|
||||
// 'latitude': latitude (double)
|
||||
|
|
|
@ -251,34 +251,39 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed));
|
||||
opStream.listen(
|
||||
(event) => processed.add(event),
|
||||
onError: (error) => onComplete(),
|
||||
onError: (error) {
|
||||
debugPrint('_showOpReport error=$error');
|
||||
onComplete();
|
||||
},
|
||||
onDone: onComplete,
|
||||
);
|
||||
|
||||
_opReportOverlayEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return StreamBuilder<T>(
|
||||
stream: opStream,
|
||||
builder: (context, snapshot) {
|
||||
Widget child = const SizedBox.shrink();
|
||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
|
||||
final percent = processed.length.toDouble() / selection.length;
|
||||
child = CircularPercentIndicator(
|
||||
percent: percent,
|
||||
lineWidth: 16,
|
||||
radius: 160,
|
||||
backgroundColor: Colors.white24,
|
||||
progressColor: Theme.of(context).accentColor,
|
||||
animation: true,
|
||||
center: Text(NumberFormat.percentPattern().format(percent)),
|
||||
animateFromLastPercent: true,
|
||||
return AbsorbPointer(
|
||||
child: StreamBuilder<T>(
|
||||
stream: opStream,
|
||||
builder: (context, snapshot) {
|
||||
Widget child = const SizedBox.shrink();
|
||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
|
||||
final percent = processed.length.toDouble() / selection.length;
|
||||
child = CircularPercentIndicator(
|
||||
percent: percent,
|
||||
lineWidth: 16,
|
||||
radius: 160,
|
||||
backgroundColor: Colors.white24,
|
||||
progressColor: Theme.of(context).accentColor,
|
||||
animation: true,
|
||||
center: Text(NumberFormat.percentPattern().format(percent)),
|
||||
animateFromLastPercent: true,
|
||||
);
|
||||
}
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.collectionOpOverlayAnimation,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.collectionOpOverlayAnimation,
|
||||
child: child,
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
Overlay.of(context).insert(_opReportOverlayEntry);
|
||||
|
|
|
@ -92,6 +92,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
Text('DB metadata:${data == null ? ' no row' : ''}'),
|
||||
if (data != null)
|
||||
InfoRowGroup({
|
||||
'mimeType': '${data.mimeType}',
|
||||
'dateMillis': '${data.dateMillis}',
|
||||
'isAnimated': '${data.isAnimated}',
|
||||
'videoRotation': '${data.videoRotation}',
|
||||
|
@ -132,6 +133,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
if (catalog != null)
|
||||
InfoRowGroup({
|
||||
'contentId': '${catalog.contentId}',
|
||||
'mimeType': '${catalog.mimeType}',
|
||||
'dateMillis': '${catalog.dateMillis}',
|
||||
'isAnimated': '${catalog.isAnimated}',
|
||||
'videoRotation': '${catalog.videoRotation}',
|
||||
|
|
|
@ -113,7 +113,7 @@ class StatsPage extends StatelessWidget {
|
|||
}
|
||||
|
||||
String _cleanMime(String mime) {
|
||||
mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '');
|
||||
mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', '');
|
||||
return mime;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue