From cadd2b4d1cd6c89beb8727efd31ba3b217edcee0 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 16 Feb 2021 17:26:14 +0900 Subject: [PATCH] support Android KitKat API 19-20 --- CHANGELOG.md | 2 +- README.md | 2 +- android/app/build.gradle | 2 +- .../aves/channel/calls/DebugHandler.kt | 14 ++++-- .../aves/channel/calls/ImageFileHandler.kt | 3 +- .../aves/channel/calls/StorageHandler.kt | 19 ++++++-- .../aves/channel/calls/TimeHandler.kt | 2 +- .../channel/calls/fetchers/RegionFetcher.kt | 13 ++--- .../streams/StorageAccessStreamHandler.kt | 22 +++++++++ .../thibault/aves/utils/PermissionManager.kt | 48 ++++++++++++------- .../thibault/aves/utils/StorageUtils.kt | 6 ++- .../ic_shortcut_collection_foreground.xml | 0 .../ic_shortcut_movie_foreground.xml | 0 .../ic_shortcut_search_foreground.xml | 0 14 files changed, 96 insertions(+), 37 deletions(-) rename android/app/src/main/res/{drawable => drawable-anydpi-v26}/ic_shortcut_collection_foreground.xml (100%) rename android/app/src/main/res/{drawable => drawable-anydpi-v26}/ic_shortcut_movie_foreground.xml (100%) rename android/app/src/main/res/{drawable => drawable-anydpi-v26}/ic_shortcut_search_foreground.xml (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9649a88..7231e3e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added -- support Android Lollipop & Marshmallow (API 21 ~ 23) +- support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23) ## [v1.3.4] - 2021-02-10 ### Added diff --git a/README.md b/README.md index ef2b6cdf3..81b930ab2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt - search and filter by country, place, XMP tag, type (animated, raster, vector…) - favorites - statistics -- support Android API 21 ~ 30 (Lollipop ~ R) +- support Android API 19 ~ 30 (KitKat ~ R) - Android integration (app shortcuts, handle view/pick intents) ## Known Issues diff --git a/android/app/build.gradle b/android/app/build.gradle index 33987090d..5247b3a54 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -53,7 +53,7 @@ android { defaultConfig { applicationId "deckers.thibault.aves" - minSdkVersion 21 + minSdkVersion 19 targetSdkVersion 30 // same as compileSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName 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 9ffb58ebe..cea3fe155 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 @@ -51,13 +51,21 @@ class DebugHandler(private val context: Context) : MethodCallHandler { private fun getContextDirs() = hashMapOf( "cacheDir" to context.cacheDir, - "codeCacheDir" to context.codeCacheDir, "filesDir" to context.filesDir, - "noBackupFilesDir" to context.noBackupFilesDir, "obbDir" to context.obbDir, "externalCacheDir" to context.externalCacheDir, ).apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) put("dataDir", context.dataDir) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + putAll( + hashMapOf( + "codeCacheDir" to context.codeCacheDir, + "noBackupFilesDir" to context.noBackupFilesDir, + ) + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + put("dataDir", context.dataDir) + } }.mapValues { it.value?.path } private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 5af8a45f3..67f6afec8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -125,7 +125,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { pageId = pageId, sampleSize = sampleSize, regionRect = regionRect, - imageSize = Size(imageWidth, imageHeight), + imageWidth = imageWidth, + imageHeight = imageHeight, result = result, ) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index 9199bb43c..e7cca3ea5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.os.storage.StorageManager +import androidx.core.os.EnvironmentCompat import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath @@ -61,12 +62,19 @@ class StorageHandler(private val context: Context) : MethodCallHandler { for (volumePath in getVolumePaths(context)) { val volumeFile = File(volumePath) try { + val isPrimary = volumePath == primaryVolumePath + val isRemovable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Environment.isExternalStorageRemovable(volumeFile) + } else { + // random guess + !isPrimary + } volumes.add( hashMapOf( "path" to volumePath, - "isPrimary" to (volumePath == primaryVolumePath), - "isRemovable" to Environment.isExternalStorageRemovable(volumeFile), - "state" to Environment.getExternalStorageState(volumeFile) + "isPrimary" to isPrimary, + "isRemovable" to isRemovable, + "state" to EnvironmentCompat.getStorageState(volumeFile) ) ) } catch (e: IllegalArgumentException) { @@ -119,6 +127,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler { return } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + result.error("revokeDirectoryAccess-unsupported", "volume access is not allowed before Android Lollipop", null) + return + } + val success = PermissionManager.revokeDirectoryAccess(context, path) result.success(success) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TimeHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TimeHandler.kt index 8a997b24a..0f09aff8f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TimeHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TimeHandler.kt @@ -5,7 +5,7 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import java.util.* -class TimeHandler() : MethodCallHandler { +class TimeHandler : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getDefaultTimeZone" -> result.success(TimeZone.getDefault().id) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt index 9b168e565..ad7035157 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt @@ -6,7 +6,6 @@ import android.graphics.BitmapFactory import android.graphics.BitmapRegionDecoder import android.graphics.Rect import android.net.Uri -import android.util.Size import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -37,7 +36,8 @@ class RegionFetcher internal constructor( pageId: Int?, sampleSize: Int, regionRect: Rect, - imageSize: Size, + imageWidth: Int, + imageHeight: Int, result: MethodChannel.Result, ) { if (MimeTypes.isHeifLike(mimeType) && pageId != null) { @@ -48,7 +48,8 @@ class RegionFetcher internal constructor( pageId = null, sampleSize = sampleSize, regionRect = regionRect, - imageSize = imageSize, + imageWidth = imageWidth, + imageHeight = imageHeight, result = result, ) return @@ -79,9 +80,9 @@ class RegionFetcher internal constructor( // with raw images, the known image size may not match the decoded image size // so we scale the requested region accordingly - val effectiveRect = if (imageSize.width != decoder.width || imageSize.height != decoder.height) { - val xf = decoder.width.toDouble() / imageSize.width - val yf = decoder.height.toDouble() / imageSize.height + val effectiveRect = if (imageWidth != decoder.width || imageHeight != decoder.height) { + val xf = decoder.width.toDouble() / imageWidth + val yf = decoder.height.toDouble() / imageHeight Rect( (regionRect.left * xf).roundToInt(), (regionRect.top * yf).roundToInt(), diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 77321a004..f22e7b684 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -1,6 +1,7 @@ package deckers.thibault.aves.channel.streams import android.app.Activity +import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log @@ -26,6 +27,16 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? this.eventSink = eventSink handler = Handler(Looper.getMainLooper()) + if (path == null) { + error("requestVolumeAccess-args", "failed because of missing arguments", null) + return + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + error("requestVolumeAccess-unsupported", "volume access is not allowed before Android Lollipop", null) + return + } + requestVolumeAccess(activity, path!!, { success(true) }, { success(false) }) } @@ -42,6 +53,17 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? endOfStream() } + @Suppress("SameParameterValue") + private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) { + handler.post { + try { + eventSink.error(errorCode, errorMessage, errorDetails) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to use event sink", e) + } + } + } + private fun endOfStream() { handler.post { try { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index e703392e5..13472aeae 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.Environment import android.os.storage.StorageManager import android.util.Log +import androidx.annotation.RequiresApi import deckers.thibault.aves.utils.StorageUtils.PathSegments import java.io.File import java.util.* @@ -22,6 +23,7 @@ object PermissionManager { // permission request code to pending runnable private val pendingPermissionMap = ConcurrentHashMap() + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) { Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path") @@ -106,29 +108,39 @@ object PermissionManager { } fun getRestrictedDirectories(context: Context): List> { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val dirs = ArrayList>() + val sdkInt = Build.VERSION.SDK_INT + + if (sdkInt >= Build.VERSION_CODES.R) { // cf https://developer.android.com/about/versions/11/privacy/storage#directory-access val volumePaths = StorageUtils.getVolumePaths(context) - ArrayList>().apply { - addAll(volumePaths.map { - hashMapOf( - "volumePath" to it, - "relativeDir" to "", - ) - }) - addAll(volumePaths.map { - hashMapOf( - "volumePath" to it, - "relativeDir" to Environment.DIRECTORY_DOWNLOADS, - ) - }) - } - } else { - // TODO TLAD add KitKat restriction (no SD card root access) if min version goes to API 19-20 - ArrayList() + dirs.addAll(volumePaths.map { + hashMapOf( + "volumePath" to it, + "relativeDir" to "", + ) + }) + dirs.addAll(volumePaths.map { + hashMapOf( + "volumePath" to it, + "relativeDir" to Environment.DIRECTORY_DOWNLOADS, + ) + }) + } else if (sdkInt == Build.VERSION_CODES.KITKAT || sdkInt == Build.VERSION_CODES.KITKAT_WATCH) { + // no SD card volume access on KitKat + val primaryVolume = StorageUtils.getPrimaryVolumePath(context) + val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume } + dirs.addAll(nonPrimaryVolumes.map { + hashMapOf( + "volumePath" to it, + "relativeDir" to "", + ) + }) } + return dirs } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) fun revokeDirectoryAccess(context: Context, path: String): Boolean { return StorageUtils.convertDirPathToTreeUri(context, path)?.let { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION 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 e84134608..159db5884 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 @@ -12,6 +12,7 @@ import android.provider.MediaStore import android.text.TextUtils import android.util.Log import android.webkit.MimeTypeMap +import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath import java.io.File @@ -246,6 +247,7 @@ object StorageUtils { // e.g. // /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A // /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? { val uuid = getVolumeUuidForTreeUri(context, dirPath) if (uuid != null) { @@ -287,7 +289,7 @@ object StorageUtils { fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? { try { - if (requireAccessPermission(context, anyPath)) { + if (requireAccessPermission(context, anyPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // need a document URI (not a media content URI) to open a `DocumentFile` output stream if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) { // cleanest API to get it @@ -311,7 +313,7 @@ object StorageUtils { // returns null if directory does not exist and could not be created fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? { val cleanDirPath = ensureTrailingSeparator(dirPath) - return if (requireAccessPermission(context, cleanDirPath)) { + return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null diff --git a/android/app/src/main/res/drawable/ic_shortcut_collection_foreground.xml b/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_collection_foreground.xml similarity index 100% rename from android/app/src/main/res/drawable/ic_shortcut_collection_foreground.xml rename to android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_collection_foreground.xml diff --git a/android/app/src/main/res/drawable/ic_shortcut_movie_foreground.xml b/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_movie_foreground.xml similarity index 100% rename from android/app/src/main/res/drawable/ic_shortcut_movie_foreground.xml rename to android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_movie_foreground.xml diff --git a/android/app/src/main/res/drawable/ic_shortcut_search_foreground.xml b/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_search_foreground.xml similarity index 100% rename from android/app/src/main/res/drawable/ic_shortcut_search_foreground.xml rename to android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_search_foreground.xml