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.
## [Unreleased]
### Added
- support Android Marshmallow (API 23)
## [v1.3.4] - 2021-02-10
### Added

View file

@ -21,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
- favorites
- statistics
- support Android API 24 ~ 30 (Nougat ~ R)
- support Android API 23 ~ 30 (Marshmallow ~ R)
- Android integration (app shortcuts, handle view/pick intents)
## Known Issues

View file

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

View file

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

View file

@ -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 }
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,8 +679,20 @@ 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()) {
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)) {
@ -694,9 +708,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
cursor.close()
result.success(value?.toString())
} else {
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {

View file

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

View file

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

View file

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

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),
// 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

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

View file

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

View file

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

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
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,27 +80,17 @@ 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 {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {

View file

@ -177,30 +177,47 @@ 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 (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"
}
val uuid = volume.uuid
if (uuid != null) {
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) {
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))
@ -212,6 +229,16 @@ object StorageUtils {
}
}
}
}
// 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
}

View file

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

View file

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

View file

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

View file

@ -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'] ?? '',

View file

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

View file

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

View file

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