support Android Marshmallow API 23

This commit is contained in:
Thibault Deckers 2021-02-14 16:11:13 +09:00
parent 312f94e87e
commit 284a918971
22 changed files with 233 additions and 156 deletions

View file

@ -2,6 +2,8 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
### Added
- support Android Marshmallow (API 23)
## [v1.3.4] - 2021-02-10 ## [v1.3.4] - 2021-02-10
### Added ### 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…) - search and filter by country, place, XMP tag, type (animated, raster, vector…)
- favorites - favorites
- statistics - statistics
- support Android API 24 ~ 30 (Nougat ~ R) - support Android API 23 ~ 30 (Marshmallow ~ R)
- Android integration (app shortcuts, handle view/pick intents) - Android integration (app shortcuts, handle view/pick intents)
## Known Issues ## Known Issues

View file

@ -53,8 +53,7 @@ android {
defaultConfig { defaultConfig {
applicationId "deckers.thibault.aves" applicationId "deckers.thibault.aves"
// TODO TLAD try minSdkVersion 23 minSdkVersion 23
minSdkVersion 24
targetSdkVersion 30 // same as compileSdkVersion targetSdkVersion 30 // same as compileSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName

View file

@ -50,14 +50,15 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
private fun getContextDirs() = hashMapOf( private fun getContextDirs() = hashMapOf(
"dataDir" to context.dataDir,
"cacheDir" to context.cacheDir, "cacheDir" to context.cacheDir,
"codeCacheDir" to context.codeCacheDir, "codeCacheDir" to context.codeCacheDir,
"filesDir" to context.filesDir, "filesDir" to context.filesDir,
"noBackupFilesDir" to context.noBackupFilesDir, "noBackupFilesDir" to context.noBackupFilesDir,
"obbDir" to context.obbDir, "obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir, "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) { private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }

View file

@ -119,7 +119,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// optional parent to distinguish child directories of the same type // optional parent to distinguish child directories of the same type
dir.parent?.name?.let { dirName = "$it/$dirName" } dir.parent?.name?.let { dirName = "$it/$dirName" }
val dirMap = metadataMap.getOrDefault(dirName, HashMap()) val dirMap = metadataMap[dirName] ?: HashMap()
metadataMap[dirName] = dirMap metadataMap[dirName] = dirMap
// tags // tags
@ -594,7 +594,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
KEY_MIME_TYPE to trackMime, KEY_MIME_TYPE to trackMime,
) )
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 } 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_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it } format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (isVideo(trackMime)) { if (isVideo(trackMime)) {
@ -677,26 +679,35 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
val projection = arrayOf(prop) val projection = arrayOf(prop)
val cursor = context.contentResolver.query(contentUri, projection, null, null, null) val cursor: Cursor?
if (cursor != null && cursor.moveToFirst()) { try {
var value: Any? = null cursor = context.contentResolver.query(contentUri, projection, null, null, null)
try { } catch (e: Exception) {
value = when (cursor.getType(0)) { // throws SQLiteException when the requested prop is not a known column
Cursor.FIELD_TYPE_NULL -> null result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0) return
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)
} }
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) { private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {

View file

@ -4,9 +4,11 @@ import android.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
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.getVolumePaths import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel 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) { 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) val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) { if (sm != null) {
for (volumePath in getVolumePaths(context)) { for (volumePath in getVolumePaths(context)) {
try { try {
sm.getStorageVolume(File(volumePath))?.let { sm.getStorageVolume(File(volumePath))?.let {
val volumeMap = HashMap<String, Any>() volumes.add(
volumeMap["path"] = volumePath hashMapOf(
volumeMap["description"] = it.getDescription(context) "path" to volumePath,
volumeMap["isPrimary"] = it.isPrimary "description" to it.getDescription(context),
volumeMap["isRemovable"] = it.isRemovable "isPrimary" to it.isPrimary,
volumeMap["isEmulated"] = it.isEmulated "isRemovable" to it.isRemovable,
volumeMap["state"] = it.state "state" to it.state,
volumes.add(volumeMap) )
)
} }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
// ignore // ignore
} }
} }
} }
volumes
} else { } else {
// TODO TLAD find alternative for Android <N val primaryVolumePath = getPrimaryVolumePath(context)
emptyList() 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) result.success(volumes)
} }
@ -67,21 +83,9 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return 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, // `StorageStatsManager` `getFreeBytes()` is only available from API 26,
// and non-primary volume UUIDs cannot be used with it // and non-primary volume UUIDs cannot be used with it
val file = File(path)
try { try {
result.success(file.freeSpace) result.success(file.freeSpace)
} catch (e: SecurityException) { } catch (e: SecurityException) {

View file

@ -9,6 +9,7 @@ import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.module.AppGlideModule
import deckers.thibault.aves.utils.compatRemoveIf
@GlideModule @GlideModule
class AvesAppGlideModule : AppGlideModule() { class AvesAppGlideModule : AppGlideModule() {
@ -20,7 +21,7 @@ class AvesAppGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) { override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// prevent ExifInterface error logs // prevent ExifInterface error logs
// cf https://github.com/bumptech/glide/issues/3383 // 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 override fun isManifestParsingEnabled(): Boolean = false

View file

@ -17,9 +17,6 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate", MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate", MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number", 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_COMPILATION to "Compilation",
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer", MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
MediaMetadataRetriever.METADATA_KEY_DATE to "Date", 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") } private val durationFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }

View file

@ -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), // 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. // 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. // 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 // 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 // to a temporary file, and reusing that preview file for all metadata reading purposes

View file

@ -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 // `xmlBytes`: bytes representing the XML embedded in a MP4 `uuid` box, according to Spherical Video V1 spec
class GSpherical(xmlBytes: ByteArray) { class GSpherical(xmlBytes: ByteArray) {
var spherical: Boolean = false private var spherical: Boolean = false
var stitched: Boolean = false private var stitched: Boolean = false
var stitchingSoftware: String = "" private var stitchingSoftware: String = ""
var projectionType: String = "" private var projectionType: String = ""
var stereoMode: String? = null private var stereoMode: String? = null
var sourceCount: Int? = null private var sourceCount: Int? = null
var initialViewHeadingDegrees: Int? = null private var initialViewHeadingDegrees: Int? = null
var initialViewPitchDegrees: Int? = null private var initialViewPitchDegrees: Int? = null
var initialViewRollDegrees: Int? = null private var initialViewRollDegrees: Int? = null
var timestamp: Int? = null private var timestamp: Int? = null
var fullPanoWidthPixels: Int? = null private var fullPanoWidthPixels: Int? = null
var fullPanoHeightPixels: Int? = null private var fullPanoHeightPixels: Int? = null
var croppedAreaImageWidthPixels: Int? = null private var croppedAreaImageWidthPixels: Int? = null
var croppedAreaImageHeightPixels: Int? = null private var croppedAreaImageHeightPixels: Int? = null
var croppedAreaLeftPixels: Int? = null private var croppedAreaLeftPixels: Int? = null
var croppedAreaTopPixels: Int? = null private var croppedAreaTopPixels: Int? = null
init { init {
try { try {

View file

@ -40,7 +40,7 @@ object TiffTags {
// Matteing // Matteing
// Tag = 32995 (80E3.H) // Tag = 32995 (80E3.H)
// obsoleted by the 6.0 ExtraSamples (338) // obsoleted by the 6.0 ExtraSamples (338)
val TAG_MATTEING = 0x80e3 const val TAG_MATTEING = 0x80e3
/* /*
GeoTIFF GeoTIFF
@ -80,7 +80,7 @@ object TiffTags {
// Tag = 34737 (87B1.H) // Tag = 34737 (87B1.H)
// Type = ASCII // Type = ASCII
// Count = variable // Count = variable
val TAG_GEO_ASCII_PARAMS = 0x87b1 const val TAG_GEO_ASCII_PARAMS = 0x87b1
/* /*
Photoshop Photoshop
@ -91,7 +91,7 @@ object TiffTags {
// ImageSourceData // ImageSourceData
// Tag = 37724 (935C.H) // Tag = 37724 (935C.H)
// Type = UNDEFINED // Type = UNDEFINED
val TAG_IMAGE_SOURCE_DATA = 0x935c const val TAG_IMAGE_SOURCE_DATA = 0x935c
/* /*
DNG DNG
@ -102,13 +102,13 @@ object TiffTags {
// Tag = 50735 (C62F.H) // Tag = 50735 (C62F.H)
// Type = ASCII // Type = ASCII
// Count = variable // Count = variable
val TAG_CAMERA_SERIAL_NUMBER = 0xc62f const val TAG_CAMERA_SERIAL_NUMBER = 0xc62f
// OriginalRawFileName (optional) // OriginalRawFileName (optional)
// Tag = 50827 (C68B.H) // Tag = 50827 (C68B.H)
// Type = ASCII or BYTE // Type = ASCII or BYTE
// Count = variable // Count = variable
val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b const val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b
private val tagNameMap = hashMapOf( private val tagNameMap = hashMapOf(
TAG_X_POSITION to "X Position", TAG_X_POSITION to "X Position",

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.model package deckers.thibault.aves.model
import android.net.Uri import android.net.Uri
import deckers.thibault.aves.model.FieldMap
class AvesEntry(map: FieldMap) { class AvesEntry(map: FieldMap) {
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI

View file

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

View file

@ -63,7 +63,7 @@ object PermissionManager {
// inaccessible dirs // inaccessible dirs
val segments = PathSegments(context, dirPath) val segments = PathSegments(context, dirPath)
segments.volumePath?.let { volumePath -> segments.volumePath?.let { volumePath ->
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet()) val dirSet = dirsPerVolume[volumePath] ?: HashSet()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// request primary directory on volume from Android R // request primary directory on volume from Android R
segments.relativeDir?.apply { segments.relativeDir?.apply {
@ -80,26 +80,16 @@ object PermissionManager {
} }
// format for easier handling on Flutter // format for easier handling on Flutter
val inaccessibleDirs = ArrayList<Map<String, String>>() return ArrayList<Map<String, String>>().apply {
val sm = context.getSystemService(StorageManager::class.java) addAll(dirsPerVolume.flatMap { (volumePath, relativeDirs) ->
if (sm != null) { relativeDirs.map { relativeDir ->
for ((volumePath, relativeDirs) in dirsPerVolume) { hashMapOf(
var volumeDescription: String? = null "volumePath" to volumePath,
try { "relativeDir" to relativeDir,
volumeDescription = sm.getStorageVolume(File(volumePath))?.getDescription(context) )
} catch (e: IllegalArgumentException) {
// ignore
} }
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 { fun revokeDirectoryAccess(context: Context, path: String): Boolean {

View file

@ -177,41 +177,68 @@ object StorageUtils {
* Volume tree URIs * Volume tree URIs
*/ */
// e.g.
// /storage/emulated/0/ -> primary
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? { private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
val sm = context.getSystemService(StorageManager::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (sm != null) { context.getSystemService(StorageManager::class.java)?.let { sm ->
val volume = sm.getStorageVolume(File(anyPath)) sm.getStorageVolume(File(anyPath))?.let { volume ->
if (volume != null) { if (volume.isPrimary) {
if (volume.isPrimary) { return "primary"
return "primary" }
} volume.uuid?.let { uuid ->
val uuid = volume.uuid return uuid.toUpperCase(Locale.ROOT)
if (uuid != null) { }
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") Log.e(LOG_TAG, "failed to find volume UUID for anyPath=$anyPath")
return null return null
} }
// e.g.
// primary -> /storage/emulated/0/
// 10F9-3F13 -> /storage/10F9-3F13/
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? { private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
if (uuid == "primary") { if (uuid == "primary") {
return getPrimaryVolumePath(context) return getPrimaryVolumePath(context)
} }
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
for (volumePath in getVolumePaths(context)) { context.getSystemService(StorageManager::class.java)?.let { sm ->
try { for (volumePath in getVolumePaths(context)) {
val volume = sm.getStorageVolume(File(volumePath)) try {
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) { val volume = sm.getStorageVolume(File(volumePath))
return 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") Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
return null return null
} }

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.4.21' ext.kotlin_version = '1.4.30'
repositories { repositories {
google() google()
jcenter() jcenter()
@ -10,7 +10,7 @@ buildscript {
classpath 'com.android.tools.build:gradle:3.6.4' classpath 'com.android.tools.build:gradle:3.6.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.5' 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'
} }
} }

View file

@ -132,7 +132,10 @@ class MediaStoreSource extends CollectionSource {
final uriByContentId = Map.fromEntries(changedUris.map((uri) { final uriByContentId = Map.fromEntries(changedUris.map((uri) {
if (uri == null) return null; 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); final contentId = int.tryParse(idString);
if (contentId == null) return null; if (contentId == null) return null;
return MapEntry(contentId, uri); return MapEntry(contentId, uri);

View file

@ -53,7 +53,7 @@ class AndroidFileService {
} }
// returns a list of directories, // 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 { static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
try { try {
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{ final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{

View file

@ -114,11 +114,10 @@ class Package {
class StorageVolume { class StorageVolume {
final String description, path, state; final String description, path, state;
final bool isEmulated, isPrimary, isRemovable; final bool isPrimary, isRemovable;
const StorageVolume({ const StorageVolume({
this.description, this.description,
this.isEmulated,
this.isPrimary, this.isPrimary,
this.isRemovable, this.isRemovable,
this.path, this.path,
@ -126,10 +125,10 @@ class StorageVolume {
}); });
factory StorageVolume.fromMap(Map map) { factory StorageVolume.fromMap(Map map) {
final isPrimary = map['isPrimary'] ?? false;
return StorageVolume( return StorageVolume(
description: map['description'] ?? '', description: map['description'] ?? (isPrimary ? 'Internal storage' : 'SD card'),
isEmulated: map['isEmulated'] ?? false, isPrimary: isPrimary,
isPrimary: map['isPrimary'] ?? false,
isRemovable: map['isRemovable'] ?? false, isRemovable: map['isRemovable'] ?? false,
path: map['path'] ?? '', path: map['path'] ?? '',
state: map['state'] ?? '', state: map['state'] ?? '',

View file

@ -1,5 +1,6 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/android_file_service.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:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -16,8 +17,10 @@ mixin PermissionAwareMixin {
final dir = dirs.first; final dir = dirs.first;
final volumePath = dir['volumePath'] as String; final volumePath = dir['volumePath'] as String;
final volumeDescription = dir['volumeDescription'] as String;
final relativeDir = dir['relativeDir'] 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 dirDisplayName = relativeDir.isEmpty ? 'root' : '$relativeDir';
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(

View file

@ -40,7 +40,6 @@ class _DebugStorageSectionState extends State<DebugStorageSection> with Automati
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: InfoRowGroup({ child: InfoRowGroup({
'description': '${v.description}', 'description': '${v.description}',
'isEmulated': '${v.isEmulated}',
'isPrimary': '${v.isPrimary}', 'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}', 'isRemovable': '${v.isRemovable}',
'state': '${v.state}', 'state': '${v.state}',

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -40,39 +41,29 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
@override @override
Widget build(BuildContext context) { 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( return AvesDialog(
context: context, context: context,
title: 'New Album', title: 'New Album',
scrollController: _scrollController, scrollController: _scrollController,
scrollableContent: [ scrollableContent: [
if (_allVolumes.length > 1) ...[ ...volumeTiles,
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),
],
Padding( Padding(
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(bottom: 8), padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(bottom: 8),
child: ValueListenableBuilder<bool>( 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 { void _onFocus() async {
// when the field gets focus, we wait for the soft keyboard to appear // 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 // then scroll to the bottom to make sure the field is in view