diff --git a/.travis.yml b/.travis.yml index 5fa2230b24..51414ab4bb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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: diff --git a/gradle.properties b/gradle.properties index d86e155e0d..137c168f9a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 @@ -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 diff --git a/library/src/main/java/com/bumptech/glide/Glide.java b/library/src/main/java/com/bumptech/glide/Glide.java index 20a2be95a0..13b9bc10a1 100644 --- a/library/src/main/java/com/bumptech/glide/Glide.java +++ b/library/src/main/java/com/bumptech/glide/Glide.java @@ -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; @@ -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, diff --git a/library/src/main/java/com/bumptech/glide/load/model/stream/QMediaStoreUriLoader.java b/library/src/main/java/com/bumptech/glide/load/model/stream/QMediaStoreUriLoader.java new file mode 100644 index 0000000000..4cce1b6cea --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/load/model/stream/QMediaStoreUriLoader.java @@ -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. + * + *

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: + * + *

+ * + *

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. + * + *

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. + * + *

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}. + * + *

Avoid using this class directly, it may be removed in any future version of Glide. + * + * @param The type of data this loader will load ({@link InputStream}, {@link + * ParcelFileDescriptor}). + */ +@RequiresApi(Build.VERSION_CODES.Q) +public final class QMediaStoreUriLoader implements ModelLoader { + private final Context context; + private final ModelLoader fileDelegate; + private final ModelLoader uriDelegate; + private final Class dataClass; + + @SuppressWarnings("WeakerAccess") + @Synthetic + QMediaStoreUriLoader( + Context context, + ModelLoader fileDelegate, + ModelLoader uriDelegate, + Class dataClass) { + this.context = context.getApplicationContext(); + this.fileDelegate = fileDelegate; + this.uriDelegate = uriDelegate; + this.dataClass = dataClass; + } + + @Override + public LoadData 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 implements DataFetcher { + private static final String[] PROJECTION = new String[] {MediaStore.MediaColumns.DATA}; + + private final Context context; + private final ModelLoader fileDelegate; + private final ModelLoader uriDelegate; + private final Uri uri; + private final int width; + private final int height; + private final Options options; + private final Class dataClass; + + private volatile boolean isCancelled; + @Nullable private volatile DataFetcher delegate; + + QMediaStoreUriFetcher( + Context context, + ModelLoader fileDelegate, + ModelLoader uriDelegate, + Uri uri, + int width, + int height, + Options options, + Class 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 callback) { + try { + DataFetcher 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 buildDelegateFetcher() throws FileNotFoundException { + LoadData result = buildDelegateData(); + return result != null ? result.fetcher : null; + } + + @Nullable + private LoadData 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 local = delegate; + if (local != null) { + local.cleanup(); + } + } + + @Override + public void cancel() { + isCancelled = true; + DataFetcher local = delegate; + if (local != null) { + local.cancel(); + } + } + + @NonNull + @Override + public Class 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 { + public InputStreamFactory(Context context) { + super(context, InputStream.class); + } + } + + /** Factory for {@link ParcelFileDescriptor}. */ + @RequiresApi(Build.VERSION_CODES.Q) + public static final class FileDescriptorFactory extends Factory { + public FileDescriptorFactory(Context context) { + super(context, ParcelFileDescriptor.class); + } + } + + private abstract static class Factory implements ModelLoaderFactory { + + private final Context context; + private final Class dataClass; + + Factory(Context context, Class dataClass) { + this.context = context; + this.dataClass = dataClass; + } + + @NonNull + @Override + public final ModelLoader 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. + } + } +} diff --git a/samples/gallery/src/main/java/com/bumptech/glide/samples/gallery/MediaStoreDataLoader.java b/samples/gallery/src/main/java/com/bumptech/glide/samples/gallery/MediaStoreDataLoader.java index 8123cca3d4..1eb5b35252 100644 --- a/samples/gallery/src/main/java/com/bumptech/glide/samples/gallery/MediaStoreDataLoader.java +++ b/samples/gallery/src/main/java/com/bumptech/glide/samples/gallery/MediaStoreDataLoader.java @@ -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> { private static final String[] IMAGE_PROJECTION = new String[] {