From 284a918971ba94e1b678ad6c29fb8797f96c3290 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 14 Feb 2021 16:11:13 +0900 Subject: [PATCH] support Android Marshmallow API 23 --- CHANGELOG.md | 2 + README.md | 2 +- android/app/build.gradle | 3 +- .../aves/channel/calls/DebugHandler.kt | 5 +- .../aves/channel/calls/MetadataHandler.kt | 53 ++++++++------ .../aves/channel/calls/StorageHandler.kt | 56 ++++++++------- .../aves/decoder/AvesAppGlideModule.kt | 3 +- .../metadata/MediaMetadataRetrieverHelper.kt | 12 +++- .../thibault/aves/metadata/Metadata.kt | 2 +- .../thibault/aves/metadata/SphericalVideo.kt | 32 ++++----- .../thibault/aves/metadata/TiffTags.kt | 10 +-- .../deckers/thibault/aves/model/AvesEntry.kt | 1 - .../thibault/aves/utils/CollectionUtils.kt | 20 ++++++ .../thibault/aves/utils/PermissionManager.kt | 28 +++----- .../thibault/aves/utils/StorageUtils.kt | 65 ++++++++++++----- android/build.gradle | 4 +- lib/model/source/media_store_source.dart | 5 +- lib/services/android_file_service.dart | 2 +- lib/utils/android_file_utils.dart | 9 ++- .../action_mixins/permission_aware.dart | 5 +- lib/widgets/debug/storage.dart | 1 - lib/widgets/dialogs/create_album_dialog.dart | 69 +++++++++++-------- 22 files changed, 233 insertions(+), 156 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff63c3db..0998cc081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index ba87c7bcd..8eb19d9d7 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 24 ~ 30 (Nougat ~ R) +- support Android API 23 ~ 30 (Marshmallow ~ R) - Android integration (app shortcuts, handle view/pick intents) ## Known Issues diff --git a/android/app/build.gradle b/android/app/build.gradle index 8121a9d6a..7da22a93d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 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 0de4bf39f..9ffb58ebe 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 @@ -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("uri")?.let { Uri.parse(it) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index d0e315d08..6046c7508 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -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) { 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 248db4ff6..26acc1bf6 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 @@ -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> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - val volumes = ArrayList>() + val volumes = ArrayList>() + 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() - 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 parser is ExifInterfaceImageHeaderParser } + glide.registry.imageHeaderParsers.compatRemoveIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser } } override fun isManifestParsingEnabled(): Boolean = false diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt index 69bd0f3da..6852bedda 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt @@ -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") } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 47b649f64..a776b8c53 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt index d3e07061e..5aec0dba7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt @@ -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 { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt index 0f6dce703..6fd91d5e1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt @@ -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", diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt index 872ed6819..ab6f58ce9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesEntry.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt new file mode 100644 index 000000000..56c78df1f --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/CollectionUtils.kt @@ -0,0 +1,20 @@ +package deckers.thibault.aves.utils + +import android.os.Build + +// compatibility extension for `removeIf` for API < N +fun MutableList.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 + } +} \ No newline at end of file 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 bb737b892..49d1b005b 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 @@ -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>() - 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>().apply { + addAll(dirsPerVolume.flatMap { (volumePath, relativeDirs) -> + relativeDirs.map { relativeDir -> + hashMapOf( + "volumePath" to volumePath, + "relativeDir" to relativeDir, + ) } - for (relativeDir in relativeDirs) { - val dirMap = HashMap() - dirMap["volumePath"] = volumePath - dirMap["volumeDescription"] = volumeDescription ?: "" - dirMap["relativeDir"] = relativeDir - inaccessibleDirs.add(dirMap) - } - } + }) } - return inaccessibleDirs } fun revokeDirectoryAccess(context: Context, path: String): Boolean { 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 e3f644b90..e84134608 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 @@ -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 + 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 > getInaccessibleDirectories(Iterable dirPaths) async { try { final result = await platform.invokeMethod('getInaccessibleDirectories', { diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 12a71dd47..f97ccb3a3 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -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'] ?? '', diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index 5d3ac5255..d57373c8b 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -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( diff --git a/lib/widgets/debug/storage.dart b/lib/widgets/debug/storage.dart index d684157bd..b3a1ac803 100644 --- a/lib/widgets/debug/storage.dart +++ b/lib/widgets/debug/storage.dart @@ -40,7 +40,6 @@ class _DebugStorageSectionState extends State with Automati padding: EdgeInsets.symmetric(horizontal: 8), child: InfoRowGroup({ 'description': '${v.description}', - 'isEmulated': '${v.isEmulated}', 'isPrimary': '${v.isPrimary}', 'isRemovable': '${v.isRemovable}', 'state': '${v.state}', diff --git a/lib/widgets/dialogs/create_album_dialog.dart b/lib/widgets/dialogs/create_album_dialog.dart index 0b746a5c3..2ac658691 100644 --- a/lib/widgets/dialogs/create_album_dialog.dart +++ b/lib/widgets/dialogs/create_album_dialog.dart @@ -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 { @override Widget build(BuildContext context) { + final volumeTiles = []; + if (_allVolumes.length > 1) { + final byPrimary = groupBy(_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( - 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( @@ -110,6 +101,28 @@ class _CreateAlbumDialogState extends State { ); } + Widget _buildVolumeTile(StorageVolume volume) => RadioListTile( + 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