fix: folder import — treat picked tree URIs via fromTreeUri (was misread as file -> Invalid URI)

This commit is contained in:
dev 2026-09-07 11:21:27 -05:00
commit c0aea3eabc
245 changed files with 155 additions and 58 deletions

View file

@ -5,6 +5,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.rada.data.local.entity.SavedSongEntity
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
@ -18,8 +19,9 @@ data class ImportResult(
)
/**
* Manual song import: copies user-picked files (via SAF) into the library
* directory and registers them in Room. Nothing is scanned automatically.
* Manual song import: copies user-picked files or whole folders (via SAF)
* into the library directory and registers them in Room.
* Nothing is scanned automatically.
*/
@Singleton
class SongImporter @Inject constructor(
@ -27,7 +29,60 @@ class SongImporter @Inject constructor(
private val libraryRepository: LibraryRepository,
) {
/**
* Entry point. Each uri may be a single document or a picked tree/folder;
* trees are walked recursively for audio files.
*/
suspend fun import(uris: List<Uri>): ImportResult {
val filesToImport = mutableListOf<Uri>()
for (uri in uris) {
// Folder picks arrive as tree URIs (…/tree/primary:…); file picks
// arrive as document URIs. fromSingleUri() misreads tree URIs as
// files, so detect trees by their path first.
val isTreeUri = uri.path?.startsWith("/tree/") == true
val doc = try {
if (isTreeUri) DocumentFile.fromTreeUri(context, uri)
else DocumentFile.fromSingleUri(context, uri)
} catch (_: Exception) {
null
}
when {
doc == null && !isTreeUri -> filesToImport += uri
doc == null -> Log.w(TAG, "Cannot open picked tree: $uri")
doc.isDirectory -> collectAudioFromTree(doc, filesToImport)
else -> filesToImport += uri
}
}
if (filesToImport.isEmpty()) {
return ImportResult(0, 0, listOf("No audio files found in the picked selection."))
}
return importFiles(filesToImport)
}
private fun collectAudioFromTree(dir: DocumentFile, out: MutableList<Uri>) {
try {
for (child in dir.listFiles()) {
if (child.isDirectory) {
collectAudioFromTree(child, out)
} else if (isAudio(child)) {
out += child.uri
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to list folder ${dir.name}: ${e.message}")
}
}
private fun isAudio(doc: DocumentFile): Boolean {
val mime = doc.type
if (mime != null && mime.startsWith("audio/")) return true
val name = doc.name?.lowercase().orEmpty()
return name.substringAfterLast('.', "").let { ext ->
ext in setOf("mp3", "flac", "ogg", "oga", "opus", "m4a", "aac", "wav")
}
}
private suspend fun importFiles(uris: List<Uri>): ImportResult {
var imported = 0
var skipped = 0
val errors = mutableListOf<String>()

View file

@ -29,6 +29,8 @@ import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
@ -86,6 +88,7 @@ fun LibraryScreen(
var showSearch by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showSortMenu by remember { mutableStateOf(false) }
var showImportMenu by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
@ -96,6 +99,13 @@ fun LibraryScreen(
if (uris.isNotEmpty()) viewModel.importSongs(uris)
}
// Folder import: pick a folder; all audio files inside are copied in.
val folderPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { treeUri ->
if (treeUri != null) viewModel.importSongs(listOf(treeUri))
}
LaunchedEffect(importMessage) {
importMessage?.let {
snackbarHostState.showSnackbar(it)
@ -130,12 +140,33 @@ fun LibraryScreen(
}
},
actions = {
IconButton(onClick = { importPicker.launch(arrayOf("audio/*")) }, enabled = !uiState.isImporting) {
Icon(
Icons.Default.PlaylistAdd,
contentDescription = "Import songs",
tint = if (uiState.isImporting) PremiumTextSecondary.copy(alpha = 0.4f) else PremiumPurple,
)
Box {
IconButton(onClick = { showImportMenu = true }, enabled = !uiState.isImporting) {
Icon(
Icons.Default.PlaylistAdd,
contentDescription = "Import songs",
tint = if (uiState.isImporting) PremiumTextSecondary.copy(alpha = 0.4f) else PremiumPurple,
)
}
DropdownMenu(
expanded = showImportMenu,
onDismissRequest = { showImportMenu = false },
) {
DropdownMenuItem(
text = { Text("Import songs…") },
onClick = {
showImportMenu = false
importPicker.launch(arrayOf("audio/*"))
},
)
DropdownMenuItem(
text = { Text("Import folder…") },
onClick = {
showImportMenu = false
folderPicker.launch(null)
},
)
}
}
if (uiState.selectedPlaylistId != null && uiState.songs.isNotEmpty()) {
IconButton(onClick = { viewModel.shufflePlay() }) {