support Android KitKat API 19-20

This commit is contained in:
Thibault Deckers 2021-02-16 17:26:14 +09:00
parent 9173ee9121
commit cadd2b4d1c
14 changed files with 96 additions and 37 deletions

View file

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

View file

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

View file

@ -53,7 +53,7 @@ android {
defaultConfig {
applicationId "deckers.thibault.aves"
minSdkVersion 21
minSdkVersion 19
targetSdkVersion 30 // same as compileSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

View file

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

View file

@ -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,
)
}

View file

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

View file

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

View file

@ -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(),

View file

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

View file

@ -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<Int, PendingPermissionHandler>()
@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<Map<String, String>> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val dirs = ArrayList<Map<String, String>>()
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<Map<String, String>>().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

View file

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