scoped storage: fixed opening files and reading metadata
This commit is contained in:
parent
836e7fe4d0
commit
1d6103c0c0
7 changed files with 79 additions and 56 deletions
|
@ -7,6 +7,9 @@
|
|||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- to access media with unredacted metadata with scoped storage (Android 10+) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
|
||||
<!-- TODO remove this permission once this issue is fixed:
|
||||
https://github.com/flutter/flutter/issues/42349
|
||||
https://github.com/flutter/flutter/issues/42451
|
||||
|
|
|
@ -26,7 +26,6 @@ import com.drew.metadata.gif.GifAnimationDirectory;
|
|||
import com.drew.metadata.webp.WebpDirectory;
|
||||
import com.drew.metadata.xmp.XmpDirectory;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
|
@ -38,6 +37,7 @@ import java.util.regex.Pattern;
|
|||
import deckers.thibault.aves.utils.Constants;
|
||||
import deckers.thibault.aves.utils.MetadataHelper;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
|
@ -97,18 +97,13 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
return mimeType != null && mimeType.startsWith(MimeTypes.VIDEO);
|
||||
}
|
||||
|
||||
private InputStream getInputStream(String path, String uri) throws FileNotFoundException {
|
||||
// FileInputStream is faster than input stream from ContentResolver
|
||||
return path != null ? new FileInputStream(path) : context.getContentResolver().openInputStream(Uri.parse(uri));
|
||||
}
|
||||
|
||||
private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
|
||||
String path = call.argument("path");
|
||||
String uri = call.argument("uri");
|
||||
|
||||
Map<String, Map<String, String>> metadataMap = new HashMap<>();
|
||||
|
||||
try (InputStream is = getInputStream(path, uri)) {
|
||||
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri), path)) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
for (Directory dir : metadata.getDirectories()) {
|
||||
if (dir.getTagCount() > 0) {
|
||||
|
@ -158,13 +153,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
// unnamed fallback directory
|
||||
metadataMap.put("", dirMap);
|
||||
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
try {
|
||||
if (path != null) {
|
||||
retriever.setDataSource(path);
|
||||
} else {
|
||||
retriever.setDataSource(context, Uri.parse(uri));
|
||||
}
|
||||
try (MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri), path)) {
|
||||
for (Map.Entry<Integer, String> kv : Constants.MEDIA_METADATA_KEYS.entrySet()) {
|
||||
Integer key = kv.getKey();
|
||||
String value = retriever.extractMetadata(key);
|
||||
|
@ -183,8 +172,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
result.success(metadataMap);
|
||||
} catch (Exception e) {
|
||||
result.error("getAllVideoMetadataFallback-exception", "failed to get metadata for uri=" + uri + ", path=" + path, e.getMessage());
|
||||
} finally {
|
||||
retriever.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -195,7 +182,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
|
||||
Map<String, Object> metadataMap = new HashMap<>();
|
||||
|
||||
try (InputStream is = getInputStream(path, uri)) {
|
||||
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri), path)) {
|
||||
if (!MimeTypes.MP2T.equals(mimeType)) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
|
||||
|
@ -253,13 +240,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
|
||||
if (isVideo(mimeType)) {
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
try {
|
||||
if (path != null) {
|
||||
retriever.setDataSource(path);
|
||||
} else {
|
||||
retriever.setDataSource(context, Uri.parse(uri));
|
||||
}
|
||||
try (MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri), path)) {
|
||||
String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
|
||||
String rotationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
|
||||
String locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION);
|
||||
|
@ -295,8 +276,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
}
|
||||
} catch (Exception e) {
|
||||
result.error("getCatalogMetadata-exception", "failed to get video metadata for uri=" + uri + ", path=" + path, e.getMessage());
|
||||
} finally {
|
||||
retriever.release();
|
||||
}
|
||||
}
|
||||
result.success(metadataMap);
|
||||
|
@ -321,7 +300,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
try (InputStream is = getInputStream(path, uri)) {
|
||||
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri), path)) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
|
||||
if (directory != null) {
|
||||
|
|
|
@ -13,6 +13,8 @@ import java.io.ByteArrayInputStream;
|
|||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
|
||||
class VideoThumbnailFetcher implements DataFetcher<InputStream> {
|
||||
private final VideoThumbnail model;
|
||||
|
||||
|
@ -22,9 +24,7 @@ class VideoThumbnailFetcher implements DataFetcher<InputStream> {
|
|||
|
||||
@Override
|
||||
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
try {
|
||||
retriever.setDataSource(model.getContext(), model.getUri());
|
||||
try (MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(model.getContext(), model.getUri(), null)) {
|
||||
byte[] picture = retriever.getEmbeddedPicture();
|
||||
if (picture != null) {
|
||||
callback.onDataReady(new ByteArrayInputStream(picture));
|
||||
|
@ -40,8 +40,6 @@ class VideoThumbnailFetcher implements DataFetcher<InputStream> {
|
|||
}
|
||||
} catch (Exception ex) {
|
||||
callback.onLoadFailed(ex);
|
||||
} finally {
|
||||
retriever.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,9 +20,6 @@ import com.drew.metadata.jpeg.JpegDirectory;
|
|||
import com.drew.metadata.mp4.Mp4Directory;
|
||||
import com.drew.metadata.mp4.media.Mp4VideoDirectory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
|
@ -31,6 +28,7 @@ import java.util.TimeZone;
|
|||
|
||||
import deckers.thibault.aves.utils.MetadataHelper;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
|
||||
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
|
||||
|
||||
|
@ -38,11 +36,21 @@ public class ImageEntry {
|
|||
public Uri uri; // content or file URI
|
||||
public String path; // best effort to get local path
|
||||
|
||||
public String mimeType, title, bucketDisplayName;
|
||||
public String mimeType;
|
||||
@Nullable
|
||||
public String title;
|
||||
@Nullable
|
||||
private String bucketDisplayName;
|
||||
@Nullable
|
||||
public Integer width, height, orientationDegrees;
|
||||
@Nullable
|
||||
public Long sizeBytes, dateModifiedSecs, sourceDateTakenMillis, durationMillis;
|
||||
public Long sizeBytes;
|
||||
@Nullable
|
||||
public Long dateModifiedSecs;
|
||||
@Nullable
|
||||
private Long sourceDateTakenMillis;
|
||||
@Nullable
|
||||
private Long durationMillis;
|
||||
|
||||
public ImageEntry() {
|
||||
}
|
||||
|
@ -101,10 +109,6 @@ public class ImageEntry {
|
|||
return durationMillis != null && durationMillis > 0;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return path == null ? null : new File(path).getName();
|
||||
}
|
||||
|
||||
private boolean isImage() {
|
||||
return mimeType.startsWith(MimeTypes.IMAGE);
|
||||
}
|
||||
|
@ -119,11 +123,6 @@ public class ImageEntry {
|
|||
|
||||
// metadata retrieval
|
||||
|
||||
private InputStream getInputStream(Context context) throws FileNotFoundException {
|
||||
// FileInputStream is faster than input stream from ContentResolver
|
||||
return path != null ? new FileInputStream(path) : context.getContentResolver().openInputStream(uri);
|
||||
}
|
||||
|
||||
// expects entry with: uri/path, mimeType
|
||||
// finds: width, height, orientation/rotation, date, title, duration
|
||||
public ImageEntry fillPreCatalogMetadata(Context context) {
|
||||
|
@ -138,10 +137,7 @@ public class ImageEntry {
|
|||
// expects entry with: uri/path, mimeType
|
||||
// finds: width, height, orientation/rotation, date, title, duration
|
||||
private void fillByMediaMetadataRetriever(Context context) {
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
try {
|
||||
retriever.setDataSource(context, uri);
|
||||
|
||||
try (MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri, path)) {
|
||||
String width = null, height = null, rotation = null, durationMillis = null;
|
||||
if (isImage()) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
|
@ -181,8 +177,6 @@ public class ImageEntry {
|
|||
}
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
} finally {
|
||||
retriever.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -191,7 +185,7 @@ public class ImageEntry {
|
|||
private void fillByMetadataExtractor(Context context) {
|
||||
if (MimeTypes.SVG.equals(mimeType)) return;
|
||||
|
||||
try (InputStream is = getInputStream(context)) {
|
||||
try (InputStream is = StorageUtils.openInputStream(context, uri, path)) {
|
||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||
|
||||
if (MimeTypes.JPEG.equals(mimeType)) {
|
||||
|
@ -253,7 +247,7 @@ public class ImageEntry {
|
|||
private void fillByBitmapDecode(Context context) {
|
||||
if (MimeTypes.SVG.equals(mimeType)) return;
|
||||
|
||||
try (InputStream is = getInputStream(context)) {
|
||||
try (InputStream is = StorageUtils.openInputStream(context, uri, path)) {
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inJustDecodeBounds = true;
|
||||
BitmapFactory.decodeStream(is, null, options);
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
* https://raw.githubusercontent.com/iPaulPro/aFileChooser/master/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java
|
||||
*/
|
||||
|
||||
// TLAD: copied from https://raw.githubusercontent.com/flutter/plugins/master/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java
|
||||
// TLAD: formatted code copied from:
|
||||
// https://raw.githubusercontent.com/flutter/plugins/master/packages/image_picker/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java
|
||||
// do not add code to this file!
|
||||
|
||||
package deckers.thibault.aves.utils;
|
||||
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
@ -16,7 +19,10 @@ import com.google.common.base.Splitter;
|
|||
import com.google.common.collect.Lists;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
@ -28,6 +34,47 @@ import java.util.Set;
|
|||
public class StorageUtils {
|
||||
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
|
||||
|
||||
private static boolean isMediaStoreContentUri(Uri uri) {
|
||||
// a URI's authority is [userinfo@]host[:port]
|
||||
// but we only want the host when comparing to Media Store's "authority"
|
||||
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost());
|
||||
}
|
||||
|
||||
public static InputStream openInputStream(Context context, Uri uri, String path) throws FileNotFoundException {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (isMediaStoreContentUri(uri)) {
|
||||
uri = MediaStore.setRequireOriginal(uri);
|
||||
}
|
||||
return context.getContentResolver().openInputStream(uri);
|
||||
}
|
||||
|
||||
// on Android <Q, we directly work with file paths if possible,
|
||||
// as `FileInputStream` is faster than input stream from `ContentResolver`
|
||||
return path != null ? new FileInputStream(path) : context.getContentResolver().openInputStream(uri);
|
||||
}
|
||||
|
||||
public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri, String path) {
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (isMediaStoreContentUri(uri)) {
|
||||
uri = MediaStore.setRequireOriginal(uri);
|
||||
}
|
||||
retriever.setDataSource(context, uri);
|
||||
return retriever;
|
||||
}
|
||||
|
||||
// on Android <Q, we directly work with file paths if possible
|
||||
if (path != null) {
|
||||
retriever.setDataSource(path);
|
||||
} else {
|
||||
retriever.setDataSource(context, uri);
|
||||
}
|
||||
return retriever;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all available SD-Cards in the system (include emulated)
|
||||
* <p/>
|
||||
|
|
|
@ -68,7 +68,7 @@ class _HomePageState extends State<HomePage> {
|
|||
// TODO reduce permission check time
|
||||
final permissions = await [
|
||||
Permission.storage,
|
||||
// unredacted EXIF with scoped storage (Android 10+)
|
||||
// to access media with unredacted metadata with scoped storage (Android 10+)
|
||||
Permission.accessMediaLocation,
|
||||
].request(); // 350ms
|
||||
if (permissions[Permission.storage] != PermissionStatus.granted) {
|
||||
|
|
Loading…
Reference in a new issue