@@ -39,6 +39,7 @@ import androidx.compose.runtime.setValue
3939import androidx.compose.ui.Alignment
4040import androidx.compose.ui.Modifier
4141import androidx.compose.ui.graphics.Color
42+ import androidx.compose.ui.platform.LocalContext
4243import androidx.compose.ui.platform.LocalDensity
4344import androidx.compose.ui.res.stringResource
4445import androidx.compose.ui.text.style.TextOverflow
@@ -76,6 +77,12 @@ private data class FolderPickerDirectoryEntry(
7677 val listKey : String ,
7778)
7879
80+ private data class StorageVolumeRoot (
81+ val label : String ,
82+ val path : String ,
83+ val isPrimary : Boolean ,
84+ )
85+
7986private fun folderPickerDirectoryEntry (file : File ): FolderPickerDirectoryEntry ? {
8087 if (! file.isDirectory || ! file.canRead() || file.name.startsWith(" ." )) return null
8188 val resolvedPath =
@@ -95,14 +102,19 @@ private fun buildBreadcrumbSegments(
95102 primaryRoot : String ,
96103 internalStorageLabel : String ,
97104 sdCardLabel : String ,
105+ devicesLabel : String ,
98106): List <BreadcrumbSegment > {
99107 val normalized = normalizeFilesystemFolderPath(canonicalPath) ? : return emptyList()
108+ if (normalized == " /storage" ) {
109+ return listOf (BreadcrumbSegment (devicesLabel, " /storage" ))
110+ }
100111 val primary = primaryRoot.trimEnd(' /' )
101112
102113 if (normalized == primary || normalized.startsWith(primary + File .separator)) {
103114 val tail = if (normalized == primary) " " else normalized.removePrefix(primary + File .separator)
104115 val parts = if (tail.isEmpty()) emptyList() else tail.split(' /' ).filter { it.isNotEmpty() }
105116 val out = ArrayList <BreadcrumbSegment >()
117+ out .add(BreadcrumbSegment (devicesLabel, " /storage" ))
106118 out .add(BreadcrumbSegment (internalStorageLabel, primary))
107119 var accumulated = primary
108120 for (part in parts) {
@@ -119,6 +131,7 @@ private fun buildBreadcrumbSegments(
119131 val rest = sdMatch.groupValues[2 ].orEmpty().trim(' /' )
120132 val parts = if (rest.isEmpty()) emptyList() else rest.split(' /' ).filter { it.isNotEmpty() }
121133 val out = ArrayList <BreadcrumbSegment >()
134+ out .add(BreadcrumbSegment (devicesLabel, " /storage" ))
122135 out .add(BreadcrumbSegment (sdCardLabel, sdRoot))
123136 var accumulated = sdRoot
124137 for (part in parts) {
@@ -129,7 +142,10 @@ private fun buildBreadcrumbSegments(
129142 }
130143
131144 val fallbackLabel = normalized.substringAfterLast(File .separator).ifEmpty { normalized }
132- return listOf (BreadcrumbSegment (fallbackLabel, normalized))
145+ return listOf (
146+ BreadcrumbSegment (devicesLabel, " /storage" ),
147+ BreadcrumbSegment (fallbackLabel, normalized),
148+ )
133149}
134150
135151/* *
@@ -143,14 +159,58 @@ fun FilesystemFolderPickerSheetContent(
143159 onDismiss : () -> Unit ,
144160 onFolderChosen : (normalizedAbsolutePath: String ) -> Unit ,
145161) {
162+ // Keep these literals so that the font subset harvester detects them:
163+ @Suppress(" UNUSED_EXPRESSION" )
164+ if (false ) {
165+ FilePipeMaterialRoundedSymbol (name = " folder" )
166+ FilePipeMaterialRoundedSymbol (name = " mobile" )
167+ FilePipeMaterialRoundedSymbol (name = " sd_card" )
168+ }
169+
146170 val internalStorageLabel = stringResource(R .string.filesystem_folder_picker_internal_storage)
147171 val sdCardLabel = stringResource(R .string.filesystem_folder_picker_sd_card)
172+ val devicesLabel = stringResource(R .string.filesystem_folder_picker_devices)
148173 val primaryRoot =
149174 remember {
150175 runCatching { Environment .getExternalStorageDirectory().canonicalPath }.getOrNull()
151176 ? : " /storage/emulated/0"
152177 }
153178
179+ val context = LocalContext .current
180+ val storageVolumes =
181+ remember(context, internalStorageLabel, sdCardLabel) {
182+ val list = mutableListOf<StorageVolumeRoot >()
183+ val primaryPath =
184+ runCatching { Environment .getExternalStorageDirectory().canonicalPath }.getOrNull()
185+ ? : " /storage/emulated/0"
186+ list.add(StorageVolumeRoot (internalStorageLabel, primaryPath, isPrimary = true ))
187+
188+ val externalDirs = context.getExternalFilesDirs(null )
189+ for (dir in externalDirs) {
190+ if (dir == null ) continue
191+ val path = dir.absolutePath
192+ if (path.contains(" /Android/data/" )) {
193+ val rootPath = path.substringBefore(" /Android/data/" )
194+ if (rootPath != primaryPath && File (rootPath).exists()) {
195+ val sdName = rootPath.substringAfterLast(' /' )
196+ list.add(
197+ StorageVolumeRoot (
198+ label =
199+ if (sdName.matches(Regex (" ^[0-9A-F]{4}-[0-9A-F]{4}$" , RegexOption .IGNORE_CASE ))) {
200+ " $sdCardLabel ($sdName )"
201+ } else {
202+ sdCardLabel
203+ },
204+ path = rootPath,
205+ isPrimary = false ,
206+ ),
207+ )
208+ }
209+ }
210+ }
211+ list.distinctBy { it.path }
212+ }
213+
154214 val startPath =
155215 remember(initialDirectory) {
156216 normalizeFilesystemFolderPath(initialDirectory)
@@ -168,8 +228,8 @@ fun FilesystemFolderPickerSheetContent(
168228 var newFolderDialogErrorResId by remember { mutableStateOf<Int ?>(null ) }
169229
170230 val breadcrumbSegments =
171- remember(currentPath, primaryRoot, internalStorageLabel, sdCardLabel) {
172- buildBreadcrumbSegments(currentPath, primaryRoot, internalStorageLabel, sdCardLabel)
231+ remember(currentPath, primaryRoot, internalStorageLabel, sdCardLabel, devicesLabel ) {
232+ buildBreadcrumbSegments(currentPath, primaryRoot, internalStorageLabel, sdCardLabel, devicesLabel )
173233 }
174234
175235 var childDirectories by remember(currentPath, childListRefreshKey) {
@@ -178,12 +238,22 @@ fun FilesystemFolderPickerSheetContent(
178238 LaunchedEffect (currentPath, childListRefreshKey) {
179239 childDirectories =
180240 withContext(Dispatchers .IO ) {
181- File (currentPath)
182- .listFiles()
183- ?.mapNotNull(::folderPickerDirectoryEntry)
184- ?.distinctBy { entry -> entry.listKey }
185- ?.sortedBy { entry -> entry.name.lowercase() }
186- ? : emptyList()
241+ if (currentPath == " /storage" ) {
242+ storageVolumes.map { volume ->
243+ FolderPickerDirectoryEntry (
244+ name = volume.label,
245+ path = volume.path,
246+ listKey = volume.path,
247+ )
248+ }
249+ } else {
250+ File (currentPath)
251+ .listFiles()
252+ ?.mapNotNull(::folderPickerDirectoryEntry)
253+ ?.distinctBy { entry -> entry.listKey }
254+ ?.sortedBy { entry -> entry.name.lowercase() }
255+ ? : emptyList()
256+ }
187257 }
188258 }
189259
@@ -332,9 +402,11 @@ fun FilesystemFolderPickerSheetContent(
332402 modifier =
333403 Modifier .tapSoundClickable {
334404 val target = normalizeFilesystemFolderPath(segment.path) ? : return @tapSoundClickable
335- if (File (target).isDirectory &&
336- File (target).canRead() &&
337- isFilesystemFolderPathAllowedForRules(target)
405+ if (target == " /storage" || (
406+ File (target).isDirectory &&
407+ File (target).canRead() &&
408+ isFilesystemFolderPathAllowedForRules(target)
409+ )
338410 ) {
339411 currentPath = target
340412 }
@@ -366,8 +438,17 @@ fun FilesystemFolderPickerSheetContent(
366438 ListItem (
367439 colors = ListItemDefaults .colors(containerColor = Color .Transparent ),
368440 leadingContent = {
441+ val isDrive =
442+ entry.path == " /storage/emulated/0" ||
443+ Regex (" ^/storage/[0-9A-F]{4}-[0-9A-F]{4}$" , RegexOption .IGNORE_CASE ).matches(entry.path)
444+ val iconName =
445+ if (isDrive) {
446+ if (entry.path == " /storage/emulated/0" ) " mobile" else " sd_card"
447+ } else {
448+ " folder"
449+ }
369450 FilePipeMaterialRoundedSymbol (
370- name = " folder " ,
451+ name = iconName ,
371452 contentDescription = null ,
372453 size = 24 .dp,
373454 modifier = Modifier .size(24 .dp),
0 commit comments