scoped storage: fixed opening files and reading metadata

This commit is contained in:
Thibault Deckers 2020-04-28 11:18:18 +09:00
parent 836e7fe4d0
commit 1d6103c0c0
7 changed files with 79 additions and 56 deletions

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {