Skip to content

Commit

Permalink
Add Q ModelLoader to load unredacted data when possible to avoid HEIC…
Browse files Browse the repository at this point in the history
… failures.

EXIF redaction in MediaStore on Q breaks HEIC/HEIF decoding. This class
does it's best to obtain unredacted file data on Q depending on the
state the hosting application is in with regards to storage.

It's not a complete fix. In particular applications that target Q, do
not opt in to legacy storage and do not have the ACCESS_MEDIA_LOCATION
permission will still be unable to decode HEIC/HEIF after this change.

PiperOrigin-RevId: 276302007
  • Loading branch information
sjudd authored and glide-copybara-robot committed Oct 23, 2019
1 parent 42d3f07 commit 1c51b24
Show file tree
Hide file tree
Showing 5 changed files with 284 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ before_install:
- mkdir "$ANDROID_HOME/licenses" || true
- echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55\nd56f5187479451eabf01fb78af6dfcb131a6481e" > "$ANDROID_HOME/licenses/android-sdk-license"
- echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd\n504667f4c0de7af1a06de9f4b1727b84351f2910" > "$ANDROID_HOME/licenses/android-sdk-preview-license"
- yes | $ANDROID_HOME/tools/bin/sdkmanager "build-tools;28.0.3" "platforms;android-28"
- yes | $ANDROID_HOME/tools/bin/sdkmanager "build-tools;29.0.2" "platforms;android-29"

android:
components:
Expand Down
6 changes: 3 additions & 3 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ OK_HTTP_VERSION=3.9.1
ANDROID_GRADLE_VERSION=3.3.0
DAGGER_VERSION=2.15

JUNIT_VERSION=4.13-SNAPSHOT
JUNIT_VERSION=4.13-beta-3
# Matches the version in Google.
MOCKITO_VERSION=2.23.4
MOCKITO_ANDROID_VERSION=2.24.0
ROBOLECTRIC_VERSION=4.3-beta-1
ROBOLECTRIC_VERSION=4.3.1
MOCKWEBSERVER_VERSION=3.0.0-RC1
TRUTH_VERSION=0.45
JSR_305_VERSION=3.0.2
Expand All @@ -42,7 +42,7 @@ ERROR_PRONE_VERSION=2.3.1
ERROR_PRONE_PLUGIN_VERSION=0.0.13
VIOLATIONS_PLUGIN_VERSION=1.8

COMPILE_SDK_VERSION=28
COMPILE_SDK_VERSION=29
TARGET_SDK_VERSION=28
MIN_SDK_VERSION=14

Expand Down
12 changes: 11 additions & 1 deletion library/src/main/java/com/bumptech/glide/Glide.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import com.bumptech.glide.load.model.stream.HttpUriLoader;
import com.bumptech.glide.load.model.stream.MediaStoreImageThumbLoader;
import com.bumptech.glide.load.model.stream.MediaStoreVideoThumbLoader;
import com.bumptech.glide.load.model.stream.QMediaStoreUriLoader;
import com.bumptech.glide.load.model.stream.UrlLoader;
import com.bumptech.glide.load.resource.bitmap.BitmapDrawableDecoder;
import com.bumptech.glide.load.resource.bitmap.BitmapDrawableEncoder;
Expand Down Expand Up @@ -506,7 +507,16 @@ Uri.class, Bitmap.class, new ResourceBitmapDecoder(resourceDrawableDecoder, bitm
ParcelFileDescriptor.class,
new AssetUriLoader.FileDescriptorFactory(context.getAssets()))
.append(Uri.class, InputStream.class, new MediaStoreImageThumbLoader.Factory(context))
.append(Uri.class, InputStream.class, new MediaStoreVideoThumbLoader.Factory(context))
.append(Uri.class, InputStream.class, new MediaStoreVideoThumbLoader.Factory(context));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
registry.append(
Uri.class, InputStream.class, new QMediaStoreUriLoader.InputStreamFactory(context));
registry.append(
Uri.class,
ParcelFileDescriptor.class,
new QMediaStoreUriLoader.FileDescriptorFactory(context));
}
registry
.append(Uri.class, InputStream.class, new UriLoader.StreamFactory(contentResolver))
.append(
Uri.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package com.bumptech.glide.load.model.stream;

import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.bumptech.glide.Priority;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.data.DataFetcher;
import com.bumptech.glide.load.data.mediastore.MediaStoreUtil;
import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import com.bumptech.glide.signature.ObjectKey;
import com.bumptech.glide.util.Synthetic;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.InputStream;

/**
* Best effort attempt to work around various Q storage states and bugs.
*
* <p>In particular, HEIC images on Q cannot be decoded if they've gone through Android's exif
* redaction, due to a bug in the implementation that corrupts the file. To avoid the issue, we need
* to get at the un-redacted File. There are two ways we can do so:
*
* <ul>
* <li>MediaStore.setRequireOriginal
* <li>Querying for and opening the file via the underlying file path, rather than via {@code
* ContentResolver}
* </ul>
*
* <p>MediaStore.setRequireOriginal will only work for applications that target Q and request and
* currently have {@link android.Manifest.permission#ACCESS_MEDIA_LOCATION}. It's the simplest
* change to make, but it covers the fewest applications.
*
* <p>Querying for the file path and opening the file directly works for applications that do not
* target Q and for applications that do target Q but that opt in to legacy storage mode. Other
* options are theoretically available for applications that do not target Q, but due to other bugs,
* the only consistent way to get unredacted files is via the file system.
*
* <p>This class does not fix applications that target Q, do not opt in to legacy storage and that
* don't have {@link android.Manifest.permission#ACCESS_MEDIA_LOCATION}.
*
* <p>Avoid using this class directly, it may be removed in any future version of Glide.
*
* @param <DataT> The type of data this loader will load ({@link InputStream}, {@link
* ParcelFileDescriptor}).
*/
@RequiresApi(Build.VERSION_CODES.Q)
public final class QMediaStoreUriLoader<DataT> implements ModelLoader<Uri, DataT> {
private final Context context;
private final ModelLoader<File, DataT> fileDelegate;
private final ModelLoader<Uri, DataT> uriDelegate;
private final Class<DataT> dataClass;

@SuppressWarnings("WeakerAccess")
@Synthetic
QMediaStoreUriLoader(
Context context,
ModelLoader<File, DataT> fileDelegate,
ModelLoader<Uri, DataT> uriDelegate,
Class<DataT> dataClass) {
this.context = context.getApplicationContext();
this.fileDelegate = fileDelegate;
this.uriDelegate = uriDelegate;
this.dataClass = dataClass;
}

@Override
public LoadData<DataT> buildLoadData(
@NonNull Uri uri, int width, int height, @NonNull Options options) {
return new LoadData<>(
new ObjectKey(uri),
new QMediaStoreUriFetcher<>(
context, fileDelegate, uriDelegate, uri, width, height, options, dataClass));
}

@Override
public boolean handles(@NonNull Uri uri) {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && MediaStoreUtil.isMediaStoreUri(uri);
}

private static final class QMediaStoreUriFetcher<DataT> implements DataFetcher<DataT> {
private static final String[] PROJECTION = new String[] {MediaStore.MediaColumns.DATA};

private final Context context;
private final ModelLoader<File, DataT> fileDelegate;
private final ModelLoader<Uri, DataT> uriDelegate;
private final Uri uri;
private final int width;
private final int height;
private final Options options;
private final Class<DataT> dataClass;

private volatile boolean isCancelled;
@Nullable private volatile DataFetcher<DataT> delegate;

QMediaStoreUriFetcher(
Context context,
ModelLoader<File, DataT> fileDelegate,
ModelLoader<Uri, DataT> uriDelegate,
Uri uri,
int width,
int height,
Options options,
Class<DataT> dataClass) {
this.context = context.getApplicationContext();
this.fileDelegate = fileDelegate;
this.uriDelegate = uriDelegate;
this.uri = uri;
this.width = width;
this.height = height;
this.options = options;
this.dataClass = dataClass;
}

@Override
public void loadData(
@NonNull Priority priority, @NonNull DataCallback<? super DataT> callback) {
try {
DataFetcher<DataT> local = buildDelegateFetcher();
if (local == null) {
callback.onLoadFailed(
new IllegalArgumentException("Failed to build fetcher for: " + uri));
return;
}
delegate = local;
if (isCancelled) {
cancel();
} else {
local.loadData(priority, callback);
}
} catch (FileNotFoundException e) {
callback.onLoadFailed(e);
}
}

@Nullable
private DataFetcher<DataT> buildDelegateFetcher() throws FileNotFoundException {
LoadData<DataT> result = buildDelegateData();
return result != null ? result.fetcher : null;
}

@Nullable
private LoadData<DataT> buildDelegateData() throws FileNotFoundException {
if (Environment.isExternalStorageLegacy()) {
return fileDelegate.buildLoadData(queryForFilePath(uri), width, height, options);
} else {
Uri toLoad = isAccessMediaLocationGranted() ? MediaStore.setRequireOriginal(uri) : uri;
return uriDelegate.buildLoadData(toLoad, width, height, options);
}
}

@Override
public void cleanup() {
DataFetcher<DataT> local = delegate;
if (local != null) {
local.cleanup();
}
}

@Override
public void cancel() {
isCancelled = true;
DataFetcher<DataT> local = delegate;
if (local != null) {
local.cancel();
}
}

@NonNull
@Override
public Class<DataT> getDataClass() {
return dataClass;
}

@NonNull
@Override
public DataSource getDataSource() {
return DataSource.LOCAL;
}

@NonNull
private File queryForFilePath(Uri uri) throws FileNotFoundException {
Cursor cursor = null;
try {
cursor =
context
.getContentResolver()
.query(
uri,
PROJECTION,
/*selection=*/ null,
/*selectionArgs=*/ null,
/*sortOrder=*/ null);
if (cursor == null || !cursor.moveToFirst()) {
throw new FileNotFoundException("Failed to media store entry for: " + uri);
}
String path = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA));
if (TextUtils.isEmpty(path)) {
throw new FileNotFoundException("File path was empty in media store for: " + uri);
}
return new File(path);
} finally {
if (cursor != null) {
cursor.close();
}
}
}

private boolean isAccessMediaLocationGranted() {
return context.checkSelfPermission(android.Manifest.permission.ACCESS_MEDIA_LOCATION)
== PackageManager.PERMISSION_GRANTED;
}
}

