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 java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail;
|
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 deckers.thibault.aves.utils.Utils;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
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);
|
private static final String LOG_TAG = Utils.createLogTag(ImageDecodeTask.class);
|
||||||
|
|
||||||
static class Params {
|
static class Params {
|
||||||
ImageEntry entry;
|
AvesImageEntry entry;
|
||||||
Integer width, height, defaultSize;
|
Integer width, height, defaultSize;
|
||||||
MethodChannel.Result result;
|
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.entry = entry;
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
|
@ -116,7 +116,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||||
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
|
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
|
||||||
ImageEntry entry = params.entry;
|
AvesImageEntry entry = params.entry;
|
||||||
Integer width = params.width;
|
Integer width = params.width;
|
||||||
Integer height = params.height;
|
Integer height = params.height;
|
||||||
// Log.d(LOG_TAG, "getThumbnailBytesByResolver width=" + width + ", path=" + entry.path);
|
// 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) {
|
private Bitmap getThumbnailBytesByMediaStore(Params params) {
|
||||||
ImageEntry entry = params.entry;
|
AvesImageEntry entry = params.entry;
|
||||||
long contentId = ContentUris.parseId(entry.uri);
|
long contentId = ContentUris.parseId(entry.uri);
|
||||||
|
|
||||||
ContentResolver resolver = activity.getContentResolver();
|
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 {
|
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
|
||||||
ImageEntry entry = params.entry;
|
AvesImageEntry entry = params.entry;
|
||||||
int width = params.width;
|
int width = params.width;
|
||||||
int height = params.height;
|
int height = params.height;
|
||||||
// Log.d(LOG_TAG, "getThumbnailByGlide width=" + width + ", path=" + entry.path);
|
// 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.List;
|
||||||
import java.util.Map;
|
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.ImageProvider;
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
|
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 height = (int) Math.round(heightDip * density);
|
||||||
int defaultSize = (int) Math.round(defaultSizeDip * 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));
|
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.ExifIFD0Directory;
|
||||||
import com.drew.metadata.exif.ExifSubIFDDirectory;
|
import com.drew.metadata.exif.ExifSubIFDDirectory;
|
||||||
import com.drew.metadata.exif.GpsDirectory;
|
import com.drew.metadata.exif.GpsDirectory;
|
||||||
|
import com.drew.metadata.file.FileTypeDirectory;
|
||||||
import com.drew.metadata.gif.GifAnimationDirectory;
|
import com.drew.metadata.gif.GifAnimationDirectory;
|
||||||
import com.drew.metadata.webp.WebpDirectory;
|
import com.drew.metadata.webp.WebpDirectory;
|
||||||
import com.drew.metadata.xmp.XmpDirectory;
|
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";
|
public static final String CHANNEL = "deckers.thibault/aves/metadata";
|
||||||
|
|
||||||
// catalog metadata
|
// catalog metadata
|
||||||
|
private static final String KEY_MIME_TYPE = "mimeType";
|
||||||
private static final String KEY_DATE_MILLIS = "dateMillis";
|
private static final String KEY_DATE_MILLIS = "dateMillis";
|
||||||
private static final String KEY_IS_ANIMATED = "isAnimated";
|
private static final String KEY_IS_ANIMATED = "isAnimated";
|
||||||
private static final String KEY_LATITUDE = "latitude";
|
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) {
|
private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
|
||||||
|
String mimeType = call.argument("mimeType");
|
||||||
String uri = call.argument("uri");
|
String uri = call.argument("uri");
|
||||||
|
|
||||||
Map<String, Map<String, String>> metadataMap = new HashMap<>();
|
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);
|
Log.w(LOG_TAG, "failed to get video metadata by ImageMetadataReader for uri=" + uri, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
|
if (isVideo(mimeType)) {
|
||||||
if (!videoDir.isEmpty()) {
|
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
|
||||||
metadataMap.put("Video", videoDir);
|
if (!videoDir.isEmpty()) {
|
||||||
|
metadataMap.put("Video", videoDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadataMap.isEmpty()) {
|
if (metadataMap.isEmpty()) {
|
||||||
|
@ -215,6 +220,18 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
||||||
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) {
|
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) {
|
||||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
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
|
// EXIF
|
||||||
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
|
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
|
||||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||||
|
|
|
@ -13,7 +13,7 @@ import java.util.Map;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.stream.Collectors;
|
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.ImageProvider;
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||||
import deckers.thibault.aves.utils.Utils;
|
import deckers.thibault.aves.utils.Utils;
|
||||||
|
@ -94,7 +94,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
||||||
String destinationDir = (String) argMap.get("destinationPath");
|
String destinationDir = (String) argMap.get("destinationPath");
|
||||||
if (copy == null || destinationDir == null) return;
|
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() {
|
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
|
||||||
@Override
|
@Override
|
||||||
public void onSuccess(Map<String, Object> fields) {
|
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;
|
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
|
||||||
|
|
||||||
public class ImageEntry {
|
public class SourceImageEntry {
|
||||||
public Uri uri; // content or file URI
|
public Uri uri; // content or file URI
|
||||||
public String path; // best effort to get local path
|
public String path; // best effort to get local path
|
||||||
|
|
||||||
public String mimeType;
|
public String sourceMimeType;
|
||||||
@Nullable
|
@Nullable
|
||||||
public String title;
|
public String title;
|
||||||
@Nullable
|
@Nullable
|
||||||
|
@ -50,13 +50,13 @@ public class ImageEntry {
|
||||||
@Nullable
|
@Nullable
|
||||||
private Long durationMillis;
|
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.uri = Uri.parse((String) map.get("uri"));
|
||||||
this.path = (String) map.get("path");
|
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.width = (int) map.get("width");
|
||||||
this.height = (int) map.get("height");
|
this.height = (int) map.get("height");
|
||||||
this.orientationDegrees = (int) map.get("orientationDegrees");
|
this.orientationDegrees = (int) map.get("orientationDegrees");
|
||||||
|
@ -71,7 +71,7 @@ public class ImageEntry {
|
||||||
return new HashMap<String, Object>() {{
|
return new HashMap<String, Object>() {{
|
||||||
put("uri", uri.toString());
|
put("uri", uri.toString());
|
||||||
put("path", path);
|
put("path", path);
|
||||||
put("mimeType", mimeType);
|
put("sourceMimeType", sourceMimeType);
|
||||||
put("width", width);
|
put("width", width);
|
||||||
put("height", height);
|
put("height", height);
|
||||||
put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0);
|
put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0);
|
||||||
|
@ -106,22 +106,22 @@ public class ImageEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isImage() {
|
private boolean isImage() {
|
||||||
return mimeType.startsWith(MimeTypes.IMAGE);
|
return sourceMimeType.startsWith(MimeTypes.IMAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isSvg() {
|
public boolean isSvg() {
|
||||||
return mimeType.equals(MimeTypes.SVG);
|
return sourceMimeType.equals(MimeTypes.SVG);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isVideo() {
|
private boolean isVideo() {
|
||||||
return mimeType.startsWith(MimeTypes.VIDEO);
|
return sourceMimeType.startsWith(MimeTypes.VIDEO);
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadata retrieval
|
// metadata retrieval
|
||||||
|
|
||||||
// expects entry with: uri, mimeType
|
// expects entry with: uri, mimeType
|
||||||
// finds: width, height, orientation/rotation, date, title, duration
|
// finds: width, height, orientation/rotation, date, title, duration
|
||||||
public ImageEntry fillPreCatalogMetadata(Context context) {
|
public SourceImageEntry fillPreCatalogMetadata(Context context) {
|
||||||
fillByMediaMetadataRetriever(context);
|
fillByMediaMetadataRetriever(context);
|
||||||
if (hasSize() && (!isVideo() || hasDuration())) return this;
|
if (hasSize() && (!isVideo() || hasDuration())) return this;
|
||||||
fillByMetadataExtractor(context);
|
fillByMetadataExtractor(context);
|
||||||
|
@ -183,12 +183,12 @@ public class ImageEntry {
|
||||||
// expects entry with: uri, mimeType
|
// expects entry with: uri, mimeType
|
||||||
// finds: width, height, orientation, date
|
// finds: width, height, orientation, date
|
||||||
private void fillByMetadataExtractor(Context context) {
|
private void fillByMetadataExtractor(Context context) {
|
||||||
if (MimeTypes.SVG.equals(mimeType)) return;
|
if (isSvg()) return;
|
||||||
|
|
||||||
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
||||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||||
|
|
||||||
if (MimeTypes.JPEG.equals(mimeType)) {
|
if (MimeTypes.JPEG.equals(sourceMimeType)) {
|
||||||
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
|
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
|
||||||
if (jpegDir != null) {
|
if (jpegDir != null) {
|
||||||
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
|
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
|
||||||
|
@ -207,7 +207,7 @@ public class ImageEntry {
|
||||||
sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
|
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);
|
Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class);
|
||||||
if (mp4VideoDir != null) {
|
if (mp4VideoDir != null) {
|
||||||
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
|
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
|
||||||
|
@ -223,7 +223,7 @@ public class ImageEntry {
|
||||||
durationMillis = mp4Dir.getLong(Mp4Directory.TAG_DURATION);
|
durationMillis = mp4Dir.getLong(Mp4Directory.TAG_DURATION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (MimeTypes.AVI.equals(mimeType)) {
|
} else if (MimeTypes.AVI.equals(sourceMimeType)) {
|
||||||
AviDirectory aviDir = metadata.getFirstDirectoryOfType(AviDirectory.class);
|
AviDirectory aviDir = metadata.getFirstDirectoryOfType(AviDirectory.class);
|
||||||
if (aviDir != null) {
|
if (aviDir != null) {
|
||||||
if (aviDir.containsTag(AviDirectory.TAG_WIDTH)) {
|
if (aviDir.containsTag(AviDirectory.TAG_WIDTH)) {
|
||||||
|
@ -245,7 +245,7 @@ public class ImageEntry {
|
||||||
// expects entry with: uri
|
// expects entry with: uri
|
||||||
// finds: width, height
|
// finds: width, height
|
||||||
private void fillByBitmapDecode(Context context) {
|
private void fillByBitmapDecode(Context context) {
|
||||||
if (MimeTypes.SVG.equals(mimeType)) return;
|
if (isSvg()) return;
|
||||||
|
|
||||||
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
||||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
BitmapFactory.Options options = new BitmapFactory.Options();
|
|
@ -5,14 +5,14 @@ import android.net.Uri;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import deckers.thibault.aves.model.ImageEntry;
|
import deckers.thibault.aves.model.SourceImageEntry;
|
||||||
|
|
||||||
class ContentImageProvider extends ImageProvider {
|
class ContentImageProvider extends ImageProvider {
|
||||||
@Override
|
@Override
|
||||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
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.uri = uri;
|
||||||
entry.mimeType = mimeType;
|
entry.sourceMimeType = mimeType;
|
||||||
entry.fillPreCatalogMetadata(context);
|
entry.fillPreCatalogMetadata(context);
|
||||||
|
|
||||||
if (entry.hasSize() || entry.isSvg()) {
|
if (entry.hasSize() || entry.isSvg()) {
|
||||||
|
|
|
@ -7,15 +7,15 @@ import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
|
||||||
import deckers.thibault.aves.model.ImageEntry;
|
import deckers.thibault.aves.model.SourceImageEntry;
|
||||||
import deckers.thibault.aves.utils.FileUtils;
|
import deckers.thibault.aves.utils.FileUtils;
|
||||||
|
|
||||||
class FileImageProvider extends ImageProvider {
|
class FileImageProvider extends ImageProvider {
|
||||||
@Override
|
@Override
|
||||||
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
|
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.uri = uri;
|
||||||
entry.mimeType = mimeType;
|
entry.sourceMimeType = mimeType;
|
||||||
|
|
||||||
String path = FileUtils.getPathFromUri(context, uri);
|
String path = FileUtils.getPathFromUri(context, uri);
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
|
|
|
@ -17,14 +17,9 @@ import android.provider.MediaStore;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.exifinterface.media.ExifInterface;
|
import androidx.exifinterface.media.ExifInterface;
|
||||||
|
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat;
|
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.Futures;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
||||||
|
@ -32,12 +27,11 @@ import java.io.File;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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.Env;
|
||||||
import deckers.thibault.aves.utils.MetadataHelper;
|
import deckers.thibault.aves.utils.MetadataHelper;
|
||||||
import deckers.thibault.aves.utils.MimeTypes;
|
import deckers.thibault.aves.utils.MimeTypes;
|
||||||
|
@ -65,7 +59,7 @@ public abstract class ImageProvider {
|
||||||
return Futures.immediateFailedFuture(new UnsupportedOperationException());
|
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());
|
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) {
|
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
|
switch (mimeType) {
|
||||||
// so we retrieve it again from the file metadata
|
|
||||||
String actualMimeType = getMimeType(activity, uri);
|
|
||||||
if (actualMimeType == null) {
|
|
||||||
actualMimeType = mimeType;
|
|
||||||
}
|
|
||||||
switch (actualMimeType) {
|
|
||||||
case MimeTypes.JPEG:
|
case MimeTypes.JPEG:
|
||||||
rotateJpeg(activity, path, uri, clockwise, callback);
|
rotateJpeg(activity, path, uri, clockwise, callback);
|
||||||
break;
|
break;
|
||||||
|
@ -167,7 +135,7 @@ public abstract class ImageProvider {
|
||||||
rotatePng(activity, path, uri, clockwise, callback);
|
rotatePng(activity, path, uri, clockwise, callback);
|
||||||
break;
|
break;
|
||||||
default:
|
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.ContentValues;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
import android.media.MediaScannerConnection;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
|
@ -31,7 +32,8 @@ import java.util.concurrent.ExecutionException;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
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.Env;
|
||||||
import deckers.thibault.aves.utils.MimeTypes;
|
import deckers.thibault.aves.utils.MimeTypes;
|
||||||
import deckers.thibault.aves.utils.PermissionManager;
|
import deckers.thibault.aves.utils.PermissionManager;
|
||||||
|
@ -165,7 +167,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
Map<String, Object> entryMap = new HashMap<String, Object>() {{
|
Map<String, Object> entryMap = new HashMap<String, Object>() {{
|
||||||
put("uri", itemUri.toString());
|
put("uri", itemUri.toString());
|
||||||
put("path", path);
|
put("path", path);
|
||||||
put("mimeType", mimeType);
|
put("sourceMimeType", mimeType);
|
||||||
put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
|
put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
|
||||||
put("sizeBytes", cursor.getLong(sizeColumn));
|
put("sizeBytes", cursor.getLong(sizeColumn));
|
||||||
put("title", cursor.getString(titleColumn));
|
put("title", cursor.getString(titleColumn));
|
||||||
|
@ -181,7 +183,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) {
|
if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) {
|
||||||
// some images are incorrectly registered in the Media Store,
|
// some images are incorrectly registered in the Media Store,
|
||||||
// they are valid but miss some attributes, such as width, height, orientation
|
// 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();
|
entryMap = entry.toMap();
|
||||||
width = entry.width != null ? entry.width : 0;
|
width = entry.width != null ? entry.width : 0;
|
||||||
height = entry.height != null ? entry.height : 0;
|
height = entry.height != null ? entry.height : 0;
|
||||||
|
@ -235,6 +237,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri);
|
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri);
|
||||||
if (df != null && df.delete()) {
|
if (df != null && df.delete()) {
|
||||||
future.set(null);
|
future.set(null);
|
||||||
|
} else {
|
||||||
future.setException(new Exception("failed to delete file with df=" + df));
|
future.setException(new Exception("failed to delete file with df=" + df));
|
||||||
}
|
}
|
||||||
} catch (FileNotFoundException e) {
|
} catch (FileNotFoundException e) {
|
||||||
|
@ -253,16 +256,14 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
Log.e(LOG_TAG, "failed to delete entry", e);
|
Log.e(LOG_TAG, "failed to delete entry", e);
|
||||||
future.setException(e);
|
future.setException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return future;
|
return future;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private String getVolumeName(final Activity activity, String path) {
|
||||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<ImageEntry> entries, ImageOpCallback callback) {
|
|
||||||
String volumeName = "external";
|
String volumeName = "external";
|
||||||
StorageManager sm = activity.getSystemService(StorageManager.class);
|
StorageManager sm = activity.getSystemService(StorageManager.class);
|
||||||
if (sm != null) {
|
if (sm != null) {
|
||||||
StorageVolume volume = sm.getStorageVolume(new File(destinationDir));
|
StorageVolume volume = sm.getStorageVolume(new File(path));
|
||||||
if (volume != null && !volume.isPrimary()) {
|
if (volume != null && !volume.isPrimary()) {
|
||||||
String uuid = volume.getUuid();
|
String uuid = volume.getUuid();
|
||||||
if (uuid != null) {
|
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));
|
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ImageEntry entry : entries) {
|
String volumeName = null;
|
||||||
|
|
||||||
|
for (AvesImageEntry entry : entries) {
|
||||||
Uri sourceUri = entry.uri;
|
Uri sourceUri = entry.uri;
|
||||||
String sourcePath = entry.path;
|
String sourcePath = entry.path;
|
||||||
String mimeType = entry.mimeType;
|
String mimeType = entry.mimeType;
|
||||||
|
@ -287,7 +295,16 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
put("uri", sourceUri.toString());
|
put("uri", sourceUri.toString());
|
||||||
}};
|
}};
|
||||||
try {
|
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("success", true);
|
||||||
result.put("newFields", newFields);
|
result.put("newFields", newFields);
|
||||||
} catch (ExecutionException | InterruptedException e) {
|
} 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();
|
SettableFuture<Map<String, Object>> future = SettableFuture.create();
|
||||||
|
|
||||||
try {
|
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 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(...)`
|
// 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 contentValues = new ContentValues();
|
||||||
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
||||||
|
@ -324,10 +343,12 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
||||||
source.copyTo(destination);
|
source.copyTo(destination);
|
||||||
|
|
||||||
|
boolean deletedSource = false;
|
||||||
if (!copy) {
|
if (!copy) {
|
||||||
// delete original entry
|
// delete original entry
|
||||||
try {
|
try {
|
||||||
delete(activity, sourcePath, sourceUri).get();
|
delete(activity, sourcePath, sourceUri).get();
|
||||||
|
deletedSource = true;
|
||||||
} catch (ExecutionException | InterruptedException e) {
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, 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("uri", destinationUri.toString());
|
||||||
newFields.put("contentId", ContentUris.parseId(destinationUri));
|
newFields.put("contentId", ContentUris.parseId(destinationUri));
|
||||||
newFields.put("path", destinationPath);
|
newFields.put("path", destinationPath);
|
||||||
|
newFields.put("deletedSource", deletedSource);
|
||||||
future.set(newFields);
|
future.set(newFields);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
@ -347,6 +369,80 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
return future;
|
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 {
|
public interface NewEntryHandler {
|
||||||
void handleEntry(Map<String, Object> entry);
|
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) {
|
private static Optional<DocumentFileCompat> getSdCardDocumentFile(Context context, Uri rootTreeUri, String[] storageVolumeRoots, String path) {
|
||||||
if (rootTreeUri == null || storageVolumeRoots == null || path == null) {
|
if (rootTreeUri == null || storageVolumeRoots == null || path == null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
@ -187,7 +197,7 @@ public class StorageUtils {
|
||||||
// follow the entry path down the document tree
|
// follow the entry path down the document tree
|
||||||
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, path);
|
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, path);
|
||||||
while (pathIterator.hasNext()) {
|
while (pathIterator.hasNext()) {
|
||||||
documentFile = documentFile.findFile(pathIterator.next());
|
documentFile = findFileIgnoreCase(documentFile, pathIterator.next());
|
||||||
if (documentFile == null) {
|
if (documentFile == null) {
|
||||||
return Optional.empty();
|
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)) {
|
if (Env.requireAccessPermission(directoryPath)) {
|
||||||
Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
Uri rootTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||||
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
|
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
|
||||||
if (parentFile == null) return false;
|
if (parentFile == null) return null;
|
||||||
|
|
||||||
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
|
String[] storageVolumeRoots = Env.getStorageVolumeRoots(activity);
|
||||||
if (!directoryPath.endsWith(File.separator)) {
|
if (!directoryPath.endsWith(File.separator)) {
|
||||||
|
@ -237,24 +249,31 @@ public class StorageUtils {
|
||||||
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath);
|
Iterator<String> pathIterator = getPathStepIterator(storageVolumeRoots, directoryPath);
|
||||||
while (pathIterator.hasNext()) {
|
while (pathIterator.hasNext()) {
|
||||||
String dirName = pathIterator.next();
|
String dirName = pathIterator.next();
|
||||||
DocumentFileCompat dirFile = parentFile.findFile(dirName);
|
DocumentFileCompat dirFile = findFileIgnoreCase(parentFile, dirName);
|
||||||
if (dirFile == null || !dirFile.exists()) {
|
if (dirFile == null || !dirFile.exists()) {
|
||||||
try {
|
try {
|
||||||
dirFile = parentFile.createDirectory(dirName);
|
dirFile = parentFile.createDirectory(dirName);
|
||||||
if (dirFile != null) {
|
if (dirFile == null) {
|
||||||
parentFile = dirFile;
|
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch (FileNotFoundException e) {
|
} catch (FileNotFoundException e) {
|
||||||
Log.e(LOG_TAG, "failed to create directory with name=" + dirName + " from parent=" + parentFile, 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 {
|
} else {
|
||||||
File directory = new File(directoryPath);
|
File directory = new File(directoryPath);
|
||||||
if (directory.exists()) return true;
|
if (!directory.exists()) {
|
||||||
return directory.mkdirs();
|
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 _directory;
|
||||||
String _filename;
|
String _filename;
|
||||||
int contentId;
|
int contentId;
|
||||||
final String mimeType;
|
final String sourceMimeType;
|
||||||
int width;
|
int width;
|
||||||
int height;
|
int height;
|
||||||
int orientationDegrees;
|
int orientationDegrees;
|
||||||
|
@ -40,7 +40,7 @@ class ImageEntry {
|
||||||
this.uri,
|
this.uri,
|
||||||
String path,
|
String path,
|
||||||
this.contentId,
|
this.contentId,
|
||||||
this.mimeType,
|
this.sourceMimeType,
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
this.orientationDegrees,
|
this.orientationDegrees,
|
||||||
|
@ -63,7 +63,7 @@ class ImageEntry {
|
||||||
uri: uri ?? uri,
|
uri: uri ?? uri,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
contentId: copyContentId,
|
contentId: copyContentId,
|
||||||
mimeType: mimeType,
|
sourceMimeType: sourceMimeType,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
orientationDegrees: orientationDegrees,
|
orientationDegrees: orientationDegrees,
|
||||||
|
@ -79,12 +79,13 @@ class ImageEntry {
|
||||||
return copied;
|
return copied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// from DB or platform source entry
|
||||||
factory ImageEntry.fromMap(Map map) {
|
factory ImageEntry.fromMap(Map map) {
|
||||||
return ImageEntry(
|
return ImageEntry(
|
||||||
uri: map['uri'] as String,
|
uri: map['uri'] as String,
|
||||||
path: map['path'] as String,
|
path: map['path'] as String,
|
||||||
contentId: map['contentId'] as int,
|
contentId: map['contentId'] as int,
|
||||||
mimeType: map['mimeType'] as String,
|
sourceMimeType: map['sourceMimeType'] as String,
|
||||||
width: map['width'] as int,
|
width: map['width'] as int,
|
||||||
height: map['height'] as int,
|
height: map['height'] as int,
|
||||||
orientationDegrees: map['orientationDegrees'] as int,
|
orientationDegrees: map['orientationDegrees'] as int,
|
||||||
|
@ -96,12 +97,13 @@ class ImageEntry {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// for DB only
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'uri': uri,
|
'uri': uri,
|
||||||
'path': path,
|
'path': path,
|
||||||
'contentId': contentId,
|
'contentId': contentId,
|
||||||
'mimeType': mimeType,
|
'sourceMimeType': sourceMimeType,
|
||||||
'width': width,
|
'width': width,
|
||||||
'height': height,
|
'height': height,
|
||||||
'orientationDegrees': orientationDegrees,
|
'orientationDegrees': orientationDegrees,
|
||||||
|
@ -142,6 +144,10 @@ class ImageEntry {
|
||||||
return _filename;
|
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('/.*'), '/*');
|
String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');
|
||||||
|
|
||||||
bool get isFavourite => favourites.isFavourite(this);
|
bool get isFavourite => favourites.isFavourite(this);
|
||||||
|
|
|
@ -30,12 +30,13 @@ class DateMetadata {
|
||||||
class CatalogMetadata {
|
class CatalogMetadata {
|
||||||
final int contentId, dateMillis, videoRotation;
|
final int contentId, dateMillis, videoRotation;
|
||||||
final bool isAnimated;
|
final bool isAnimated;
|
||||||
final String xmpSubjects, xmpTitleDescription;
|
final String mimeType, xmpSubjects, xmpTitleDescription;
|
||||||
final double latitude, longitude;
|
final double latitude, longitude;
|
||||||
Address address;
|
Address address;
|
||||||
|
|
||||||
CatalogMetadata({
|
CatalogMetadata({
|
||||||
this.contentId,
|
this.contentId,
|
||||||
|
this.mimeType,
|
||||||
this.dateMillis,
|
this.dateMillis,
|
||||||
this.isAnimated,
|
this.isAnimated,
|
||||||
this.videoRotation,
|
this.videoRotation,
|
||||||
|
@ -53,6 +54,7 @@ class CatalogMetadata {
|
||||||
}) {
|
}) {
|
||||||
return CatalogMetadata(
|
return CatalogMetadata(
|
||||||
contentId: contentId ?? this.contentId,
|
contentId: contentId ?? this.contentId,
|
||||||
|
mimeType: mimeType,
|
||||||
dateMillis: dateMillis,
|
dateMillis: dateMillis,
|
||||||
isAnimated: isAnimated,
|
isAnimated: isAnimated,
|
||||||
videoRotation: videoRotation,
|
videoRotation: videoRotation,
|
||||||
|
@ -67,6 +69,7 @@ class CatalogMetadata {
|
||||||
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
|
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
|
||||||
return CatalogMetadata(
|
return CatalogMetadata(
|
||||||
contentId: map['contentId'],
|
contentId: map['contentId'],
|
||||||
|
mimeType: map['mimeType'],
|
||||||
dateMillis: map['dateMillis'] ?? 0,
|
dateMillis: map['dateMillis'] ?? 0,
|
||||||
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
|
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
|
||||||
videoRotation: map['videoRotation'] ?? 0,
|
videoRotation: map['videoRotation'] ?? 0,
|
||||||
|
@ -79,6 +82,7 @@ class CatalogMetadata {
|
||||||
|
|
||||||
Map<String, dynamic> toMap({bool boolAsInteger = false}) => {
|
Map<String, dynamic> toMap({bool boolAsInteger = false}) => {
|
||||||
'contentId': contentId,
|
'contentId': contentId,
|
||||||
|
'mimeType': mimeType,
|
||||||
'dateMillis': dateMillis,
|
'dateMillis': dateMillis,
|
||||||
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
|
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
|
||||||
'videoRotation': videoRotation,
|
'videoRotation': videoRotation,
|
||||||
|
@ -90,7 +94,7 @@ class CatalogMetadata {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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'
|
'contentId INTEGER PRIMARY KEY'
|
||||||
', uri TEXT'
|
', uri TEXT'
|
||||||
', path TEXT'
|
', path TEXT'
|
||||||
', mimeType TEXT'
|
', sourceMimeType TEXT'
|
||||||
', width INTEGER'
|
', width INTEGER'
|
||||||
', height INTEGER'
|
', height INTEGER'
|
||||||
', orientationDegrees INTEGER'
|
', orientationDegrees INTEGER'
|
||||||
|
@ -46,6 +46,7 @@ class MetadataDb {
|
||||||
')');
|
')');
|
||||||
await db.execute('CREATE TABLE $metadataTable('
|
await db.execute('CREATE TABLE $metadataTable('
|
||||||
'contentId INTEGER PRIMARY KEY'
|
'contentId INTEGER PRIMARY KEY'
|
||||||
|
', mimeType TEXT'
|
||||||
', dateMillis INTEGER'
|
', dateMillis INTEGER'
|
||||||
', isAnimated INTEGER'
|
', isAnimated INTEGER'
|
||||||
', videoRotation INTEGER'
|
', videoRotation INTEGER'
|
||||||
|
|
|
@ -16,6 +16,18 @@ class ImageFileService {
|
||||||
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
|
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
|
||||||
static const double thumbnailDefaultSize = 64.0;
|
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
|
// knownEntries: map of contentId -> dateModifiedSecs
|
||||||
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) {
|
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) {
|
||||||
try {
|
try {
|
||||||
|
@ -95,7 +107,7 @@ class ImageFileService {
|
||||||
() async {
|
() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
|
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
|
||||||
'entry': entry.toMap(),
|
'entry': _toPlatformEntryMap(entry),
|
||||||
'widthDip': width,
|
'widthDip': width,
|
||||||
'heightDip': height,
|
'heightDip': height,
|
||||||
'defaultSizeDip': thumbnailDefaultSize,
|
'defaultSizeDip': thumbnailDefaultSize,
|
||||||
|
@ -128,7 +140,7 @@ class ImageFileService {
|
||||||
try {
|
try {
|
||||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
'op': 'delete',
|
'op': 'delete',
|
||||||
'entries': entries.map((e) => e.toMap()).toList(),
|
'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(),
|
||||||
}).map((event) => ImageOpEvent.fromMap(event));
|
}).map((event) => ImageOpEvent.fromMap(event));
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
@ -141,7 +153,7 @@ class ImageFileService {
|
||||||
try {
|
try {
|
||||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
'op': 'move',
|
'op': 'move',
|
||||||
'entries': entries.map((e) => e.toMap()).toList(),
|
'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(),
|
||||||
'copy': copy,
|
'copy': copy,
|
||||||
'destinationPath': destinationAlbum,
|
'destinationPath': destinationAlbum,
|
||||||
}).map((event) => MoveOpEvent.fromMap(event));
|
}).map((event) => MoveOpEvent.fromMap(event));
|
||||||
|
@ -155,7 +167,7 @@ class ImageFileService {
|
||||||
try {
|
try {
|
||||||
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||||
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
||||||
'entry': entry.toMap(),
|
'entry': _toPlatformEntryMap(entry),
|
||||||
'newName': newName,
|
'newName': newName,
|
||||||
}) as Map;
|
}) as Map;
|
||||||
return result;
|
return result;
|
||||||
|
@ -169,7 +181,7 @@ class ImageFileService {
|
||||||
try {
|
try {
|
||||||
// return map with: 'width' 'height' 'orientationDegrees' (all optional)
|
// return map with: 'width' 'height' 'orientationDegrees' (all optional)
|
||||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||||
'entry': entry.toMap(),
|
'entry': _toPlatformEntryMap(entry),
|
||||||
'clockwise': clockwise,
|
'clockwise': clockwise,
|
||||||
}) as Map;
|
}) as Map;
|
||||||
return result;
|
return result;
|
||||||
|
|
|
@ -29,6 +29,7 @@ class MetadataService {
|
||||||
final call = () async {
|
final call = () async {
|
||||||
try {
|
try {
|
||||||
// return map with:
|
// return map with:
|
||||||
|
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
|
||||||
// 'dateMillis': date taken in milliseconds since Epoch (long)
|
// 'dateMillis': date taken in milliseconds since Epoch (long)
|
||||||
// 'isAnimated': animated gif/webp (bool)
|
// 'isAnimated': animated gif/webp (bool)
|
||||||
// 'latitude': latitude (double)
|
// 'latitude': latitude (double)
|
||||||
|
|
|
@ -251,34 +251,39 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
||||||
final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed));
|
final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed));
|
||||||
opStream.listen(
|
opStream.listen(
|
||||||
(event) => processed.add(event),
|
(event) => processed.add(event),
|
||||||
onError: (error) => onComplete(),
|
onError: (error) {
|
||||||
|
debugPrint('_showOpReport error=$error');
|
||||||
|
onComplete();
|
||||||
|
},
|
||||||
onDone: onComplete,
|
onDone: onComplete,
|
||||||
);
|
);
|
||||||
|
|
||||||
_opReportOverlayEntry = OverlayEntry(
|
_opReportOverlayEntry = OverlayEntry(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return StreamBuilder<T>(
|
return AbsorbPointer(
|
||||||
stream: opStream,
|
child: StreamBuilder<T>(
|
||||||
builder: (context, snapshot) {
|
stream: opStream,
|
||||||
Widget child = const SizedBox.shrink();
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
|
Widget child = const SizedBox.shrink();
|
||||||
final percent = processed.length.toDouble() / selection.length;
|
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
|
||||||
child = CircularPercentIndicator(
|
final percent = processed.length.toDouble() / selection.length;
|
||||||
percent: percent,
|
child = CircularPercentIndicator(
|
||||||
lineWidth: 16,
|
percent: percent,
|
||||||
radius: 160,
|
lineWidth: 16,
|
||||||
backgroundColor: Colors.white24,
|
radius: 160,
|
||||||
progressColor: Theme.of(context).accentColor,
|
backgroundColor: Colors.white24,
|
||||||
animation: true,
|
progressColor: Theme.of(context).accentColor,
|
||||||
center: Text(NumberFormat.percentPattern().format(percent)),
|
animation: true,
|
||||||
animateFromLastPercent: 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);
|
Overlay.of(context).insert(_opReportOverlayEntry);
|
||||||
|
|
|
@ -92,6 +92,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
||||||
Text('DB metadata:${data == null ? ' no row' : ''}'),
|
Text('DB metadata:${data == null ? ' no row' : ''}'),
|
||||||
if (data != null)
|
if (data != null)
|
||||||
InfoRowGroup({
|
InfoRowGroup({
|
||||||
|
'mimeType': '${data.mimeType}',
|
||||||
'dateMillis': '${data.dateMillis}',
|
'dateMillis': '${data.dateMillis}',
|
||||||
'isAnimated': '${data.isAnimated}',
|
'isAnimated': '${data.isAnimated}',
|
||||||
'videoRotation': '${data.videoRotation}',
|
'videoRotation': '${data.videoRotation}',
|
||||||
|
@ -132,6 +133,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
||||||
if (catalog != null)
|
if (catalog != null)
|
||||||
InfoRowGroup({
|
InfoRowGroup({
|
||||||
'contentId': '${catalog.contentId}',
|
'contentId': '${catalog.contentId}',
|
||||||
|
'mimeType': '${catalog.mimeType}',
|
||||||
'dateMillis': '${catalog.dateMillis}',
|
'dateMillis': '${catalog.dateMillis}',
|
||||||
'isAnimated': '${catalog.isAnimated}',
|
'isAnimated': '${catalog.isAnimated}',
|
||||||
'videoRotation': '${catalog.videoRotation}',
|
'videoRotation': '${catalog.videoRotation}',
|
||||||
|
|
|
@ -113,7 +113,7 @@ class StatsPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
String _cleanMime(String mime) {
|
String _cleanMime(String mime) {
|
||||||
mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '');
|
mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', '');
|
||||||
return mime;
|
return mime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue