diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 9d3303312..1ac757d6f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -1,5 +1,6 @@ package deckers.thibault.aves +import android.annotation.SuppressLint import android.app.SearchManager import android.content.Intent import android.net.Uri @@ -95,31 +96,34 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { - DOCUMENT_TREE_ACCESS_REQUEST -> { - val treeUri = data?.data - if (resultCode != RESULT_OK || treeUri == null) { - onPermissionResult(requestCode, null) - return - } + DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode) + DELETE_PERMISSION_REQUEST -> onDeletePermissionResult(resultCode) + CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> onPermissionResult(requestCode, data?.data) + } + } - // save access permissions across reboots - val takeFlags = (data.flags - and (Intent.FLAG_GRANT_READ_URI_PERMISSION - or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) - contentResolver.takePersistableUriPermission(treeUri, takeFlags) + @SuppressLint("WrongConstant") + private fun onDocumentTreeAccessResult(data: Intent?, resultCode: Int, requestCode: Int) { + val treeUri = data?.data + if (resultCode != RESULT_OK || treeUri == null) { + onPermissionResult(requestCode, null) + return + } - // resume pending action - onPermissionResult(requestCode, treeUri) - } - DELETE_PERMISSION_REQUEST -> { - // delete permission may be requested on Android 10+ only - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) - } - } - CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> { - onPermissionResult(requestCode, data?.data) - } + // save access permissions across reboots + val takeFlags = (data.flags + and (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + contentResolver.takePersistableUriPermission(treeUri, takeFlags) + + // resume pending action + onPermissionResult(requestCode, treeUri) + } + + private fun onDeletePermissionResult(resultCode: Int) { + // delete permission may be requested on Android 10+ only + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index ba9b886da..569d8d5dc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -1,6 +1,5 @@ package deckers.thibault.aves.channel.calls -import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.database.Cursor @@ -105,7 +104,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } var contentUri: Uri = uri - if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) { + if (StorageUtils.isMediaStoreContentUri(uri)) { uri.tryParseId()?.let { id -> contentUri = when { isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 895c58eda..bd9e7967c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -1,6 +1,5 @@ package deckers.thibault.aves.channel.calls -import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.database.Cursor @@ -692,7 +691,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } var contentUri: Uri = uri - if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) { + if (StorageUtils.isMediaStoreContentUri(uri)) { uri.tryParseId()?.let { id -> contentUri = when { isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index c10ba3e55..2a32f93ba 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -24,6 +24,7 @@ import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide +import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.UriUtils.tryParseId import io.flutter.plugin.common.MethodChannel @@ -130,7 +131,7 @@ class ThumbnailFetcher internal constructor( svgFetch -> SvgThumbnail(context, uri) tiffFetch -> TiffImage(context, uri, pageId) multiTrackFetch -> MultiTrackImage(context, uri, pageId) - else -> uri + else -> StorageUtils.getGlideSafeUri(uri, mimeType) } Glide.with(context) .asBitmap() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index dbd1ee080..59dfd47d7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -120,7 +120,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } else if (mimeType == MimeTypes.TIFF) { TiffImage(activity, uri, pageId) } else { - uri + StorageUtils.getGlideSafeUri(uri, mimeType) } val target = Glide.with(activity) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 6057abddb..e546681c8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -142,7 +142,7 @@ abstract class ImageProvider { } else if (sourceMimeType == MimeTypes.TIFF) { TiffImage(context, sourceUri, pageId) } else { - sourceUri + StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType) } // request a fresh image with the highest quality format diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt index 2cfe1844b..63d0afbde 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt @@ -2,18 +2,17 @@ package deckers.thibault.aves.model.provider import android.content.ContentResolver import android.net.Uri -import android.provider.MediaStore +import deckers.thibault.aves.utils.StorageUtils import java.util.* object ImageProviderFactory { fun getProvider(uri: Uri): ImageProvider? { return when (uri.scheme?.lowercase(Locale.ROOT)) { ContentResolver.SCHEME_CONTENT -> { - // a URI's authority is [userinfo@]host[:port] - // but we only want the host when comparing to Media Store's "authority" - return when (uri.host?.lowercase(Locale.ROOT)) { - MediaStore.AUTHORITY -> MediaStoreImageProvider() - else -> ContentImageProvider() + if (StorageUtils.isMediaStoreContentUri(uri)) { + MediaStoreImageProvider() + } else { + ContentImageProvider() } } ContentResolver.SCHEME_FILE -> FileImageProvider() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 49eea7d96..27e5432a3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -3,6 +3,7 @@ package deckers.thibault.aves.utils import android.Manifest import android.annotation.SuppressLint import android.content.ContentResolver +import android.content.ContentUris import android.content.Context import android.content.pm.PackageManager import android.media.MediaMetadataRetriever @@ -15,7 +16,10 @@ import android.text.TextUtils import android.util.Log import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat +import deckers.thibault.aves.utils.MimeTypes.isImage +import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath +import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.File import java.io.FileNotFoundException import java.io.InputStream @@ -395,7 +399,7 @@ object StorageUtils { return !onPrimaryVolume } - private fun isMediaStoreContentUri(uri: Uri?): Boolean { + fun isMediaStoreContentUri(uri: Uri?): Boolean { uri ?: return false // a URI's authority is [userinfo@]host[:port] // but we only want the host when comparing to Media Store's "authority" @@ -407,7 +411,7 @@ object StorageUtils { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { val path = uri.path path ?: return uri - // from Android R, accessing the original URI for a file media content yields a `SecurityException` + // from Android R, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException` if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) { // "Caller must hold ACCESS_MEDIA_LOCATION permission to access original" if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) { @@ -418,6 +422,24 @@ object StorageUtils { return uri } + // As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used + // to work around a bug from Android Q where metadata redaction corrupts HEIC images. + // This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException` + // for some content URIs (e.g. `content://media/external_primary/downloads/...`) + // so we build a typical `images` or `videos` content URI from the original content ID. + fun getGlideSafeUri(uri: Uri, mimeType: String): Uri { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { + uri.tryParseId()?.let { id -> + return when { + isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) + else -> uri + } + } + } + return uri + } + fun openInputStream(context: Context, uri: Uri): InputStream? { val effectiveUri = getOriginalUri(context, uri) return try {