/** Factory for {@link InputStream}. */
@RequiresApi(Build.VERSION_CODES.Q)
public static final class InputStreamFactory extends Factory<InputStream> {
public InputStreamFactory(Context context) {
super(context, InputStream.class);
}
}

/** Factory for {@link ParcelFileDescriptor}. */
@RequiresApi(Build.VERSION_CODES.Q)
public static final class FileDescriptorFactory extends Factory<ParcelFileDescriptor> {
public FileDescriptorFactory(Context context) {
super(context, ParcelFileDescriptor.class);
}
}

private abstract static class Factory<DataT> implements ModelLoaderFactory<Uri, DataT> {

private final Context context;
private final Class<DataT> dataClass;

Factory(Context context, Class<DataT> dataClass) {
this.context = context;
this.dataClass = dataClass;
}

@NonNull
@Override
public final ModelLoader<Uri, DataT> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new QMediaStoreUriLoader<>(
context,
multiFactory.build(File.class, dataClass),
multiFactory.build(Uri.class, dataClass),
dataClass);
}

@Override
public final void teardown() {
// Do nothing.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.util.List;

/** Loads metadata from the media store for images and videos. */
@SuppressWarnings("InlinedApi")
public class MediaStoreDataLoader extends AsyncTaskLoader<List<MediaStoreData>> {
private static final String[] IMAGE_PROJECTION =
new String[] {
Expand Down

0 comments on commit 1c51b24

Please sign in to comment.