feat(auto): browse and play local music folders in Android Auto [size:exception]

Phase 1: pick a device folder via SAF (persisted grant, no new
permission), browse its nested subfolders/tracks as a 5th Android
Auto root folder (hidden until configured), and play tracks through
the existing pipeline (EQ, art rotation, cold-start-safe source).
No metadata/sort/filter/shuffle yet -- filename is the title, generic
rotating art is the placeholder; deferred to a follow-up phase.

Adds a new pluriwave/file_actions native method (listAudioChildren)
and an onActivityResult override in MainActivity for the SAF folder
picker -- both static-review-only, no Android build available here.
This commit is contained in:
2026-07-19 20:30:50 +02:00
parent 99897ec848
commit 6ae7e378c4
28 changed files with 2425 additions and 40 deletions
@@ -38,6 +38,7 @@ class MainActivity : AudioServiceActivity() {
private val visualizerPermissionRequestCode = 4821
private val notificationPermissionRequestCode = 4822
private val bluetoothConnectPermissionRequestCode = 4823
private val pickMusicFolderRequestCode = 4824
private val bluetoothMacPlaceholder = "02:00:00:00:00:00"
private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null
@@ -45,6 +46,13 @@ class MainActivity : AudioServiceActivity() {
private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper())
// Local-music SAF folder picker (android-auto-local-music, static
// review only — see file_actions.pickMusicFolder / onActivityResult):
// the MethodChannel.Result held across the startActivityForResult round
// trip, so the eventual onActivityResult callback can respond to the
// SAME pending Dart call instead of a stale one.
private var pendingMusicFolderResult: MethodChannel.Result? = null
// Audio devices channel state
private var audioDevicesSink: EventChannel.EventSink? = null
private var audioDeviceCallback: AudioDeviceCallback? = null
@@ -245,11 +253,196 @@ class MainActivity : AudioServiceActivity() {
result.success(openFile(path, mimeType))
}
}
// ---- android-auto-local-music (static review only) ----
"pickMusicFolder" -> {
Log.d(tag, "file_actions.pickMusicFolder launching picker")
// A stale pending call (e.g. the user backgrounded the
// app mid-picker and re-triggered it) resolves as
// cancelled first, so no Dart-side `Future` is left
// dangling and `pendingMusicFolderResult` always points
// at the LATEST call by the time onActivityResult fires.
pendingMusicFolderResult?.success(null)
pendingMusicFolderResult = result
try {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
@Suppress("DEPRECATION")
startActivityForResult(intent, pickMusicFolderRequestCode)
} catch (error: Throwable) {
Log.e(tag, "file_actions.pickMusicFolder launch failed", error)
pendingMusicFolderResult?.success(null)
pendingMusicFolderResult = null
}
}
"listAudioChildren" -> {
val treeUri = call.argument<String>("treeUri")
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
Log.d(
tag,
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any>>())
} else {
result.success(listAudioChildren(treeUri, parentDocumentId))
}
}
"resolvePlayableUri" -> {
val treeUri = call.argument<String>("treeUri")
val documentId = call.argument<String>("documentId")
Log.d(
tag,
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
)
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
result.success(null)
} else {
result.success(resolvePlayableUri(treeUri, documentId))
}
}
"hasPersistedPermission" -> {
val treeUri = call.argument<String>("treeUri")
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
result.success(
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
)
}
else -> result.notImplemented()
}
}
}
/**
* Handles the [pickMusicFolderRequestCode] round trip from
* `file_actions.pickMusicFolder` (android-auto-local-music, static
* review only — no existing `onActivityResult` override existed on this
* Activity before this change). On a successful pick, persists the
* granted read permission via [android.content.ContentResolver.takePersistableUriPermission]
* and resolves the pending [MethodChannel.Result] with the tree URI
* string; on cancel, missing data, or a persistence failure, resolves
* with `null` instead of throwing. Any other request code is delegated
* to `super` untouched.
*/
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == pickMusicFolderRequestCode) {
val pending = pendingMusicFolderResult
pendingMusicFolderResult = null
val treeUri = data?.data
if (resultCode == RESULT_OK && treeUri != null) {
try {
contentResolver.takePersistableUriPermission(
treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
Log.d(tag, "file_actions.pickMusicFolder picked uri=$treeUri")
pending?.success(treeUri.toString())
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.pickMusicFolder takePersistableUriPermission failed",
error
)
pending?.success(null)
}
} else {
Log.d(
tag,
"file_actions.pickMusicFolder cancelled or no data resultCode=$resultCode"
)
pending?.success(null)
}
return
}
super.onActivityResult(requestCode, resultCode, data)
}
/**
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
* static review only — Design "Lazy per-folder enumeration, never an
* eager tree dump"): [parentDocumentId] blank means the tree root
* itself, otherwise the given subfolder's documentId. Filters files to
* `audio/*` MIME at the native layer (lean payload); each returned row
* also carries `mime` so the Dart side can re-validate via
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
* empty list rather than throwing.
*/
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
return try {
val parsedTree = Uri.parse(treeUri)
val parentId = parentDocumentId.ifBlank {
DocumentsContract.getTreeDocumentId(parsedTree)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val resultado = mutableListOf<Map<String, Any>>()
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idxDocId) ?: continue
val nombre = cursor.getString(idxNombre) ?: continue
val mime = cursor.getString(idxMime) ?: ""
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
if (!esDirectorio && !mime.startsWith("audio/")) continue
resultado.add(
mapOf(
"documentId" to documentId,
"nombre" to nombre,
"esDirectorio" to esDirectorio,
"mime" to mime
)
)
}
}
resultado
} catch (error: Throwable) {
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
emptyList()
}
}
/**
* Resolves a leaf [documentId] within [treeUri] to its playable
* `content://` URI (android-auto-local-music, static review only).
* Returns `null` on any failure instead of throwing.
*/
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
return try {
val parsedTree = Uri.parse(treeUri)
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
null
}
}
/**
* Checks whether [treeUri]'s read permission is still among
* [android.content.ContentResolver.getPersistedUriPermissions]
* (android-auto-local-music, static review only) — used for cold-start
* / revoked-permission detection (Spec "Permission revoked or never
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
* any other failure.
*/
private fun hasPersistedPermission(treeUri: String): Boolean {
return try {
val parsed = Uri.parse(treeUri)
contentResolver.persistedUriPermissions.any { it.uri == parsed && it.isReadPermission }
} catch (error: Throwable) {
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
false
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)