support Android Marshmallow API 23
This commit is contained in:
parent
312f94e87e
commit
284a918971
22 changed files with 233 additions and 156 deletions
|
@ -2,6 +2,8 @@
|
|||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- support Android Marshmallow (API 23)
|
||||
|
||||
## [v1.3.4] - 2021-02-10
|
||||
### 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…)
|
||||
- favorites
|
||||
- statistics
|
||||
- support Android API 24 ~ 30 (Nougat ~ R)
|
||||
- support Android API 23 ~ 30 (Marshmallow ~ R)
|
||||
- Android integration (app shortcuts, handle view/pick intents)
|
||||
|
||||
## Known Issues
|
||||
|
|
|
@ -53,8 +53,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId "deckers.thibault.aves"
|
||||
// TODO TLAD try minSdkVersion 23
|
||||
minSdkVersion 24
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 30 // same as compileSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
|
|
@ -50,14 +50,15 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getContextDirs() = hashMapOf(
|
||||
"dataDir" to context.dataDir,
|
||||
"cacheDir" to context.cacheDir,
|
||||
"codeCacheDir" to context.codeCacheDir,
|
||||
"filesDir" to context.filesDir,
|
||||
"noBackupFilesDir" to context.noBackupFilesDir,
|
||||
"obbDir" to context.obbDir,
|
||||
"externalCacheDir" to context.externalCacheDir,
|
||||
).mapValues { it.value?.path }
|
||||
).apply {
|
||||
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) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
|
|
|
@ -119,7 +119,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
// optional parent to distinguish child directories of the same type
|
||||
dir.parent?.name?.let { dirName = "$it/$dirName" }
|
||||
|
||||
val dirMap = metadataMap.getOrDefault(dirName, HashMap())
|
||||
val dirMap = metadataMap[dirName] ?: HashMap()
|
||||
metadataMap[dirName] = dirMap
|
||||
|
||||
// tags
|
||||
|
@ -594,7 +594,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
KEY_MIME_TYPE to trackMime,
|
||||
)
|
||||
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
|
||||
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
|
||||
}
|
||||
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
||||
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
||||
if (isVideo(trackMime)) {
|
||||
|
@ -677,26 +679,35 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
val projection = arrayOf(prop)
|
||||
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
var value: Any? = null
|
||||
try {
|
||||
value = when (cursor.getType(0)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
|
||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
|
||||
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
|
||||
}
|
||||
cursor.close()
|
||||
result.success(value?.toString())
|
||||
} else {
|
||||
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
|
||||
val cursor: Cursor?
|
||||
try {
|
||||
cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
||||
} catch (e: Exception) {
|
||||
// throws SQLiteException when the requested prop is not a known column
|
||||
result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
|
||||
return
|
||||
}
|
||||
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
result.error("getContentResolverProp-cursor", "failed to get cursor for contentUri=$contentUri", null)
|
||||
return
|
||||
}
|
||||
|
||||
var value: Any? = null
|
||||
try {
|
||||
value = when (cursor.getType(0)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
|
||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
|
||||
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
|
||||
}
|
||||
cursor.close()
|
||||
result.success(value?.toString())
|
||||
}
|
||||
|
||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
|
|
@ -4,9 +4,11 @@ import android.content.Context
|
|||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
|
||||
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
@ -31,31 +33,45 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val volumes = ArrayList<Map<String, Any>>()
|
||||
val volumes = ArrayList<Map<String, Any>>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
sm.getStorageVolume(File(volumePath))?.let {
|
||||
val volumeMap = HashMap<String, Any>()
|
||||
volumeMap["path"] = volumePath
|
||||
volumeMap["description"] = it.getDescription(context)
|
||||
volumeMap["isPrimary"] = it.isPrimary
|
||||
volumeMap["isRemovable"] = it.isRemovable
|
||||
volumeMap["isEmulated"] = it.isEmulated
|
||||
volumeMap["state"] = it.state
|
||||
volumes.add(volumeMap)
|
||||
volumes.add(
|
||||
hashMapOf(
|
||||
"path" to volumePath,
|
||||
"description" to it.getDescription(context),
|
||||
"isPrimary" to it.isPrimary,
|
||||
"isRemovable" to it.isRemovable,
|
||||
"state" to it.state,
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
volumes
|
||||
} else {
|
||||
// TODO TLAD find alternative for Android <N
|
||||
emptyList()
|
||||
val primaryVolumePath = getPrimaryVolumePath(context)
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
val volumeFile = File(volumePath)
|
||||
try {
|
||||
volumes.add(
|
||||
hashMapOf(
|
||||
"path" to volumePath,
|
||||
"isPrimary" to (volumePath == primaryVolumePath),
|
||||
"isRemovable" to Environment.isExternalStorageRemovable(volumeFile),
|
||||
"state" to Environment.getExternalStorageState(volumeFile)
|
||||
)
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
result.success(volumes)
|
||||
}
|
||||
|
@ -67,21 +83,9 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm == null) {
|
||||
result.error("getFreeSpace-sm", "failed because of missing Storage Manager", null)
|
||||
return
|
||||
}
|
||||
|
||||
val file = File(path)
|
||||
val volume = sm.getStorageVolume(file)
|
||||
if (volume == null) {
|
||||
result.error("getFreeSpace-volume", "failed because of missing volume for path=$path", null)
|
||||
return
|
||||
}
|
||||
|
||||
// `StorageStatsManager` `getFreeBytes()` is only available from API 26,
|
||||
// and non-primary volume UUIDs cannot be used with it
|
||||
val file = File(path)
|
||||
try {
|
||||
result.success(file.freeSpace)
|
||||
} catch (e: SecurityException) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.bumptech.glide.annotation.GlideModule
|
|||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import deckers.thibault.aves.utils.compatRemoveIf
|
||||
|
||||
@GlideModule
|
||||
class AvesAppGlideModule : AppGlideModule() {
|
||||
|
@ -20,7 +21,7 @@ class AvesAppGlideModule : AppGlideModule() {
|
|||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
// prevent ExifInterface error logs
|
||||
// cf https://github.com/bumptech/glide/issues/3383
|
||||
glide.registry.imageHeaderParsers.removeIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser }
|
||||
glide.registry.imageHeaderParsers.compatRemoveIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser }
|
||||
}
|
||||
|
||||
override fun isManifestParsingEnabled(): Boolean = false
|
||||
|
|
|
@ -17,9 +17,6 @@ object MediaMetadataRetrieverHelper {
|
|||
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
|
||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
|
||||
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
|
||||
MediaMetadataRetriever.METADATA_KEY_COMPILATION to "Compilation",
|
||||
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
|
||||
MediaMetadataRetriever.METADATA_KEY_DATE to "Date",
|
||||
|
@ -59,6 +56,15 @@ object MediaMetadataRetrieverHelper {
|
|||
)
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
putAll(
|
||||
hashMapOf(
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val durationFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
|
|
|
@ -95,7 +95,7 @@ object Metadata {
|
|||
// opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
|
||||
// so we define an arbitrary threshold to avoid a crash on launch.
|
||||
// It is not clear whether it is because of the file itself or its metadata.
|
||||
const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
|
||||
private const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
|
||||
|
||||
// we try and read metadata from large files by copying an arbitrary amount from its beginning
|
||||
// to a temporary file, and reusing that preview file for all metadata reading purposes
|
||||
|
|
|
@ -8,22 +8,22 @@ import java.io.ByteArrayInputStream
|
|||
|
||||
// `xmlBytes`: bytes representing the XML embedded in a MP4 `uuid` box, according to Spherical Video V1 spec
|
||||
class GSpherical(xmlBytes: ByteArray) {
|
||||
var spherical: Boolean = false
|
||||
var stitched: Boolean = false
|
||||
var stitchingSoftware: String = ""
|
||||
var projectionType: String = ""
|
||||
var stereoMode: String? = null
|
||||
var sourceCount: Int? = null
|
||||
var initialViewHeadingDegrees: Int? = null
|
||||
var initialViewPitchDegrees: Int? = null
|
||||
var initialViewRollDegrees: Int? = null
|
||||
var timestamp: Int? = null
|
||||
var fullPanoWidthPixels: Int? = null
|
||||
var fullPanoHeightPixels: Int? = null
|
||||
var croppedAreaImageWidthPixels: Int? = null
|
||||
var croppedAreaImageHeightPixels: Int? = null
|
||||
var croppedAreaLeftPixels: Int? = null
|
||||
var croppedAreaTopPixels: Int? = null
|
||||
private var spherical: Boolean = false
|
||||
private var stitched: Boolean = false
|
||||
private var stitchingSoftware: String = ""
|
||||
private var projectionType: String = ""
|
||||
private var stereoMode: String? = null
|
||||
private var sourceCount: Int? = null
|
||||
private var initialViewHeadingDegrees: Int? = null
|
||||
private var initialViewPitchDegrees: Int? = null
|
||||
private var initialViewRollDegrees: Int? = null
|
||||
private var timestamp: Int? = null
|
||||
private var fullPanoWidthPixels: Int? = null
|
||||
private var fullPanoHeightPixels: Int? = null
|
||||
private var croppedAreaImageWidthPixels: Int? = null
|
||||
private var croppedAreaImageHeightPixels: Int? = null
|
||||
private var croppedAreaLeftPixels: Int? = null
|
||||
private var croppedAreaTopPixels: Int? = null
|
||||
|
||||
init {
|
||||
try {
|
||||
|
|
|
@ -40,7 +40,7 @@ object TiffTags {
|
|||
// Matteing
|
||||
// Tag = 32995 (80E3.H)
|
||||
// obsoleted by the 6.0 ExtraSamples (338)
|
||||
val TAG_MATTEING = 0x80e3
|
||||
const val TAG_MATTEING = 0x80e3
|
||||
|
||||
/*
|
||||
GeoTIFF
|
||||
|
@ -80,7 +80,7 @@ object TiffTags {
|
|||
// Tag = 34737 (87B1.H)
|
||||
// Type = ASCII
|
||||
// Count = variable
|
||||
val TAG_GEO_ASCII_PARAMS = 0x87b1
|
||||
const val TAG_GEO_ASCII_PARAMS = 0x87b1
|
||||
|
||||
/*
|
||||
Photoshop
|
||||
|
@ -91,7 +91,7 @@ object TiffTags {
|
|||
// ImageSourceData
|
||||
// Tag = 37724 (935C.H)
|
||||
// Type = UNDEFINED
|
||||
val TAG_IMAGE_SOURCE_DATA = 0x935c
|
||||
const val TAG_IMAGE_SOURCE_DATA = 0x935c
|
||||
|
||||
/*
|
||||
DNG
|
||||
|
@ -102,13 +102,13 @@ object TiffTags {
|
|||
// Tag = 50735 (C62F.H)
|
||||
// Type = ASCII
|
||||
// Count = variable
|
||||
val TAG_CAMERA_SERIAL_NUMBER = 0xc62f
|
||||
const val TAG_CAMERA_SERIAL_NUMBER = 0xc62f
|
||||
|
||||
// OriginalRawFileName (optional)
|
||||
// Tag = 50827 (C68B.H)
|
||||
// Type = ASCII or BYTE
|
||||
// Count = variable
|
||||
val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b
|
||||
const val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b
|
||||
|
||||
private val tagNameMap = hashMapOf(
|
||||
TAG_X_POSITION to "X Position",
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
|
||||
class AvesEntry(map: FieldMap) {
|
||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.os.Build
|
||||
|
||||
// compatibility extension for `removeIf` for API < N
|
||||
fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
this.removeIf(filter)
|
||||
} else {
|
||||
var removed = false
|
||||
val each = this.iterator()
|
||||
while (each.hasNext()) {
|
||||
if (filter(each.next())) {
|
||||
each.remove()
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
}
|
|
@ -63,7 +63,7 @@ object PermissionManager {
|
|||
// inaccessible dirs
|
||||
val segments = PathSegments(context, dirPath)
|
||||
segments.volumePath?.let { volumePath ->
|
||||
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet())
|
||||
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// request primary directory on volume from Android R
|
||||
segments.relativeDir?.apply {
|
||||
|
@ -80,26 +80,16 @@ object PermissionManager {
|
|||
}
|
||||
|
||||
// format for easier handling on Flutter
|
||||
val inaccessibleDirs = ArrayList<Map<String, String>>()
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for ((volumePath, relativeDirs) in dirsPerVolume) {
|
||||
var volumeDescription: String? = null
|
||||
try {
|
||||
volumeDescription = sm.getStorageVolume(File(volumePath))?.getDescription(context)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
return ArrayList<Map<String, String>>().apply {
|
||||
addAll(dirsPerVolume.flatMap { (volumePath, relativeDirs) ->
|
||||
relativeDirs.map { relativeDir ->
|
||||
hashMapOf(
|
||||
"volumePath" to volumePath,
|
||||
"relativeDir" to relativeDir,
|
||||
)
|
||||
}
|
||||
for (relativeDir in relativeDirs) {
|
||||
val dirMap = HashMap<String, String>()
|
||||
dirMap["volumePath"] = volumePath
|
||||
dirMap["volumeDescription"] = volumeDescription ?: ""
|
||||
dirMap["relativeDir"] = relativeDir
|
||||
inaccessibleDirs.add(dirMap)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return inaccessibleDirs
|
||||
}
|
||||
|
||||
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
|
||||
|
|
|
@ -177,41 +177,68 @@ object StorageUtils {
|
|||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
// e.g.
|
||||
// /storage/emulated/0/ -> primary
|
||||
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
|
||||
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
val volume = sm.getStorageVolume(File(anyPath))
|
||||
if (volume != null) {
|
||||
if (volume.isPrimary) {
|
||||
return "primary"
|
||||
}
|
||||
val uuid = volume.uuid
|
||||
if (uuid != null) {
|
||||
return uuid.toUpperCase(Locale.ROOT)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
context.getSystemService(StorageManager::class.java)?.let { sm ->
|
||||
sm.getStorageVolume(File(anyPath))?.let { volume ->
|
||||
if (volume.isPrimary) {
|
||||
return "primary"
|
||||
}
|
||||
volume.uuid?.let { uuid ->
|
||||
return uuid.toUpperCase(Locale.ROOT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback for <N
|
||||
getVolumePath(context, anyPath)?.let { volumePath ->
|
||||
if (volumePath == getPrimaryVolumePath(context)) {
|
||||
return "primary"
|
||||
}
|
||||
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
|
||||
return uuid.toUpperCase(Locale.ROOT)
|
||||
}
|
||||
}
|
||||
|
||||
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=$anyPath")
|
||||
return null
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// primary -> /storage/emulated/0/
|
||||
// 10F9-3F13 -> /storage/10F9-3F13/
|
||||
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
|
||||
if (uuid == "primary") {
|
||||
return getPrimaryVolumePath(context)
|
||||
}
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
val volume = sm.getStorageVolume(File(volumePath))
|
||||
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
|
||||
return volumePath
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
context.getSystemService(StorageManager::class.java)?.let { sm ->
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
val volume = sm.getStorageVolume(File(volumePath))
|
||||
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
|
||||
return volumePath
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback for <N
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
val volumeUuid = volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }
|
||||
if (uuid.equals(volumeUuid, ignoreCase = true)) {
|
||||
return volumePath
|
||||
}
|
||||
}
|
||||
|
||||
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.4.21'
|
||||
ext.kotlin_version = '1.4.30'
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
|
@ -10,7 +10,7 @@ buildscript {
|
|||
classpath 'com.android.tools.build:gradle:3.6.4'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.0'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -132,7 +132,10 @@ class MediaStoreSource extends CollectionSource {
|
|||
|
||||
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
|
||||
if (uri == null) return null;
|
||||
final idString = Uri.parse(uri).pathSegments.last;
|
||||
final pathSegments = Uri.parse(uri).pathSegments;
|
||||
// e.g. URI `content://media/` has no path segment
|
||||
if (pathSegments.isEmpty) return null;
|
||||
final idString = pathSegments.last;
|
||||
final contentId = int.tryParse(idString);
|
||||
if (contentId == null) return null;
|
||||
return MapEntry(contentId, uri);
|
||||
|
|
|
@ -53,7 +53,7 @@ class AndroidFileService {
|
|||
}
|
||||
|
||||
// returns a list of directories,
|
||||
// each directory is a map with "volumePath", "volumeDescription", "relativeDir"
|
||||
// each directory is a map with "volumePath", "relativeDir"
|
||||
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
|
||||
|
|
|
@ -114,11 +114,10 @@ class Package {
|
|||
|
||||
class StorageVolume {
|
||||
final String description, path, state;
|
||||
final bool isEmulated, isPrimary, isRemovable;
|
||||
final bool isPrimary, isRemovable;
|
||||
|
||||
const StorageVolume({
|
||||
this.description,
|
||||
this.isEmulated,
|
||||
this.isPrimary,
|
||||
this.isRemovable,
|
||||
this.path,
|
||||
|
@ -126,10 +125,10 @@ class StorageVolume {
|
|||
});
|
||||
|
||||
factory StorageVolume.fromMap(Map map) {
|
||||
final isPrimary = map['isPrimary'] ?? false;
|
||||
return StorageVolume(
|
||||
description: map['description'] ?? '',
|
||||
isEmulated: map['isEmulated'] ?? false,
|
||||
isPrimary: map['isPrimary'] ?? false,
|
||||
description: map['description'] ?? (isPrimary ? 'Internal storage' : 'SD card'),
|
||||
isPrimary: isPrimary,
|
||||
isRemovable: map['isRemovable'] ?? false,
|
||||
path: map['path'] ?? '',
|
||||
state: map['state'] ?? '',
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -16,8 +17,10 @@ mixin PermissionAwareMixin {
|
|||
|
||||
final dir = dirs.first;
|
||||
final volumePath = dir['volumePath'] as String;
|
||||
final volumeDescription = dir['volumeDescription'] as String;
|
||||
final relativeDir = dir['relativeDir'] as String;
|
||||
|
||||
final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null);
|
||||
final volumeDescription = volume?.description ?? volumePath;
|
||||
final dirDisplayName = relativeDir.isEmpty ? 'root' : '“$relativeDir”';
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
|
|
|
@ -40,7 +40,6 @@ class _DebugStorageSectionState extends State<DebugStorageSection> with Automati
|
|||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: InfoRowGroup({
|
||||
'description': '${v.description}',
|
||||
'isEmulated': '${v.isEmulated}',
|
||||
'isPrimary': '${v.isPrimary}',
|
||||
'isRemovable': '${v.isRemovable}',
|
||||
'state': '${v.state}',
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
@ -40,39 +41,29 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final volumeTiles = <Widget>[];
|
||||
if (_allVolumes.length > 1) {
|
||||
final byPrimary = groupBy<StorageVolume, bool>(_allVolumes, (volume) => volume.isPrimary);
|
||||
int compare(StorageVolume a, StorageVolume b) => compareAsciiUpperCase(a.path, b.path);
|
||||
final primaryVolumes = byPrimary[true]..sort(compare);
|
||||
final otherVolumes = byPrimary[false]..sort(compare);
|
||||
volumeTiles.addAll([
|
||||
Padding(
|
||||
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(top: 20),
|
||||
child: Text('Storage:'),
|
||||
),
|
||||
...primaryVolumes.map(_buildVolumeTile),
|
||||
...otherVolumes.map(_buildVolumeTile),
|
||||
SizedBox(height: 8),
|
||||
]);
|
||||
}
|
||||
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
title: 'New Album',
|
||||
scrollController: _scrollController,
|
||||
scrollableContent: [
|
||||
if (_allVolumes.length > 1) ...[
|
||||
Padding(
|
||||
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(top: 20),
|
||||
child: Text('Storage:'),
|
||||
),
|
||||
..._allVolumes.map((volume) => RadioListTile<StorageVolume>(
|
||||
value: volume,
|
||||
groupValue: _selectedVolume,
|
||||
onChanged: (volume) {
|
||||
_selectedVolume = volume;
|
||||
_validate();
|
||||
setState(() {});
|
||||
},
|
||||
title: Text(
|
||||
volume.description,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: Text(
|
||||
volume.path,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
)),
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
...volumeTiles,
|
||||
Padding(
|
||||
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(bottom: 8),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
|
@ -110,6 +101,28 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildVolumeTile(StorageVolume volume) => RadioListTile<StorageVolume>(
|
||||
value: volume,
|
||||
groupValue: _selectedVolume,
|
||||
onChanged: (volume) {
|
||||
_selectedVolume = volume;
|
||||
_validate();
|
||||
setState(() {});
|
||||
},
|
||||
title: Text(
|
||||
volume.description,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: Text(
|
||||
volume.path,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
);
|
||||
|
||||
void _onFocus() async {
|
||||
// when the field gets focus, we wait for the soft keyboard to appear
|
||||
// then scroll to the bottom to make sure the field is in view
|
||||
|
|
Loading…
Reference in a new issue