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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user