support Android KitKat API 19-20
This commit is contained in:
parent
9173ee9121
commit
cadd2b4d1c
14 changed files with 96 additions and 37 deletions
|
@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
- support Android Lollipop & Marshmallow (API 21 ~ 23)
|
- support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23)
|
||||||
|
|
||||||
## [v1.3.4] - 2021-02-10
|
## [v1.3.4] - 2021-02-10
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -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…)
|
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
|
||||||
- favorites
|
- favorites
|
||||||
- statistics
|
- statistics
|
||||||
- support Android API 21 ~ 30 (Lollipop ~ R)
|
- support Android API 19 ~ 30 (KitKat ~ R)
|
||||||
- Android integration (app shortcuts, handle view/pick intents)
|
- Android integration (app shortcuts, handle view/pick intents)
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
|
@ -53,7 +53,7 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "deckers.thibault.aves"
|
applicationId "deckers.thibault.aves"
|
||||||
minSdkVersion 21
|
minSdkVersion 19
|
||||||
targetSdkVersion 30 // same as compileSdkVersion
|
targetSdkVersion 30 // same as compileSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
|
|
|
@ -51,13 +51,21 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getContextDirs() = hashMapOf(
|
private fun getContextDirs() = hashMapOf(
|
||||||
"cacheDir" to context.cacheDir,
|
"cacheDir" to context.cacheDir,
|
||||||
"codeCacheDir" to context.codeCacheDir,
|
|
||||||
"filesDir" to context.filesDir,
|
"filesDir" to context.filesDir,
|
||||||
"noBackupFilesDir" to context.noBackupFilesDir,
|
|
||||||
"obbDir" to context.obbDir,
|
"obbDir" to context.obbDir,
|
||||||
"externalCacheDir" to context.externalCacheDir,
|
"externalCacheDir" to context.externalCacheDir,
|
||||||
).apply {
|
).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 }
|
}.mapValues { it.value?.path }
|
||||||
|
|
||||||
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
|
|
@ -125,7 +125,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
pageId = pageId,
|
pageId = pageId,
|
||||||
sampleSize = sampleSize,
|
sampleSize = sampleSize,
|
||||||
regionRect = regionRect,
|
regionRect = regionRect,
|
||||||
imageSize = Size(imageWidth, imageHeight),
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
|
import androidx.core.os.EnvironmentCompat
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.utils.PermissionManager
|
import deckers.thibault.aves.utils.PermissionManager
|
||||||
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
|
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
|
||||||
|
@ -61,12 +62,19 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
for (volumePath in getVolumePaths(context)) {
|
for (volumePath in getVolumePaths(context)) {
|
||||||
val volumeFile = File(volumePath)
|
val volumeFile = File(volumePath)
|
||||||
try {
|
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(
|
volumes.add(
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
"path" to volumePath,
|
"path" to volumePath,
|
||||||
"isPrimary" to (volumePath == primaryVolumePath),
|
"isPrimary" to isPrimary,
|
||||||
"isRemovable" to Environment.isExternalStorageRemovable(volumeFile),
|
"isRemovable" to isRemovable,
|
||||||
"state" to Environment.getExternalStorageState(volumeFile)
|
"state" to EnvironmentCompat.getStorageState(volumeFile)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
|
@ -119,6 +127,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
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)
|
val success = PermissionManager.revokeDirectoryAccess(context, path)
|
||||||
result.success(success)
|
result.success(success)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class TimeHandler() : MethodCallHandler {
|
class TimeHandler : MethodCallHandler {
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getDefaultTimeZone" -> result.success(TimeZone.getDefault().id)
|
"getDefaultTimeZone" -> result.success(TimeZone.getDefault().id)
|
||||||
|
|
|
@ -6,7 +6,6 @@ import android.graphics.BitmapFactory
|
||||||
import android.graphics.BitmapRegionDecoder
|
import android.graphics.BitmapRegionDecoder
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Size
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
@ -37,7 +36,8 @@ class RegionFetcher internal constructor(
|
||||||
pageId: Int?,
|
pageId: Int?,
|
||||||
sampleSize: Int,
|
sampleSize: Int,
|
||||||
regionRect: Rect,
|
regionRect: Rect,
|
||||||
imageSize: Size,
|
imageWidth: Int,
|
||||||
|
imageHeight: Int,
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
|
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
|
||||||
|
@ -48,7 +48,8 @@ class RegionFetcher internal constructor(
|
||||||
pageId = null,
|
pageId = null,
|
||||||
sampleSize = sampleSize,
|
sampleSize = sampleSize,
|
||||||
regionRect = regionRect,
|
regionRect = regionRect,
|
||||||
imageSize = imageSize,
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
@ -79,9 +80,9 @@ class RegionFetcher internal constructor(
|
||||||
|
|
||||||
// with raw images, the known image size may not match the decoded image size
|
// with raw images, the known image size may not match the decoded image size
|
||||||
// so we scale the requested region accordingly
|
// so we scale the requested region accordingly
|
||||||
val effectiveRect = if (imageSize.width != decoder.width || imageSize.height != decoder.height) {
|
val effectiveRect = if (imageWidth != decoder.width || imageHeight != decoder.height) {
|
||||||
val xf = decoder.width.toDouble() / imageSize.width
|
val xf = decoder.width.toDouble() / imageWidth
|
||||||
val yf = decoder.height.toDouble() / imageSize.height
|
val yf = decoder.height.toDouble() / imageHeight
|
||||||
Rect(
|
Rect(
|
||||||
(regionRect.left * xf).roundToInt(),
|
(regionRect.left * xf).roundToInt(),
|
||||||
(regionRect.top * yf).roundToInt(),
|
(regionRect.top * yf).roundToInt(),
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package deckers.thibault.aves.channel.streams
|
package deckers.thibault.aves.channel.streams
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
@ -26,6 +27,16 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
this.eventSink = eventSink
|
this.eventSink = eventSink
|
||||||
handler = Handler(Looper.getMainLooper())
|
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) })
|
requestVolumeAccess(activity, path!!, { success(true) }, { success(false) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,6 +53,17 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
endOfStream()
|
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() {
|
private fun endOfStream() {
|
||||||
handler.post {
|
handler.post {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
@ -22,6 +23,7 @@ object PermissionManager {
|
||||||
// permission request code to pending runnable
|
// permission request code to pending runnable
|
||||||
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
|
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||||
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) {
|
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")
|
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>> {
|
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
|
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
|
||||||
val volumePaths = StorageUtils.getVolumePaths(context)
|
val volumePaths = StorageUtils.getVolumePaths(context)
|
||||||
ArrayList<Map<String, String>>().apply {
|
dirs.addAll(volumePaths.map {
|
||||||
addAll(volumePaths.map {
|
hashMapOf(
|
||||||
hashMapOf(
|
"volumePath" to it,
|
||||||
"volumePath" to it,
|
"relativeDir" to "",
|
||||||
"relativeDir" to "",
|
)
|
||||||
)
|
})
|
||||||
})
|
dirs.addAll(volumePaths.map {
|
||||||
addAll(volumePaths.map {
|
hashMapOf(
|
||||||
hashMapOf(
|
"volumePath" to it,
|
||||||
"volumePath" to it,
|
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
|
||||||
"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
|
||||||
} else {
|
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)
|
||||||
// TODO TLAD add KitKat restriction (no SD card root access) if min version goes to API 19-20
|
val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume }
|
||||||
ArrayList()
|
dirs.addAll(nonPrimaryVolumes.map {
|
||||||
|
hashMapOf(
|
||||||
|
"volumePath" to it,
|
||||||
|
"relativeDir" to "",
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
return dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||||
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
|
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
|
||||||
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
|
|
@ -12,6 +12,7 @@ import android.provider.MediaStore
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -246,6 +247,7 @@ object StorageUtils {
|
||||||
// e.g.
|
// e.g.
|
||||||
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
|
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
|
||||||
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
|
// /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? {
|
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
|
||||||
val uuid = getVolumeUuidForTreeUri(context, dirPath)
|
val uuid = getVolumeUuidForTreeUri(context, dirPath)
|
||||||
if (uuid != null) {
|
if (uuid != null) {
|
||||||
|
@ -287,7 +289,7 @@ object StorageUtils {
|
||||||
|
|
||||||
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
|
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
|
||||||
try {
|
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
|
// 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)) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
|
||||||
// cleanest API to get it
|
// cleanest API to get it
|
||||||
|
@ -311,7 +313,7 @@ object StorageUtils {
|
||||||
// returns null if directory does not exist and could not be created
|
// returns null if directory does not exist and could not be created
|
||||||
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
|
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
|
||||||
val cleanDirPath = ensureTrailingSeparator(dirPath)
|
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 grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
|
||||||
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
|
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
|
||||||
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
|
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
|
||||||
|
|
Loading…
Reference in a new issue