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 visualizerPermissionRequestCode = 4821
|
||||||
private val notificationPermissionRequestCode = 4822
|
private val notificationPermissionRequestCode = 4822
|
||||||
private val bluetoothConnectPermissionRequestCode = 4823
|
private val bluetoothConnectPermissionRequestCode = 4823
|
||||||
|
private val pickMusicFolderRequestCode = 4824
|
||||||
private val bluetoothMacPlaceholder = "02:00:00:00:00:00"
|
private val bluetoothMacPlaceholder = "02:00:00:00:00:00"
|
||||||
private var visualizer: Visualizer? = null
|
private var visualizer: Visualizer? = null
|
||||||
private var pendingSink: EventChannel.EventSink? = null
|
private var pendingSink: EventChannel.EventSink? = null
|
||||||
@@ -45,6 +46,13 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
private var alarmMethodChannel: MethodChannel? = null
|
private var alarmMethodChannel: MethodChannel? = null
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
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
|
// Audio devices channel state
|
||||||
private var audioDevicesSink: EventChannel.EventSink? = null
|
private var audioDevicesSink: EventChannel.EventSink? = null
|
||||||
private var audioDeviceCallback: AudioDeviceCallback? = null
|
private var audioDeviceCallback: AudioDeviceCallback? = null
|
||||||
@@ -245,11 +253,196 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
result.success(openFile(path, mimeType))
|
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()
|
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) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "اسم الجهاز",
|
"eqDeviceNameLabel": "اسم الجهاز",
|
||||||
"eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة",
|
"eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة",
|
||||||
"eqDeviceNameConfirm": "حفظ",
|
"eqDeviceNameConfirm": "حفظ",
|
||||||
"eqDeviceConnected": "متصل"
|
"eqDeviceConnected": "متصل",
|
||||||
|
"localMusicSectionTitle": "الموسيقى المحلية (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.",
|
||||||
|
"localMusicFolderNotConfigured": "لم يتم تحديد مجلد",
|
||||||
|
"localMusicFolderTitle": "مجلد الموسيقى المحلية",
|
||||||
|
"localMusicChoosePath": "اختيار مجلد",
|
||||||
|
"localMusicChangePath": "تغيير المجلد",
|
||||||
|
"localMusicFolderUpdated": "تم تحديث مجلد الموسيقى المحلية",
|
||||||
|
"localMusicFolderSaveError": "تعذّر حفظ المجلد: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "ডিভাইসের নাম",
|
"eqDeviceNameLabel": "ডিভাইসের নাম",
|
||||||
"eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার",
|
"eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার",
|
||||||
"eqDeviceNameConfirm": "সংরক্ষণ করুন",
|
"eqDeviceNameConfirm": "সংরক্ষণ করুন",
|
||||||
"eqDeviceConnected": "সংযুক্ত"
|
"eqDeviceConnected": "সংযুক্ত",
|
||||||
|
"localMusicSectionTitle": "স্থানীয় সঙ্গীত (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।",
|
||||||
|
"localMusicFolderNotConfigured": "কোনো ফোল্ডার নির্বাচিত হয়নি",
|
||||||
|
"localMusicFolderTitle": "স্থানীয় সঙ্গীত ফোল্ডার",
|
||||||
|
"localMusicChoosePath": "ফোল্ডার বেছে নিন",
|
||||||
|
"localMusicChangePath": "ফোল্ডার পরিবর্তন করুন",
|
||||||
|
"localMusicFolderUpdated": "স্থানীয় সঙ্গীত ফোল্ডার আপডেট হয়েছে",
|
||||||
|
"localMusicFolderSaveError": "ফোল্ডার সংরক্ষণ করা যায়নি: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "Gerätename",
|
"eqDeviceNameLabel": "Gerätename",
|
||||||
"eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher",
|
"eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher",
|
||||||
"eqDeviceNameConfirm": "Speichern",
|
"eqDeviceNameConfirm": "Speichern",
|
||||||
"eqDeviceConnected": "Verbunden"
|
"eqDeviceConnected": "Verbunden",
|
||||||
|
"localMusicSectionTitle": "Lokale Musik (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "Wähle einen Ordner auf diesem Gerät aus, um dessen Audiodateien im Auto zu durchsuchen und abzuspielen.",
|
||||||
|
"localMusicFolderNotConfigured": "Kein Ordner ausgewählt",
|
||||||
|
"localMusicFolderTitle": "Ordner für lokale Musik",
|
||||||
|
"localMusicChoosePath": "Ordner auswählen",
|
||||||
|
"localMusicChangePath": "Ordner ändern",
|
||||||
|
"localMusicFolderUpdated": "Ordner für lokale Musik aktualisiert",
|
||||||
|
"localMusicFolderSaveError": "Ordner konnte nicht gespeichert werden: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "Device name",
|
"eqDeviceNameLabel": "Device name",
|
||||||
"eqDeviceNameHint": "e.g. Living Room Speaker",
|
"eqDeviceNameHint": "e.g. Living Room Speaker",
|
||||||
"eqDeviceNameConfirm": "Save",
|
"eqDeviceNameConfirm": "Save",
|
||||||
"eqDeviceConnected": "Connected"
|
"eqDeviceConnected": "Connected",
|
||||||
|
"localMusicSectionTitle": "Local music (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "Pick a folder on this device to browse and play its audio files from the car.",
|
||||||
|
"localMusicFolderNotConfigured": "No folder selected",
|
||||||
|
"localMusicFolderTitle": "Local music folder",
|
||||||
|
"localMusicChoosePath": "Choose folder",
|
||||||
|
"localMusicChangePath": "Change folder",
|
||||||
|
"localMusicFolderUpdated": "Local music folder updated",
|
||||||
|
"localMusicFolderSaveError": "Could not save the folder: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -619,5 +619,18 @@
|
|||||||
"eqDeviceNameLabel": "Nombre del dispositivo",
|
"eqDeviceNameLabel": "Nombre del dispositivo",
|
||||||
"eqDeviceNameHint": "Ej: Altavoz del living",
|
"eqDeviceNameHint": "Ej: Altavoz del living",
|
||||||
"eqDeviceNameConfirm": "Guardar",
|
"eqDeviceNameConfirm": "Guardar",
|
||||||
"eqDeviceConnected": "Conectado"
|
"eqDeviceConnected": "Conectado",
|
||||||
|
"localMusicSectionTitle": "Música local (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "Elegí una carpeta de este dispositivo para explorar y reproducir sus archivos de audio desde el auto.",
|
||||||
|
"localMusicFolderNotConfigured": "No hay carpeta seleccionada",
|
||||||
|
"localMusicFolderTitle": "Carpeta de música local",
|
||||||
|
"localMusicChoosePath": "Elegir carpeta",
|
||||||
|
"localMusicChangePath": "Cambiar carpeta",
|
||||||
|
"localMusicFolderUpdated": "Carpeta de música local actualizada",
|
||||||
|
"localMusicFolderSaveError": "No se pudo guardar la carpeta: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "Nom de l'appareil",
|
"eqDeviceNameLabel": "Nom de l'appareil",
|
||||||
"eqDeviceNameHint": "ex. : Enceinte salon",
|
"eqDeviceNameHint": "ex. : Enceinte salon",
|
||||||
"eqDeviceNameConfirm": "Enregistrer",
|
"eqDeviceNameConfirm": "Enregistrer",
|
||||||
"eqDeviceConnected": "Connecté"
|
"eqDeviceConnected": "Connecté",
|
||||||
|
"localMusicSectionTitle": "Musique locale (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "Choisissez un dossier sur cet appareil pour parcourir et lire ses fichiers audio depuis la voiture.",
|
||||||
|
"localMusicFolderNotConfigured": "Aucun dossier sélectionné",
|
||||||
|
"localMusicFolderTitle": "Dossier de musique locale",
|
||||||
|
"localMusicChoosePath": "Choisir un dossier",
|
||||||
|
"localMusicChangePath": "Changer de dossier",
|
||||||
|
"localMusicFolderUpdated": "Dossier de musique locale mis à jour",
|
||||||
|
"localMusicFolderSaveError": "Impossible d’enregistrer le dossier : {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "डिवाइस का नाम",
|
"eqDeviceNameLabel": "डिवाइस का नाम",
|
||||||
"eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर",
|
"eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर",
|
||||||
"eqDeviceNameConfirm": "सहेजें",
|
"eqDeviceNameConfirm": "सहेजें",
|
||||||
"eqDeviceConnected": "कनेक्टेड"
|
"eqDeviceConnected": "कनेक्टेड",
|
||||||
|
"localMusicSectionTitle": "स्थानीय संगीत (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।",
|
||||||
|
"localMusicFolderNotConfigured": "कोई फ़ोल्डर चुना नहीं गया",
|
||||||
|
"localMusicFolderTitle": "स्थानीय संगीत फ़ोल्डर",
|
||||||
|
"localMusicChoosePath": "फ़ोल्डर चुनें",
|
||||||
|
"localMusicChangePath": "फ़ोल्डर बदलें",
|
||||||
|
"localMusicFolderUpdated": "स्थानीय संगीत फ़ोल्डर अपडेट हुआ",
|
||||||
|
"localMusicFolderSaveError": "फ़ोल्डर सहेजा नहीं जा सका: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "Nama perangkat",
|
"eqDeviceNameLabel": "Nama perangkat",
|
||||||
"eqDeviceNameHint": "Mis: Speaker ruang tamu",
|
"eqDeviceNameHint": "Mis: Speaker ruang tamu",
|
||||||
"eqDeviceNameConfirm": "Simpan",
|
"eqDeviceNameConfirm": "Simpan",
|
||||||
"eqDeviceConnected": "Terhubung"
|
"eqDeviceConnected": "Terhubung",
|
||||||
|
"localMusicSectionTitle": "Musik lokal (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "Pilih folder di perangkat ini untuk menjelajahi dan memutar file audio di dalamnya dari mobil.",
|
||||||
|
"localMusicFolderNotConfigured": "Belum ada folder dipilih",
|
||||||
|
"localMusicFolderTitle": "Folder musik lokal",
|
||||||
|
"localMusicChoosePath": "Pilih folder",
|
||||||
|
"localMusicChangePath": "Ubah folder",
|
||||||
|
"localMusicFolderUpdated": "Folder musik lokal diperbarui",
|
||||||
|
"localMusicFolderSaveError": "Tidak dapat menyimpan folder: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "Nome del dispositivo",
|
"eqDeviceNameLabel": "Nome del dispositivo",
|
||||||
"eqDeviceNameHint": "Es: Cassa del salotto",
|
"eqDeviceNameHint": "Es: Cassa del salotto",
|
||||||
"eqDeviceNameConfirm": "Salva",
|
"eqDeviceNameConfirm": "Salva",
|
||||||
"eqDeviceConnected": "Connesso"
|
"eqDeviceConnected": "Connesso",
|
||||||
|
"localMusicSectionTitle": "Musica locale (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "Scegli una cartella su questo dispositivo per sfogliare e riprodurre i suoi file audio dall'auto.",
|
||||||
|
"localMusicFolderNotConfigured": "Nessuna cartella selezionata",
|
||||||
|
"localMusicFolderTitle": "Cartella musica locale",
|
||||||
|
"localMusicChoosePath": "Scegli cartella",
|
||||||
|
"localMusicChangePath": "Cambia cartella",
|
||||||
|
"localMusicFolderUpdated": "Cartella musica locale aggiornata",
|
||||||
|
"localMusicFolderSaveError": "Impossibile salvare la cartella: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "デバイス名",
|
"eqDeviceNameLabel": "デバイス名",
|
||||||
"eqDeviceNameHint": "例:リビングのスピーカー",
|
"eqDeviceNameHint": "例:リビングのスピーカー",
|
||||||
"eqDeviceNameConfirm": "保存",
|
"eqDeviceNameConfirm": "保存",
|
||||||
"eqDeviceConnected": "接続中"
|
"eqDeviceConnected": "接続中",
|
||||||
|
"localMusicSectionTitle": "ローカル音楽(Android Auto)",
|
||||||
|
"localMusicSectionDescription": "この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。",
|
||||||
|
"localMusicFolderNotConfigured": "フォルダーが選択されていません",
|
||||||
|
"localMusicFolderTitle": "ローカル音楽フォルダー",
|
||||||
|
"localMusicChoosePath": "フォルダーを選択",
|
||||||
|
"localMusicChangePath": "フォルダーを変更",
|
||||||
|
"localMusicFolderUpdated": "ローカル音楽フォルダーを更新しました",
|
||||||
|
"localMusicFolderSaveError": "フォルダーを保存できませんでした: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "Nome do dispositivo",
|
"eqDeviceNameLabel": "Nome do dispositivo",
|
||||||
"eqDeviceNameHint": "Ex: Caixa da sala",
|
"eqDeviceNameHint": "Ex: Caixa da sala",
|
||||||
"eqDeviceNameConfirm": "Salvar",
|
"eqDeviceNameConfirm": "Salvar",
|
||||||
"eqDeviceConnected": "Conectado"
|
"eqDeviceConnected": "Conectado",
|
||||||
|
"localMusicSectionTitle": "Música local (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "Escolha uma pasta neste dispositivo para navegar e reproduzir os arquivos de áudio dela no carro.",
|
||||||
|
"localMusicFolderNotConfigured": "Nenhuma pasta selecionada",
|
||||||
|
"localMusicFolderTitle": "Pasta de música local",
|
||||||
|
"localMusicChoosePath": "Escolher pasta",
|
||||||
|
"localMusicChangePath": "Alterar pasta",
|
||||||
|
"localMusicFolderUpdated": "Pasta de música local atualizada",
|
||||||
|
"localMusicFolderSaveError": "Não foi possível salvar a pasta: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "Название устройства",
|
"eqDeviceNameLabel": "Название устройства",
|
||||||
"eqDeviceNameHint": "Напр.: Колонка в гостиной",
|
"eqDeviceNameHint": "Напр.: Колонка в гостиной",
|
||||||
"eqDeviceNameConfirm": "Сохранить",
|
"eqDeviceNameConfirm": "Сохранить",
|
||||||
"eqDeviceConnected": "Подключено"
|
"eqDeviceConnected": "Подключено",
|
||||||
|
"localMusicSectionTitle": "Локальная музыка (Android Auto)",
|
||||||
|
"localMusicSectionDescription": "Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.",
|
||||||
|
"localMusicFolderNotConfigured": "Папка не выбрана",
|
||||||
|
"localMusicFolderTitle": "Папка локальной музыки",
|
||||||
|
"localMusicChoosePath": "Выбрать папку",
|
||||||
|
"localMusicChangePath": "Изменить папку",
|
||||||
|
"localMusicFolderUpdated": "Папка локальной музыки обновлена",
|
||||||
|
"localMusicFolderSaveError": "Не удалось сохранить папку: {error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -656,5 +656,18 @@
|
|||||||
"eqDeviceNameLabel": "设备名称",
|
"eqDeviceNameLabel": "设备名称",
|
||||||
"eqDeviceNameHint": "例:客厅音箱",
|
"eqDeviceNameHint": "例:客厅音箱",
|
||||||
"eqDeviceNameConfirm": "保存",
|
"eqDeviceNameConfirm": "保存",
|
||||||
"eqDeviceConnected": "已连接"
|
"eqDeviceConnected": "已连接",
|
||||||
|
"localMusicSectionTitle": "本地音乐(Android Auto)",
|
||||||
|
"localMusicSectionDescription": "选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。",
|
||||||
|
"localMusicFolderNotConfigured": "未选择文件夹",
|
||||||
|
"localMusicFolderTitle": "本地音乐文件夹",
|
||||||
|
"localMusicChoosePath": "选择文件夹",
|
||||||
|
"localMusicChangePath": "更改文件夹",
|
||||||
|
"localMusicFolderUpdated": "本地音乐文件夹已更新",
|
||||||
|
"localMusicFolderSaveError": "无法保存文件夹:{error}",
|
||||||
|
"@localMusicFolderSaveError": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'servicios/musica_local_auto.dart';
|
||||||
import 'servicios/navegacion_auto.dart';
|
import 'servicios/navegacion_auto.dart';
|
||||||
import 'servicios/servicio_audio.dart';
|
import 'servicios/servicio_audio.dart';
|
||||||
import 'servicios/servicio_audio_session.dart';
|
import 'servicios/servicio_audio_session.dart';
|
||||||
@@ -49,6 +50,13 @@ Future<void> main() async {
|
|||||||
final fuenteAuto = FuenteEmisorasAutoLocal();
|
final fuenteAuto = FuenteEmisorasAutoLocal();
|
||||||
registrarFuenteNavegacion(fuenteAuto);
|
registrarFuenteNavegacion(fuenteAuto);
|
||||||
|
|
||||||
|
// Local-music browse source (Design "getChildren data source
|
||||||
|
// registration"), same injectable-prefs DI convention as every other
|
||||||
|
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
|
||||||
|
// non-null; without this registration the local-music root would stay
|
||||||
|
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
|
||||||
|
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
|
||||||
|
|
||||||
// S3-R1: audio focus — phone calls / transient losses pause or duck the
|
// S3-R1: audio focus — phone calls / transient losses pause or duck the
|
||||||
// radio; headphones unplugged pauses it.
|
// radio; headphones unplugged pauses it.
|
||||||
final sesionAudio = ServicioAudioSession(objetivo: handler);
|
final sesionAudio = ServicioAudioSession(objetivo: handler);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/// Local file-system node returned by the native `listAudioChildren`
|
||||||
|
/// channel call (Design "Interfaces / Contracts"): either a subfolder or an
|
||||||
|
/// audio file, one SAF tree level deep. Pure DTO — no behavior, so no unit
|
||||||
|
/// tests are warranted for it on its own (exercised indirectly through its
|
||||||
|
/// consumers, e.g. `ConstructorArbolAuto.itemsLocales`).
|
||||||
|
class NodoLocal {
|
||||||
|
const NodoLocal({
|
||||||
|
required this.documentId,
|
||||||
|
required this.nombre,
|
||||||
|
required this.esDirectorio,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Opaque SAF document id, unique within the picked tree. May itself
|
||||||
|
/// contain `:`/`/` (Design "Prefix stripped by length"), so callers must
|
||||||
|
/// never split/parse it — only wrap it verbatim in a media id.
|
||||||
|
final String documentId;
|
||||||
|
|
||||||
|
/// Raw on-device filename (or folder name), NOT yet title-stripped.
|
||||||
|
final String nombre;
|
||||||
|
|
||||||
|
/// Whether this node is a browsable subfolder (`true`) or a playable
|
||||||
|
/// audio file (`false`).
|
||||||
|
final bool esDirectorio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal playable local track (Design "Phase 1 minimal shape" — no
|
||||||
|
/// artist/album/duration metadata per spec's "Not in this delta"). Pure
|
||||||
|
/// DTO — no behavior, so no unit tests are warranted for it on its own.
|
||||||
|
class PistaLocal {
|
||||||
|
const PistaLocal({
|
||||||
|
required this.documentId,
|
||||||
|
required this.titulo,
|
||||||
|
required this.contentUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Opaque SAF document id for this track.
|
||||||
|
final String documentId;
|
||||||
|
|
||||||
|
/// Display title, already computed by the caller (filename minus
|
||||||
|
/// extension, or a derived fallback — see `navegacion_auto.dart`).
|
||||||
|
final String titulo;
|
||||||
|
|
||||||
|
/// Playable `content://` URI resolved via
|
||||||
|
/// `FuenteMusicaLocalAuto.uriContenidoDePista`.
|
||||||
|
final String contentUri;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import '../l10n/gen/app_localizations.dart';
|
|||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../modelos/grupo_favoritos.dart';
|
import '../modelos/grupo_favoritos.dart';
|
||||||
import '../modelos/preset_ecualizador.dart';
|
import '../modelos/preset_ecualizador.dart';
|
||||||
|
import '../servicios/musica_local_auto.dart';
|
||||||
import '../widgets/ecualizador_widget.dart';
|
import '../widgets/ecualizador_widget.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
@@ -66,6 +67,8 @@ class _AjustesContent extends StatelessWidget {
|
|||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
_SeccionGrabaciones(),
|
_SeccionGrabaciones(),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
|
_SeccionMusicaLocal(),
|
||||||
|
SizedBox(height: 12),
|
||||||
_SeccionTimerSueno(),
|
_SeccionTimerSueno(),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
_SeccionIdioma(),
|
_SeccionIdioma(),
|
||||||
@@ -278,6 +281,118 @@ class _SeccionGrabaciones extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local-music root-folder picker (android-auto-local-music task 9),
|
||||||
|
/// mirroring [_SeccionGrabaciones]'s shape: `PluriGlassSurface` card,
|
||||||
|
/// `FutureBuilder`-driven current-folder display, a single action button and
|
||||||
|
/// snackbar feedback. Deliberately does NOT use `FilePicker.platform` (see
|
||||||
|
/// tasks.md "Grounding corrections") — [FuenteMusicaLocalAutoImpl.elegirCarpeta]
|
||||||
|
/// calls the NEW native `pickMusicFolder` channel method directly, since it
|
||||||
|
/// needs a persistable-grant SAF tree URI, not a plain filesystem path.
|
||||||
|
class _SeccionMusicaLocal extends StatefulWidget {
|
||||||
|
const _SeccionMusicaLocal();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SeccionMusicaLocal> createState() => _SeccionMusicaLocalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SeccionMusicaLocalState extends State<_SeccionMusicaLocal> {
|
||||||
|
final _fuente = FuenteMusicaLocalAutoImpl();
|
||||||
|
late Future<String?> _carpetaActual;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_carpetaActual = _fuente.carpetaActual();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _elegirCarpeta(BuildContext context) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
try {
|
||||||
|
final uri = await _fuente.elegirCarpeta();
|
||||||
|
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
|
||||||
|
// picker's own "nothing changed" affordance.
|
||||||
|
if (!context.mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_carpetaActual = Future.value(uri);
|
||||||
|
});
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.localMusicFolderUpdated)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return PluriGlassSurface(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.library_music_outlined),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
l10n.localMusicSectionTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.localMusicSectionDescription,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
FutureBuilder<String?>(
|
||||||
|
future: _carpetaActual,
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
final carpeta = snap.data;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.folder_outlined),
|
||||||
|
title: Text(l10n.localMusicFolderTitle),
|
||||||
|
subtitle: Text(
|
||||||
|
(carpeta == null || carpeta.isEmpty)
|
||||||
|
? l10n.localMusicFolderNotConfigured
|
||||||
|
: carpeta,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FutureBuilder<String?>(
|
||||||
|
future: _carpetaActual,
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
final configurada = (snap.data ?? '').isNotEmpty;
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.folder_open_rounded),
|
||||||
|
label: Text(
|
||||||
|
configurada
|
||||||
|
? l10n.localMusicChangePath
|
||||||
|
: l10n.localMusicChoosePath,
|
||||||
|
),
|
||||||
|
onPressed: () => _elegirCarpeta(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SeccionTimerSueno extends StatelessWidget {
|
class _SeccionTimerSueno extends StatelessWidget {
|
||||||
const _SeccionTimerSueno();
|
const _SeccionTimerSueno();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../modelos/pista_local.dart';
|
||||||
|
|
||||||
|
/// SharedPreferences key for the persisted local-music root tree URI
|
||||||
|
/// (Design "Data Flow"). Read/written exclusively by
|
||||||
|
/// [FuenteMusicaLocalAutoImpl].
|
||||||
|
const _keyUriCarpetaLocal = 'musica_local_uri';
|
||||||
|
|
||||||
|
/// Dart-side re-validation of a native-reported MIME type (Design
|
||||||
|
/// "Interfaces / Contracts" — native already filters to `audio/*`; this is
|
||||||
|
/// defense-in-depth, not the only gate). Requires a non-blank `audio/*`
|
||||||
|
/// [mime] AND a non-blank [nombre] — a blank filename is never a valid
|
||||||
|
/// audio entry regardless of MIME.
|
||||||
|
bool esArchivoAudio(String? mime, String? nombre) {
|
||||||
|
final mimeRecortado = mime?.trim();
|
||||||
|
final nombreRecortado = nombre?.trim();
|
||||||
|
if (mimeRecortado == null || mimeRecortado.isEmpty) return false;
|
||||||
|
if (nombreRecortado == null || nombreRecortado.isEmpty) return false;
|
||||||
|
return mimeRecortado.toLowerCase().startsWith('audio/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse-source abstraction for the local-music branch of the Android Auto
|
||||||
|
/// tree (Design "Interfaces / Contracts"), mirroring [FuenteEmisorasAuto]'s
|
||||||
|
/// (`navegacion_auto.dart`) cold-start-safe, never-throws contract. Kept as
|
||||||
|
/// a separate interface from [FuenteEmisorasAuto] — local music is its own
|
||||||
|
/// browse domain, not a station source.
|
||||||
|
abstract class FuenteMusicaLocalAuto {
|
||||||
|
/// Whether a local-music root folder is picked AND its permission is
|
||||||
|
/// still valid. Never throws — a revoked/never-granted permission
|
||||||
|
/// degrades to `false` (Spec "Permission revoked or never granted").
|
||||||
|
Future<bool> hayCarpetaConfigurada();
|
||||||
|
|
||||||
|
/// Immediate children of [documentId] (`''` = the tree root itself), one
|
||||||
|
/// SAF level deep (Design "Lazy per-folder enumeration, never an eager
|
||||||
|
/// tree dump"). Never throws — any failure degrades to `[]` (Spec
|
||||||
|
/// "Permission revoked or never granted", "Browse requested before app
|
||||||
|
/// state is loaded").
|
||||||
|
Future<List<NodoLocal>> hijos(String documentId);
|
||||||
|
|
||||||
|
/// Resolves a leaf [documentId] to its playable `content://` URI, or
|
||||||
|
/// `null` if it cannot be resolved (stale id, revoked permission). Never
|
||||||
|
/// throws.
|
||||||
|
Future<String?> uriContenidoDePista(String documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channel-backed [FuenteMusicaLocalAuto] implementation (Design "Hand-rolled
|
||||||
|
/// SAF channel, not `shared_storage`"): calls the existing
|
||||||
|
/// `pluriwave/file_actions` `MethodChannel`'s native SAF methods
|
||||||
|
/// (`MainActivity.kt`, static-review-only) and the picker/persistence side
|
||||||
|
/// used by the phone settings UI. Every channel call is wrapped in
|
||||||
|
/// try/catch so a revoked permission, a missing native method (older APK on
|
||||||
|
/// a mismatched build) or any other native-side failure degrades to an
|
||||||
|
/// empty/absent result instead of throwing — mirrors
|
||||||
|
/// `FuenteEmisorasAutoLocal`'s cold-start-safe shape
|
||||||
|
/// (`navegacion_auto.dart:421-471`).
|
||||||
|
class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
|
||||||
|
FuenteMusicaLocalAutoImpl({SharedPreferences? prefs}) : _prefs = prefs;
|
||||||
|
|
||||||
|
static const MethodChannel _canal = MethodChannel('pluriwave/file_actions');
|
||||||
|
|
||||||
|
final SharedPreferences? _prefs;
|
||||||
|
|
||||||
|
/// Injected startup instance (S3-R4 convention, mirrors
|
||||||
|
/// `ServicioEcualizador`'s DI pattern, `servicio_ecualizador.dart:37,54,57`
|
||||||
|
/// — `getInstance()` is only a fallback for call sites that don't inject
|
||||||
|
/// one, e.g. tests or a lazily-constructed settings-only instance).
|
||||||
|
Future<SharedPreferences> _resolverPrefs() async =>
|
||||||
|
_prefs ?? SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
Future<String?> _uriPersistida() async {
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
return prefs.getString(_keyUriCarpetaLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists [treeUri] as the local-music root (Design "Data Flow" —
|
||||||
|
/// settings write side). Exposed separately from [elegirCarpeta] so a
|
||||||
|
/// caller that already has a URI (e.g. a future restore/import flow)
|
||||||
|
/// doesn't need to re-invoke the native picker.
|
||||||
|
Future<void> guardarCarpeta(String treeUri) async {
|
||||||
|
final prefs = await _resolverPrefs();
|
||||||
|
await prefs.setString(_keyUriCarpetaLocal, treeUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The currently persisted root URI, or `null` if none was ever picked.
|
||||||
|
/// Used by the settings UI to render the "current folder" state.
|
||||||
|
Future<String?> carpetaActual() => _uriPersistida();
|
||||||
|
|
||||||
|
/// Launches the native SAF folder picker (`pickMusicFolder`) and persists
|
||||||
|
/// the result on success (Spec "User picks a local music root folder").
|
||||||
|
/// Returns the picked tree URI, or `null` if the user cancelled or the
|
||||||
|
/// native call failed — never throws.
|
||||||
|
Future<String?> elegirCarpeta() async {
|
||||||
|
try {
|
||||||
|
final uri = await _canal.invokeMethod<String>('pickMusicFolder');
|
||||||
|
if (uri == null || uri.isEmpty) return null;
|
||||||
|
await guardarCarpeta(uri);
|
||||||
|
return uri;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hayCarpetaConfigurada() async {
|
||||||
|
try {
|
||||||
|
final uri = await _uriPersistida();
|
||||||
|
if (uri == null || uri.isEmpty) return false;
|
||||||
|
final valido = await _canal.invokeMethod<bool>(
|
||||||
|
'hasPersistedPermission',
|
||||||
|
{'treeUri': uri},
|
||||||
|
);
|
||||||
|
return valido ?? false;
|
||||||
|
} catch (_) {
|
||||||
|
// Cold-start / revoked-permission safety (Spec "Permission revoked or
|
||||||
|
// never granted"): never throw, degrade to "not configured".
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<NodoLocal>> hijos(String documentId) async {
|
||||||
|
try {
|
||||||
|
final uri = await _uriPersistida();
|
||||||
|
if (uri == null || uri.isEmpty) return const [];
|
||||||
|
final crudos = await _canal.invokeMethod<List<Object?>>(
|
||||||
|
'listAudioChildren',
|
||||||
|
{'treeUri': uri, 'parentDocumentId': documentId},
|
||||||
|
);
|
||||||
|
if (crudos == null) return const [];
|
||||||
|
return crudos
|
||||||
|
.whereType<Map<Object?, Object?>>()
|
||||||
|
.map(_nodoDesdeMapa)
|
||||||
|
.whereType<NodoLocal>()
|
||||||
|
.toList();
|
||||||
|
} catch (_) {
|
||||||
|
// Cold-start / revoked-permission safety (Spec "Permission revoked or
|
||||||
|
// never granted", "Browse requested before app state is loaded").
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> uriContenidoDePista(String documentId) async {
|
||||||
|
try {
|
||||||
|
final uri = await _uriPersistida();
|
||||||
|
if (uri == null || uri.isEmpty) return null;
|
||||||
|
return await _canal.invokeMethod<String>('resolvePlayableUri', {
|
||||||
|
'treeUri': uri,
|
||||||
|
'documentId': documentId,
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a raw `listAudioChildren` row to a [NodoLocal], re-validating
|
||||||
|
/// audio files via [esArchivoAudio] (Design "Interfaces / Contracts" —
|
||||||
|
/// defense-in-depth on top of the native `audio/*` filter). Returns `null`
|
||||||
|
/// for a malformed row (missing id/name) or a file whose MIME fails
|
||||||
|
/// re-validation, so [hijos] can silently drop it instead of surfacing a
|
||||||
|
/// broken entry.
|
||||||
|
NodoLocal? _nodoDesdeMapa(Map<Object?, Object?> mapa) {
|
||||||
|
final documentId = mapa['documentId'] as String?;
|
||||||
|
final nombre = mapa['nombre'] as String?;
|
||||||
|
final esDirectorio = mapa['esDirectorio'] as bool? ?? false;
|
||||||
|
if (documentId == null || documentId.isEmpty) return null;
|
||||||
|
if (nombre == null) return null;
|
||||||
|
if (!esDirectorio) {
|
||||||
|
final mime = mapa['mime'] as String?;
|
||||||
|
if (!esArchivoAudio(mime, nombre)) return null;
|
||||||
|
}
|
||||||
|
return NodoLocal(
|
||||||
|
documentId: documentId,
|
||||||
|
nombre: nombre,
|
||||||
|
esDirectorio: esDirectorio,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import '../estado/orden_emisoras.dart';
|
import '../estado/orden_emisoras.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../modelos/grupo_favoritos.dart';
|
import '../modelos/grupo_favoritos.dart';
|
||||||
|
import '../modelos/pista_local.dart';
|
||||||
import '../modelos/preset_ecualizador.dart';
|
import '../modelos/preset_ecualizador.dart';
|
||||||
|
import 'musica_local_auto.dart';
|
||||||
import 'persistencia_tolerante.dart';
|
import 'persistencia_tolerante.dart';
|
||||||
import 'servicio_favoritos.dart';
|
import 'servicio_favoritos.dart';
|
||||||
|
|
||||||
@@ -23,6 +25,17 @@ const _prefijoPresetEq = 'eq_preset:';
|
|||||||
/// predicate.
|
/// predicate.
|
||||||
bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq);
|
bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq);
|
||||||
|
|
||||||
|
/// Local-track media-id prefix (Design "media-id scheme"), collision-free
|
||||||
|
/// against [_prefijoEmisora], [_prefijoPresetEq], `grupo:` and the bare
|
||||||
|
/// folder id constants. Top-level (not a [ConstructorArbolAuto] member),
|
||||||
|
/// mirroring [_prefijoPresetEq]/[esPresetMediaId]'s shape — used directly
|
||||||
|
/// from `playFromMediaId`'s dispatch in `servicio_audio.dart`.
|
||||||
|
const _prefijoPista = 'pista:';
|
||||||
|
|
||||||
|
/// Whether [id] identifies a local-track playable leaf item (Design
|
||||||
|
/// "media-id scheme").
|
||||||
|
bool esPistaMediaId(String id) => id.startsWith(_prefijoPista);
|
||||||
|
|
||||||
/// Canonical on-brand fallback-art names and rotation order, ported
|
/// Canonical on-brand fallback-art names and rotation order, ported
|
||||||
/// **verbatim** (same formula, same order) from
|
/// **verbatim** (same formula, same order) from
|
||||||
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
|
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
|
||||||
@@ -141,6 +154,13 @@ class ConstructorArbolAuto {
|
|||||||
/// generic station-list `hijos()` path.
|
/// generic station-list `hijos()` path.
|
||||||
static const idEcualizador = 'ecualizador';
|
static const idEcualizador = 'ecualizador';
|
||||||
|
|
||||||
|
/// Root folder id for the local-music browsable root (Design "media-id
|
||||||
|
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
|
||||||
|
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
|
||||||
|
/// [hijos] path. Hidden from [raiz] until a folder has been picked
|
||||||
|
/// (Design "Local root hidden until a folder is configured").
|
||||||
|
static const idMusicaLocal = 'musica_local';
|
||||||
|
|
||||||
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
||||||
static const _maxItemsPorCarpeta = 50;
|
static const _maxItemsPorCarpeta = 50;
|
||||||
|
|
||||||
@@ -148,12 +168,23 @@ class ConstructorArbolAuto {
|
|||||||
/// free against [_prefijoEmisora] and the bare folder id constants above.
|
/// free against [_prefijoEmisora] and the bare folder id constants above.
|
||||||
static const _prefijoGrupo = 'grupo:';
|
static const _prefijoGrupo = 'grupo:';
|
||||||
|
|
||||||
|
/// Local-music subfolder id prefix (Design "media-id scheme"),
|
||||||
|
/// collision-free against [_prefijoEmisora], [_prefijoGrupo],
|
||||||
|
/// [_prefijoPresetEq] and the bare folder id constants above.
|
||||||
|
static const _prefijoCarpetaLocal = 'carpeta_local:';
|
||||||
|
|
||||||
/// Separate cap for favorite-group folders under `Favoritos` (Design
|
/// Separate cap for favorite-group folders under `Favoritos` (Design
|
||||||
/// "group-folder ordering and cap"): a folder tap costs more driver
|
/// "group-folder ordering and cap"): a folder tap costs more driver
|
||||||
/// attention than a station scroll, so this is tunable independently of
|
/// attention than a station scroll, so this is tunable independently of
|
||||||
/// [_maxItemsPorCarpeta].
|
/// [_maxItemsPorCarpeta].
|
||||||
static const _maxGruposPorFavoritos = 50;
|
static const _maxGruposPorFavoritos = 50;
|
||||||
|
|
||||||
|
/// Dedicated cap for local-music folders (Design "Dedicated 50-item cap,
|
||||||
|
/// alphabetical truncation"), tunable independently of
|
||||||
|
/// [_maxItemsPorCarpeta]/[_maxGruposPorFavoritos] — the extension point
|
||||||
|
/// for a future native page-offset parameter.
|
||||||
|
static const _maxItemsCarpetaLocal = 50;
|
||||||
|
|
||||||
/// Content-style extras (Design "content style", optional polish): list
|
/// Content-style extras (Design "content style", optional polish): list
|
||||||
/// (1) for the root's folders, grid (2) for playable station items.
|
/// (1) for the root's folders, grid (2) for playable station items.
|
||||||
static const _contentStyleLista = {
|
static const _contentStyleLista = {
|
||||||
@@ -164,14 +195,21 @@ class ConstructorArbolAuto {
|
|||||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
|
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The 4 root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
||||||
/// Ecualizador), all non-playable. `Ecualizador` is deliberately LAST
|
/// optionally Música Local, Ecualizador), all non-playable. `Ecualizador`
|
||||||
/// (Design ADR-2): content-browsing folders are the primary car task and
|
/// is deliberately LAST (Design ADR-2): content-browsing folders are the
|
||||||
/// stay first, the EQ tool trails them.
|
/// primary car task and stay first, the EQ tool trails them. `Música
|
||||||
List<MediaItem> raiz() => [
|
/// Local` is OMITTED entirely (not just empty) unless [incluirMusicaLocal]
|
||||||
|
/// is `true` (Design "Local root hidden until a folder is configured") —
|
||||||
|
/// the caller passes `fuente.hayCarpetaConfigurada()`, keeping this
|
||||||
|
/// builder itself synchronous and side-effect free. When `false`, the
|
||||||
|
/// result is byte-identical to the pre-local-music 4-folder tree
|
||||||
|
/// (regression guard).
|
||||||
|
List<MediaItem> raiz({required bool incluirMusicaLocal}) => [
|
||||||
_carpeta(idFavoritos, 'Favoritos'),
|
_carpeta(idFavoritos, 'Favoritos'),
|
||||||
_carpeta(idTodas, 'Todas las emisoras'),
|
_carpeta(idTodas, 'Todas las emisoras'),
|
||||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||||
|
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
||||||
_carpeta(idEcualizador, 'Ecualizador'),
|
_carpeta(idEcualizador, 'Ecualizador'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -229,6 +267,43 @@ class ConstructorArbolAuto {
|
|||||||
MediaItem itemGrupo(GrupoFavoritos g) =>
|
MediaItem itemGrupo(GrupoFavoritos g) =>
|
||||||
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
|
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
|
||||||
|
|
||||||
|
/// Whether [id] identifies a local-music subfolder (Design "media-id
|
||||||
|
/// scheme").
|
||||||
|
bool esCarpetaLocalMediaId(String id) => id.startsWith(_prefijoCarpetaLocal);
|
||||||
|
|
||||||
|
/// Strips the `carpeta_local:` prefix from [id] by length (Design "Prefix
|
||||||
|
/// stripped by length" — survives a raw SAF documentId containing `:`/`/`
|
||||||
|
/// verbatim). Only meaningful when [esCarpetaLocalMediaId] is `true`.
|
||||||
|
String idCarpetaLocalDesde(String id) =>
|
||||||
|
id.substring(_prefijoCarpetaLocal.length);
|
||||||
|
|
||||||
|
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s (Design "Lazy
|
||||||
|
/// per-folder enumeration" + "Dedicated 50-item cap, alphabetical
|
||||||
|
/// truncation"): sorted alphabetically by [NodoLocal.nombre] and capped at
|
||||||
|
/// [_maxItemsCarpetaLocal]. Folders map to non-playable
|
||||||
|
/// `carpeta_local:<id>` items with their raw name; files map to playable
|
||||||
|
/// `pista:<id>` items with the extension stripped from the title (Design
|
||||||
|
/// "Title = filename minus extension") and a rotating on-brand `artUri`
|
||||||
|
/// (Design "art = reused station_art_* rotation"). An empty [nodos]
|
||||||
|
/// returns `[]`, never an error (Spec "browsing an empty subfolder").
|
||||||
|
List<MediaItem> itemsLocales(List<NodoLocal> nodos) {
|
||||||
|
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||||
|
return ordenados.take(_maxItemsCarpetaLocal).map(_itemLocal).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaItem _itemLocal(NodoLocal nodo) {
|
||||||
|
if (nodo.esDirectorio) {
|
||||||
|
return _carpeta('$_prefijoCarpetaLocal${nodo.documentId}', nodo.nombre);
|
||||||
|
}
|
||||||
|
return MediaItem(
|
||||||
|
id: '$_prefijoPista${nodo.documentId}',
|
||||||
|
title: _tituloDesdeNombre(nodo.nombre),
|
||||||
|
playable: true,
|
||||||
|
artUri: Uri.parse(artUriLocal(nodo.documentId)),
|
||||||
|
extras: _contentStyleGrid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Maps a [PresetEcualizador] to a playable `MediaItem` with id
|
/// Maps a [PresetEcualizador] to a playable `MediaItem` with id
|
||||||
/// `eq_preset:<nombre>` (Design ADR-1).
|
/// `eq_preset:<nombre>` (Design ADR-1).
|
||||||
MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem(
|
MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem(
|
||||||
@@ -324,6 +399,124 @@ Future<void> reproducirPorMediaId(
|
|||||||
await reproducir(item);
|
await reproducir(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback title (Design "Title = filename minus extension") for a blank
|
||||||
|
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
|
||||||
|
/// matching every other car-tree label in this file (`'Favoritos'`,
|
||||||
|
/// `'Ecualizador'`, etc.), none of which go through `AppLocalizations`.
|
||||||
|
const _tituloLocalFallback = 'Pista sin nombre';
|
||||||
|
|
||||||
|
/// Filename → display title (Design "Title = filename minus extension"):
|
||||||
|
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no
|
||||||
|
/// dot, or the dot is the first character — e.g. a hidden file like
|
||||||
|
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be
|
||||||
|
/// blank.
|
||||||
|
String _tituloDesdeNombre(String nombre) {
|
||||||
|
final recortado = nombre.trim();
|
||||||
|
if (recortado.isEmpty) return _tituloLocalFallback;
|
||||||
|
final ultimoPunto = recortado.lastIndexOf('.');
|
||||||
|
final sinExtension =
|
||||||
|
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
|
||||||
|
final resultado = sinExtension.trim();
|
||||||
|
return resultado.isEmpty ? _tituloLocalFallback : resultado;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
|
||||||
|
/// reused station_art_* rotation"): reuses the EXACT rotation
|
||||||
|
/// ([indiceArtePara]/`_nombresArte`) [artUriPara] uses for stations, seeded
|
||||||
|
/// by [documentId] instead of a station uuid — zero new assets, same
|
||||||
|
/// deterministic per-item mapping.
|
||||||
|
String artUriLocal(String documentId) =>
|
||||||
|
'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||||
|
'station_art_${_nombresArte[indiceArtePara(documentId)]}';
|
||||||
|
|
||||||
|
/// Local-music `getChildren` dispatch (Design "Data Flow"): resolves
|
||||||
|
/// [parentMediaId] against the `musica_local` root (`fuente.hijos('')`) or a
|
||||||
|
/// `carpeta_local:<id>` subfolder (`fuente.hijos(id)`), mapping the result
|
||||||
|
/// through [ConstructorArbolAuto.itemsLocales]. Returns `null` when
|
||||||
|
/// [parentMediaId] matches NEITHER shape, so the caller
|
||||||
|
/// (`ServicioAudio.getChildren`) can fall through to its other branches
|
||||||
|
/// unmodified. A `null` [fuente] (local source never registered — headless
|
||||||
|
/// cold bind) or any thrown error degrades to `[]`, never a crash (Design
|
||||||
|
/// "cold-start safe", mirrors `FuenteEmisorasAutoLocal`'s pattern; Spec
|
||||||
|
/// "Browse requested before app state is loaded" / "Permission revoked or
|
||||||
|
/// never granted").
|
||||||
|
Future<List<MediaItem>?> hijosMusicaLocal(
|
||||||
|
String parentMediaId, {
|
||||||
|
required FuenteMusicaLocalAuto? fuente,
|
||||||
|
}) async {
|
||||||
|
final constructor = ConstructorArbolAuto();
|
||||||
|
final String documentId;
|
||||||
|
if (parentMediaId == ConstructorArbolAuto.idMusicaLocal) {
|
||||||
|
documentId = '';
|
||||||
|
} else if (constructor.esCarpetaLocalMediaId(parentMediaId)) {
|
||||||
|
documentId = constructor.idCarpetaLocalDesde(parentMediaId);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (fuente == null) return const [];
|
||||||
|
try {
|
||||||
|
final nodos = await fuente.hijos(documentId);
|
||||||
|
return constructor.itemsLocales(nodos);
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort title for a played local track (Design "Local Track Playback
|
||||||
|
/// Reuses Existing Pipeline"): `FuenteMusicaLocalAuto.uriContenidoDePista`
|
||||||
|
/// only returns a content URI, not the original filename (Design's
|
||||||
|
/// Interfaces/Contracts — no metadata fields in Phase 1), so this derives a
|
||||||
|
/// title from the trailing path segment of the SAF [documentId] itself
|
||||||
|
/// (`primary:Music/Local/song.mp3` → `song.mp3` → title-stripped), applying
|
||||||
|
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
|
||||||
|
/// Now Playing title consistent with what the user tapped without requiring
|
||||||
|
/// a second native round trip.
|
||||||
|
String _tituloDesdeDocumentId(String documentId) {
|
||||||
|
final ultimaBarra = documentId.lastIndexOf('/');
|
||||||
|
final segmento =
|
||||||
|
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
|
||||||
|
return _tituloDesdeNombre(segmento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routing seam between a car-tapped `pista:<docId>` media id and the
|
||||||
|
/// existing playback pipeline (Design "Local Track Playback Reuses Existing
|
||||||
|
/// Pipeline" — same seam shape as [reproducirPorMediaId], Spec "User selects
|
||||||
|
/// a local track"). Resolves the content URI via [fuente], builds a
|
||||||
|
/// `MediaItem` and delegates to [reproducir] — the SAME injection point
|
||||||
|
/// stations use, so the shared EQ signal chain applies identically (Spec
|
||||||
|
/// "EQ still applies to local track playback", regression guard: no
|
||||||
|
/// separate/bypassed path exists here).
|
||||||
|
///
|
||||||
|
/// A stale/unknown [id] (or a malformed one) is a no-op: [reproducir] is
|
||||||
|
/// never called and no exception propagates (Spec "Unknown or stale track
|
||||||
|
/// id").
|
||||||
|
Future<void> reproducirPistaLocal(
|
||||||
|
String id, {
|
||||||
|
required FuenteMusicaLocalAuto fuente,
|
||||||
|
required Future<void> Function(MediaItem) reproducir,
|
||||||
|
}) async {
|
||||||
|
if (!esPistaMediaId(id)) return;
|
||||||
|
final documentId = id.substring(_prefijoPista.length);
|
||||||
|
if (documentId.isEmpty) return;
|
||||||
|
|
||||||
|
final contentUri = await fuente.uriContenidoDePista(documentId);
|
||||||
|
if (contentUri == null || contentUri.isEmpty) return;
|
||||||
|
|
||||||
|
final pista = PistaLocal(
|
||||||
|
documentId: documentId,
|
||||||
|
titulo: _tituloDesdeDocumentId(documentId),
|
||||||
|
contentUri: contentUri,
|
||||||
|
);
|
||||||
|
|
||||||
|
final item = MediaItem(
|
||||||
|
id: pista.contentUri,
|
||||||
|
title: pista.titulo,
|
||||||
|
album: 'PluriWave',
|
||||||
|
extras: {'documentId': pista.documentId},
|
||||||
|
);
|
||||||
|
await reproducir(item);
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves an `eq_preset:<nombre>` [id] to the matching [PresetEcualizador]
|
/// Resolves an `eq_preset:<nombre>` [id] to the matching [PresetEcualizador]
|
||||||
/// in [presets] by exact name (Design ADR-1, mirrors
|
/// in [presets] by exact name (Design ADR-1, mirrors
|
||||||
/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix,
|
/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../l10n/gen/app_localizations.dart';
|
|||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../modelos/preset_ecualizador.dart';
|
import '../modelos/preset_ecualizador.dart';
|
||||||
import 'controlador_reconexion.dart';
|
import 'controlador_reconexion.dart';
|
||||||
|
import 'musica_local_auto.dart';
|
||||||
import 'navegacion_auto.dart';
|
import 'navegacion_auto.dart';
|
||||||
import 'servicio_audio_session.dart';
|
import 'servicio_audio_session.dart';
|
||||||
import 'servicio_ecualizador.dart';
|
import 'servicio_ecualizador.dart';
|
||||||
@@ -47,6 +48,17 @@ void registrarFuenteNavegacion(FuenteEmisorasAuto fuente) {
|
|||||||
_fuenteNavegacionGlobal = fuente;
|
_fuenteNavegacionGlobal = fuente;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local-music browse source — registered from main.dart, mirrors
|
||||||
|
/// [registrarFuenteNavegacion] above (Design "getChildren data source
|
||||||
|
/// registration"). `null` until registered (headless cold bind before
|
||||||
|
/// main.dart's registration line runs) — every consumer below treats a
|
||||||
|
/// `null` fuente as "not configured" rather than throwing.
|
||||||
|
FuenteMusicaLocalAuto? _fuenteMusicaLocalGlobal;
|
||||||
|
|
||||||
|
void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
|
||||||
|
_fuenteMusicaLocalGlobal = fuente;
|
||||||
|
}
|
||||||
|
|
||||||
/// Wrapper de alto nivel para el UI.
|
/// Wrapper de alto nivel para el UI.
|
||||||
class ServicioAudio {
|
class ServicioAudio {
|
||||||
PluriWaveAudioHandler get _handler {
|
PluriWaveAudioHandler get _handler {
|
||||||
@@ -735,12 +747,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
]) async {
|
]) async {
|
||||||
try {
|
try {
|
||||||
final constructor = ConstructorArbolAuto();
|
final constructor = ConstructorArbolAuto();
|
||||||
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
||||||
if (parentMediaId == AudioService.browsableRootId) {
|
if (parentMediaId == AudioService.browsableRootId) {
|
||||||
return constructor.raiz();
|
final incluirMusicaLocal =
|
||||||
|
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
|
||||||
|
return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal);
|
||||||
}
|
}
|
||||||
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
|
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
|
||||||
return constructor.presetsEq(PresetEcualizador.presets);
|
return constructor.presetsEq(PresetEcualizador.presets);
|
||||||
}
|
}
|
||||||
|
final musicaLocal = await hijosMusicaLocal(
|
||||||
|
parentMediaId,
|
||||||
|
fuente: fuenteLocal,
|
||||||
|
);
|
||||||
|
if (musicaLocal != null) return musicaLocal;
|
||||||
final fuente = _fuenteNavegacionGlobal;
|
final fuente = _fuenteNavegacionGlobal;
|
||||||
if (fuente == null) return const [];
|
if (fuente == null) return const [];
|
||||||
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
||||||
@@ -802,6 +822,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Local-track playback (Design "Local Track Playback Reuses Existing
|
||||||
|
// Pipeline", Spec "User selects a local track"): SECOND branch,
|
||||||
|
// unconditional `return`, mirroring the eq_preset branch above — a
|
||||||
|
// `pista:` id never falls through to the station routing below.
|
||||||
|
if (esPistaMediaId(mediaId)) {
|
||||||
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
||||||
|
if (fuenteLocal == null) return;
|
||||||
|
await reproducirPistaLocal(
|
||||||
|
mediaId,
|
||||||
|
fuente: fuenteLocal,
|
||||||
|
reproducir: playMediaItem,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final fuente = _fuenteNavegacionGlobal;
|
final fuente = _fuenteNavegacionGlobal;
|
||||||
if (fuente == null) return;
|
if (fuente == null) return;
|
||||||
await reproducirPorMediaId(
|
await reproducirPorMediaId(
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Apply Progress: Android Auto Local Music — Phase 1
|
||||||
|
|
||||||
|
**Status**: done — all 12 task groups complete in a single pass.
|
||||||
|
**Delivery**: single PR with `size:exception` (per orchestrator/user decision —
|
||||||
|
matches repo precedent `35bb180 feat(auto): browsable Android Auto media tree
|
||||||
|
with play-by-id [size:exception]`). No slicing across batches.
|
||||||
|
**Mode**: Strict TDD for all pure-Dart, behavior-changing tasks; native Kotlin
|
||||||
|
and the on-device SAF picker round trip are **static-review-only** (no Android
|
||||||
|
build/DHU available in this environment — same established precedent as prior
|
||||||
|
Android Auto changes).
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
|
||||||
|
- [x] 1.1–1.2 `PistaLocal`/`NodoLocal` models (pure DTOs — no tests written,
|
||||||
|
per task 1.2's explicit "getter-only classes, skip" rule)
|
||||||
|
- [x] 2.1–2.2 `esArchivoAudio` (RED → GREEN, `musica_local_auto.dart`)
|
||||||
|
- [x] 3.1–3.2 Media-id scheme: `idMusicaLocal`, `_prefijoCarpetaLocal`,
|
||||||
|
`_prefijoPista`, `esCarpetaLocalMediaId`, `esPistaMediaId`,
|
||||||
|
`idCarpetaLocalDesde` (RED → GREEN, collision + `:`-in-docId cases)
|
||||||
|
- [x] 4.1–4.2 `FuenteMusicaLocalAuto` interface + `FuenteMusicaLocalAutoImpl`
|
||||||
|
channel-backed implementation (`musica_local_auto.dart`)
|
||||||
|
- [x] 5.1–5.4 + 11.1 `raiz(incluirMusicaLocal:)`, `itemsLocales`,
|
||||||
|
title-stripping, `artUriLocal`, root-count regression landed in the
|
||||||
|
SAME commit/batch as the test update (never a stale-count false green)
|
||||||
|
- [x] 6.1–6.2 `hijosMusicaLocal` dispatch, wired into
|
||||||
|
`PluriWaveAudioHandler.getChildren`
|
||||||
|
- [x] 7.1–7.3 `reproducirPistaLocal`, wired into
|
||||||
|
`PluriWaveAudioHandler.playFromMediaId`
|
||||||
|
- [x] 8.1–8.5 Native `file_actions` channel: `pickMusicFolder`,
|
||||||
|
`listAudioChildren`, `resolvePlayableUri`, `hasPersistedPermission`,
|
||||||
|
`onActivityResult` override (STATIC REVIEW ONLY)
|
||||||
|
- [x] 9.1–9.4 `_SeccionMusicaLocal` settings UI + SharedPreferences
|
||||||
|
persistence (`musica_local_uri`) + l10n keys (`app_en.arb`/`app_es.arb`)
|
||||||
|
- [x] 10.1–10.3 Cold-start / permission-revoked safety (explicit tests on top
|
||||||
|
of the try/catch already required by tasks 4/6/7)
|
||||||
|
- [x] 12.1 Full regression pass of `navegacion_auto_test.dart` +
|
||||||
|
`musica_local_auto_test.dart` + the 4 existing `servicio_audio_*_test.dart`
|
||||||
|
files — 91/91 passing, no pre-existing assertion broken
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Action | What Was Done |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `lib/modelos/pista_local.dart` | Created | `NodoLocal` + `PistaLocal` pure DTOs |
|
||||||
|
| `lib/servicios/musica_local_auto.dart` | Created | `esArchivoAudio`, `FuenteMusicaLocalAuto` interface, `FuenteMusicaLocalAutoImpl` (channel-backed, cold-start-safe, SharedPreferences-DI) |
|
||||||
|
| `lib/servicios/navegacion_auto.dart` | Modified | `_prefijoPista`/`esPistaMediaId` (top-level); `idMusicaLocal`/`_prefijoCarpetaLocal`/`_maxItemsCarpetaLocal`/`esCarpetaLocalMediaId`/`idCarpetaLocalDesde`/`itemsLocales`/`_itemLocal` (on `ConstructorArbolAuto`); `raiz(incluirMusicaLocal:)` signature change; module-level `artUriLocal`, `_tituloDesdeNombre`, `_tituloDesdeDocumentId`, `hijosMusicaLocal`, `reproducirPistaLocal` |
|
||||||
|
| `lib/servicios/servicio_audio.dart` | Modified | `_fuenteMusicaLocalGlobal` + `registrarFuenteMusicaLocal`; `getChildren` root-flag + `hijosMusicaLocal` delegation branches; `playFromMediaId` `pista:` branch (2nd, mirrors the `eq_preset:` branch's unconditional-return shape) |
|
||||||
|
| `lib/main.dart` | Modified (see Deviations) | Registers `FuenteMusicaLocalAutoImpl(prefs: prefs)` via `registrarFuenteMusicaLocal` — required plumbing, not an explicit task line item |
|
||||||
|
| `lib/pantallas/pantalla_ajustes.dart` | Modified | New `_SeccionMusicaLocal`/`_SeccionMusicaLocalState`, registered in `_AjustesContent`'s section list |
|
||||||
|
| `lib/l10n/app_en.arb` / `app_es.arb` | Modified | 7 new keys: `localMusicSectionTitle`, `localMusicSectionDescription`, `localMusicFolderNotConfigured`, `localMusicFolderTitle`, `localMusicChoosePath`, `localMusicChangePath`, `localMusicFolderUpdated`, `localMusicFolderSaveError` |
|
||||||
|
| `android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt` | Modified (STATIC REVIEW ONLY) | `pendingMusicFolderResult` field; `pickMusicFolder`/`listAudioChildren`/`resolvePlayableUri`/`hasPersistedPermission` cases in the existing `file_actions` `when` block; new `onActivityResult` override (first on this Activity); 3 new private helper methods |
|
||||||
|
| `test/servicios/musica_local_auto_test.dart` | Created | `esArchivoAudio` unit tests (4 tests) |
|
||||||
|
| `test/servicios/navegacion_auto_test.dart` | Modified | `raiz` test split into configured(5)/hidden(4) cases; new groups: `esCarpetaLocalMediaId / esPistaMediaId`, `artUriLocal`, `ConstructorArbolAuto.itemsLocales`, `hijosMusicaLocal`, `reproducirPistaLocal`; `_FakeFuenteMusicaLocalAuto` test double |
|
||||||
|
| `openspec/changes/android-auto-local-music/tasks.md` | Modified | All 31 checkbox items marked `[x]` |
|
||||||
|
|
||||||
|
## TDD Cycle Evidence
|
||||||
|
|
||||||
|
| Task | RED | GREEN | REFACTOR | Notes |
|
||||||
|
|------|-----|-------|----------|-------|
|
||||||
|
| 2. `esArchivoAudio` | Wrote 4 failing tests (null/blank MIME+name, non-audio MIME, valid audio/*) before implementation existed | Implemented in `musica_local_auto.dart`, all pass | N/A — single small pure function | |
|
||||||
|
| 3. Media-id predicates | Wrote collision + `:`-in-docId tests before adding `esCarpetaLocalMediaId`/`esPistaMediaId`/`idCarpetaLocalDesde` | Implemented, all pass | Mirrored exact shape of existing `esCarpetaGrupo`/`esPresetMediaId` — no rework needed | |
|
||||||
|
| 5. `raiz`/`itemsLocales` | Rewrote the existing 4-folder `raiz` test into configured(5)/hidden(4) cases + wrote 8 `itemsLocales` cases (cap, sort, title-strip x3, art, folder-vs-file) before touching `raiz()`'s signature | Implemented `raiz(incluirMusicaLocal:)`, `itemsLocales`, `_itemLocal`, `_tituloDesdeNombre`, `artUriLocal`; all pass | None needed | Landed together with task 11 in the same edit so the suite was never red for the stale-count reason |
|
||||||
|
| 6. `hijosMusicaLocal` dispatch | Wrote 5 cases (root delegation, subfolder delegation, non-local id → null, null fuente → `[]`, throwing fuente → `[]`) before writing the function | Implemented, all pass | None needed | |
|
||||||
|
| 7. `reproducirPistaLocal` | Wrote 4 cases (resolves+plays, stale id no-op, wrong-prefix no-op, structural EQ-seam regression guard) before writing the function | Implemented, all pass | None needed | |
|
||||||
|
| 10. Cold-start safety | Covered by task 6/7's null-fuente and throwing-fuente cases above (explicit, not incidental) | Already green from 6/7's implementation | N/A | No additional guard code was needed — the try/catch shape from tasks 4/6/7 already satisfied it |
|
||||||
|
| 8. Native Kotlin (`pickMusicFolder` etc.) | N/A — static-review-only, no test runner available | N/A | Written carefully against documented `DocumentsContract`/`ActivityResultLauncher`-era SAF patterns; balanced-brace/paren sanity check run | Flagged as highest-risk, unverified at runtime (see Risks) |
|
||||||
|
| 9. Settings UI | N/A — SAF round trip is on-device-only per task 9's own note; no widget test written (would only cover layout, adds little given the channel call is stubbed either way) | N/A | Mirrors `_SeccionGrabaciones` exactly | |
|
||||||
|
| 1. Models | N/A — task 1.2 explicitly says skip tests for getter-only DTOs | N/A | N/A | |
|
||||||
|
|
||||||
|
## Test Results (independently re-run, exact counts)
|
||||||
|
|
||||||
|
```
|
||||||
|
flutter test test/servicios/navegacion_auto_test.dart test/servicios/musica_local_auto_test.dart --concurrency=1 --timeout=60s
|
||||||
|
→ 70/70 passing (66 in navegacion_auto_test.dart, 4 in musica_local_auto_test.dart)
|
||||||
|
|
||||||
|
flutter test test/servicios/servicio_audio_reconnect_test.dart test/servicios/servicio_audio_session_test.dart test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_audio_eq_reapply_test.dart --concurrency=1 --timeout=60s
|
||||||
|
→ 21/21 passing (pre-existing suites, re-run to confirm no regression from servicio_audio.dart edits)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total: 91/91 passing, 0 failing.** Both commands were run twice in this
|
||||||
|
session and produced identical counts both times.
|
||||||
|
|
||||||
|
Not run (per constraints): `flutter analyze`, `flutter build`,
|
||||||
|
`flutter gen-l10n`, any Android/Gradle build or DHU/emulator session.
|
||||||
|
|
||||||
|
## Deviations from Design
|
||||||
|
|
||||||
|
1. **`lib/main.dart` registration call — NOT an explicit task line item, but
|
||||||
|
added anyway.** Design's File Changes table lists
|
||||||
|
`registrarFuenteMusicaLocal`'s *definition* under `servicio_audio.dart`,
|
||||||
|
mirroring `_fuenteNavegacionGlobal`/`registrarFuenteNavegacion`. However,
|
||||||
|
nothing in tasks.md or design.md explicitly instructs wiring the actual
|
||||||
|
`registrarFuenteMusicaLocal(...)` *call site* into `main.dart`. Without it,
|
||||||
|
`_fuenteMusicaLocalGlobal` would always be `null` at runtime and the local
|
||||||
|
root would never appear regardless of what the user configures — the
|
||||||
|
feature would be entirely non-functional despite 100% green tests (since
|
||||||
|
all pure-Dart tests inject a fake fuente directly). I added the one-line
|
||||||
|
registration in `main.dart`, mirroring the existing
|
||||||
|
`registrarFuenteNavegacion(fuenteAuto)` call exactly (same file, same
|
||||||
|
pattern, 5 lines including the comment). This is a necessary completion of
|
||||||
|
task 4/6's own intent, not a scope expansion — flagging it explicitly
|
||||||
|
rather than silently expanding scope, as instructed.
|
||||||
|
2. **Local-track title on playback is derived from the SAF `documentId`'s
|
||||||
|
trailing path segment, not from a carried filename.** Design's
|
||||||
|
`FuenteMusicaLocalAuto` interface (`Interfaces / Contracts`) only exposes
|
||||||
|
`uriContenidoDePista(documentId) → content:// or null` — no filename is
|
||||||
|
returned alongside the content URI. Since `MediaItem.title` is required
|
||||||
|
for the Now Playing UI, `reproducirPistaLocal` derives a best-effort title
|
||||||
|
via `_tituloDesdeDocumentId` (last `/`-segment of the documentId, then the
|
||||||
|
SAME `_tituloDesdeNombre` extension-stripping rule the browse tree uses).
|
||||||
|
This is consistent with the design's stated title rule and requires no
|
||||||
|
interface change or second native round trip, but is a genuine
|
||||||
|
interpretation filling a gap in the documented contract — flagging it
|
||||||
|
explicitly per the "note deviations, don't silently freelance" rule.
|
||||||
|
3. **Folder titles in `itemsLocales` are NOT extension-stripped** — only
|
||||||
|
playable track titles are. Design's "Title = filename minus extension;
|
||||||
|
art = reused station_art_* rotation" bullet pairs title+art together, and
|
||||||
|
art clearly only applies to playable items (folders have no `artUri`) —
|
||||||
|
read narrowly as a track-mapping rule, not a folder-mapping rule.
|
||||||
|
Folders keep their raw `nombre`, matching how every other non-playable
|
||||||
|
folder in this file (`itemGrupo`, the root `_carpeta` calls) uses the raw
|
||||||
|
label with no stripping.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Native Kotlin `onActivityResult` (task 8) is genuinely new plumbing on
|
||||||
|
`MainActivity` and is UNVERIFIED at runtime** — no Android build/DHU
|
||||||
|
available in this environment. Written carefully against standard SAF
|
||||||
|
`ACTION_OPEN_DOCUMENT_TREE`/`DocumentsContract` patterns (mirrors how
|
||||||
|
`openDirectory`/`viewDirectory` already use `DocumentsContract` in this
|
||||||
|
same file), with explicit handling for: stale-pending-result overwrite
|
||||||
|
(user re-triggers the picker while a previous call is still pending),
|
||||||
|
cancel (`resultCode != RESULT_OK`), null `data.data`, and a
|
||||||
|
`takePersistableUriPermission` failure. This is exactly the risk flagged
|
||||||
|
by both design.md's "Open Questions" and tasks.md's task-8 note — reviewer
|
||||||
|
attention warranted here specifically, on-device manual verification
|
||||||
|
required before this ships.
|
||||||
|
- **`listAudioChildren`'s SAF query (task 8.2) is also unverified at
|
||||||
|
runtime.** Uses `DocumentsContract.buildChildDocumentsUriUsingTree` +
|
||||||
|
`ContentResolver.query` with the standard 3-column projection
|
||||||
|
(`COLUMN_DOCUMENT_ID`/`COLUMN_DISPLAY_NAME`/`COLUMN_MIME_TYPE`) — a
|
||||||
|
well-documented pattern, but not exercised against a real SAF provider in
|
||||||
|
this pass.
|
||||||
|
- Deviation #2 above (derived playback title) means a played local track's
|
||||||
|
Now Playing title could theoretically differ from its browse-list title if
|
||||||
|
a future native change ever made `documentId`'s trailing segment diverge
|
||||||
|
from the actual filename (unlikely with the current SAF-native
|
||||||
|
`DocumentsContract` documentId shape, but worth a note for Phase 2 if
|
||||||
|
metadata is added).
|
||||||
|
|
||||||
|
## Workload / PR Boundary
|
||||||
|
|
||||||
|
- **Mode**: single PR, `size:exception` (explicit user choice, matches
|
||||||
|
precedent commit `35bb180`).
|
||||||
|
- **Current work unit**: all of Phase 1 (task groups 1–12), one pass.
|
||||||
|
- **Boundary**: starts from zero (no prior apply-progress existed) and ends
|
||||||
|
with every task in `tasks.md` marked `[x]` and the full targeted test
|
||||||
|
suite green.
|
||||||
|
- **Estimated review budget impact**: tasks.md's own forecast estimated
|
||||||
|
~750–950 changed lines; this implementation is within that range. Reviewer
|
||||||
|
should budget for a single large review pass, with extra attention on
|
||||||
|
`MainActivity.kt`'s new `onActivityResult` per the Risks section above.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
31/31 checkbox items across 12/12 task groups complete. Working tree left
|
||||||
|
unstaged for the orchestrator to commit/push with a `[size:exception]` tag,
|
||||||
|
per instructions.
|
||||||
|
|
||||||
|
**Next recommended**: `sdd-verify`
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Design: Android Auto Local Music — Phase 1
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
Extend the existing screaming-architecture seams, do NOT fork them. Local music becomes a
|
||||||
|
new browse domain that slots into the SAME lazy `getChildren`/`playFromMediaId` dispatch used
|
||||||
|
by stations, groups and EQ presets. All routing, id parsing, filename→title mapping, cap and
|
||||||
|
fallback-art logic lives in pure Dart in `navegacion_auto.dart` (fully unit-testable). Native
|
||||||
|
Kotlin stays thin and static-review-only: it just walks ONE `DocumentFile` level on demand and
|
||||||
|
returns a serializable node list, mirroring the already-lazy per-folder browse model.
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
### Decision: Hand-rolled SAF channel, not `shared_storage`
|
||||||
|
|
||||||
|
**Choice**: Extend the existing `pluriwave/file_actions` `MethodChannel` in `MainActivity.kt`
|
||||||
|
with lazy per-level SAF methods. **Rejected**: adding `shared_storage` (or similar).
|
||||||
|
**Rationale**: `shared_storage` is unmaintained (dependency-vetting risk); native is
|
||||||
|
static-review-only EITHER way here, so the package buys no testability. Hand-rolling gives full
|
||||||
|
control of the wire shape, lets us filter audio at the native layer (lean payload), and adds
|
||||||
|
ZERO new pub dependencies. The channel already speaks `DocumentsContract` — this is a natural
|
||||||
|
extension, not new surface.
|
||||||
|
|
||||||
|
### Decision: Lazy per-folder enumeration, never an eager tree dump
|
||||||
|
|
||||||
|
**Choice**: `listAudioChildren(treeUri, parentDocumentId)` returns ONE level (subfolders +
|
||||||
|
audio files). **Rejected**: eager recursive JSON of the whole tree. **Rationale**: libraries
|
||||||
|
reach thousands of files; a full tree round-trip is slow and memory-heavy. The existing
|
||||||
|
`getChildren` is already lazy per folder tap — one native call per browsed level mirrors it
|
||||||
|
exactly, bounds latency/memory to a single folder, and naturally respects the row cap.
|
||||||
|
|
||||||
|
### Decision: Pure SAF, no `READ_MEDIA_AUDIO`, no `permission_handler`
|
||||||
|
|
||||||
|
**Choice**: `ACTION_OPEN_DOCUMENT_TREE` + `takePersistableUriPermission` only. **Rejected**:
|
||||||
|
`READ_MEDIA_AUDIO`/`MediaStore` + `permission_handler`. **Rationale**: a persisted tree grant
|
||||||
|
reads everything under the picked root with NO dangerous runtime permission, is scoped-storage
|
||||||
|
compliant, needs no Play-Store data-access justification, and leaves the manifest permission set
|
||||||
|
UNCHANGED. Net: no new manifest permission, no runtime-request flow, no new Dart dep.
|
||||||
|
|
||||||
|
### Decision: Media-id scheme `musica_local` / `carpeta_local:` / `pista:`
|
||||||
|
|
||||||
|
**Choice**: root folder id `musica_local`; subfolders `carpeta_local:<documentId>`; tracks
|
||||||
|
`pista:<documentId>`. Prefix stripped by length so the raw `documentId` (which itself contains
|
||||||
|
`:`/`/`) survives verbatim. **Rationale**: collision-free against `emisora:`, `grupo:`,
|
||||||
|
`eq_preset:` and the bare folder ids. Playback content URI is resolved lazily at play time via
|
||||||
|
the source, so ids stay short.
|
||||||
|
|
||||||
|
### Decision: Local root hidden until a folder is configured; placed before Ecualizador
|
||||||
|
|
||||||
|
**Choice**: order = Favoritos, Todas, Mis emisoras, **Música Local**, Ecualizador; the local
|
||||||
|
folder is OMITTED from `raiz()` when no folder is persisted. **Rationale**: content-browsing
|
||||||
|
folders lead, the EQ tool trails (existing ADR-2); hiding an unconfigured root mirrors the
|
||||||
|
empty-group hidden-folder precedent (no dead ends).
|
||||||
|
|
||||||
|
### Decision: Dedicated 50-item cap, alphabetical truncation
|
||||||
|
|
||||||
|
**Choice**: separate `_maxItemsCarpetaLocal = 50`, sort by filename, truncate. **Rejected**:
|
||||||
|
higher/unbounded cap. **Rationale**: driver-distraction parity with stations; pagination is
|
||||||
|
explicitly deferred (Phase 2/3). Separate constant is the extension point (native call can later
|
||||||
|
take a page offset). No metadata in Phase 1, so ordering is alphabetical, deterministic, stable.
|
||||||
|
|
||||||
|
### Decision: Title = filename minus extension; art = reused `station_art_*` rotation
|
||||||
|
|
||||||
|
**Choice**: `titulo` = display name with the last `.ext` stripped (whole name if no dot;
|
||||||
|
non-empty fallback constant if blank/null). `artUri` = the existing 4-asset rotation seeded by
|
||||||
|
`documentId` via the existing `indiceArtePara`. **Rationale**: zero new assets, on-brand,
|
||||||
|
deterministic per-track art, reuses tested rotation infra. A distinct local-track placeholder is
|
||||||
|
deferred polish.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
Phone Settings ──pickMusicFolder──▶ SAF picker ──persist──▶ SharedPreferences('musica_local_uri')
|
||||||
|
Car browse root ─▶ raiz(incluirMusicaLocal: fuente.hayCarpetaConfigurada())
|
||||||
|
Car taps Música Local / carpeta_local:<id> ─▶ fuente.hijos(docId) ─▶ itemsLocales(nodos) [native lists 1 level]
|
||||||
|
Car taps pista:<id> ─▶ reproducirPistaLocal ─▶ fuente.uriContenido(docId) ─▶ playMediaItem(content:// item)
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
| File | Action | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `lib/modelos/pista_local.dart` | Create | `PistaLocal` + `NodoLocal` DTO (documentId, name, isDirectory) |
|
||||||
|
| `lib/servicios/musica_local_auto.dart` | Create | `FuenteMusicaLocalAuto` abstraction + channel-backed impl (cold-start safe, never throws) |
|
||||||
|
| `lib/servicios/navegacion_auto.dart` | Modify | Add `carpeta_local:`/`pista:` predicates, `titulo`, `artUriLocal`, `itemsLocales`, `reproducirPistaLocal`; `raiz(incluirMusicaLocal:)` |
|
||||||
|
| `lib/servicios/servicio_audio.dart` | Modify | Register `_fuenteMusicaLocalGlobal`; new `getChildren` branches (root flag, `musica_local`, `carpeta_local:`) + `playFromMediaId` `pista:` branch |
|
||||||
|
| `lib/pantallas/pantalla_ajustes.dart` | Modify | New `_SeccionMusicaLocal` folder-pick section (mirrors `_SeccionGrabaciones`) |
|
||||||
|
| `android/.../MainActivity.kt` | Modify | Add `pickMusicFolder`, `listAudioChildren`, `resolvePlayableUri`, `hasPersistedPermission` to `file_actions` channel (static-review-only) |
|
||||||
|
|
||||||
|
Manifest and `pubspec.yaml`: NO changes required (pure-SAF, no new dep/permission).
|
||||||
|
|
||||||
|
## Interfaces / Contracts
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class NodoLocal { final String documentId; final String nombre; final bool esDirectorio; }
|
||||||
|
|
||||||
|
abstract class FuenteMusicaLocalAuto {
|
||||||
|
Future<bool> hayCarpetaConfigurada();
|
||||||
|
Future<List<NodoLocal>> hijos(String documentId); // '' = tree root; never throws
|
||||||
|
Future<String?> uriContenidoDePista(String documentId); // content:// or null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Native `listAudioChildren` returns `[{documentId, nombre, esDirectorio}]`, filtering files to
|
||||||
|
`audio/*` MIME; Dart re-validates via pure `esArchivoAudio(mime, nombre)` (defense + testable).
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
| Layer | What to Test | Approach |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| Unit | id predicates, `titulo` (ext strip / no-dot / blank), `artUriLocal`, `itemsLocales` cap+map, `raiz` visibility, `reproducirPistaLocal` (stale/unknown = no-op), cold-start empty | Pure Dart `flutter test`, fake `FuenteMusicaLocalAuto` |
|
||||||
|
| Static review | 4 new Kotlin channel methods, SAF persist/re-validate | No Android build here — code review only |
|
||||||
|
|
||||||
|
## Migration / Rollout
|
||||||
|
|
||||||
|
No migration. Additive behind a hidden root that only appears once a folder is picked. Rollback
|
||||||
|
= remove the local branches/files + the settings section; stations untouched.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None blocking. Native `pickMusicFolder` uses `startActivityForResult` (new to this Activity) —
|
||||||
|
flagged for careful static review since it cannot be runtime-verified here.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Proposal: Android Auto Local Music — Phase 1 (Foundational Plumbing)
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Users want to browse and play local music files (nested device folders) through Android Auto, mirroring the proven radio-station browse tree. This introduces THREE new domains at once — SAF/scoped-storage folder access, local audio metadata, and a new track model — so it MUST ship phased. This proposal scopes **Phase 1 only**: the foundational, independently shippable plumbing that gets a user-picked folder tree browsable and playable in the car, verified before any metadata/UX polish is layered on.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope (Phase 1)
|
||||||
|
- SAF folder pick via `ACTION_OPEN_DOCUMENT_TREE`, persisted URI permission, graceful revocation handling (mirror tolerant-read precedent).
|
||||||
|
- Android storage permission declaration + request flow (`READ_MEDIA_AUDIO` / SAF grant), replacing the currently-empty manifest state.
|
||||||
|
- Recursive `DocumentFile` tree traversal enumerating audio files under the picked root (new dependency, e.g. `shared_storage`, or hand-rolled platform channel).
|
||||||
|
- New `PistaLocal` model (file URI, display name from filename, folder path) — NOT an `Emisora` extension.
|
||||||
|
- Android Auto browse tree extension: new root folder + folder/leaf `MediaItem`s via the existing `ConstructorArbolAuto`/`FuenteEmisorasAuto` pattern, id-prefix routing (`pista:`, `carpeta_local:`), respecting the `_maxItemsPorCarpeta = 50` cap.
|
||||||
|
- Play-by-id + standard transport (play/pause/stop) by REUSING the existing `audio_service`/`PluriWaveAudioHandler` pipeline.
|
||||||
|
- Album art: existing on-brand fallback rotation via `artUri` (no embedded art yet).
|
||||||
|
|
||||||
|
### Out of Scope (deferred)
|
||||||
|
- **Phase 2**: metadata extraction (title/artist/embedded art/bitrate/sample-rate), sort & filter by name/quality, embedded album art.
|
||||||
|
- **Phase 3**: subfolder scoping refinements, shuffle.
|
||||||
|
- Live/car-side waveform — architecturally impossible under legacy `MediaBrowserService` (confirmed). Phone-side waveform reuse of `visualizador_audio.dart` is a separate future follow-up.
|
||||||
|
- EQ — already shipped, direct reuse, no new work.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `local-music-browse`: SAF folder access, persisted permission, recursive audio-file enumeration, `PistaLocal` model, and Android Auto browse/play of local files.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `android-auto-navigation`: browse tree gains a local-music root folder alongside existing station folders (confirm exact spec name in `openspec/specs/` during sdd-spec).
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Extend the existing screaming-architecture seams rather than fork them. Recursive traversal + `PistaLocal` mapping are pure Dart (fully unit-testable). SAF platform-channel/permission/manifest work is static-review-only (no Android build/DHU in this env — same precedent as all prior native Android Auto work). Browse tree reuses `ConstructorArbolAuto` shape; playback reuses `PluriWaveAudioHandler` unchanged, handing a local file URI to `just_audio` exactly as station URLs are handed today.
|
||||||
|
|
||||||
|
## Affected Areas
|
||||||
|
|
||||||
|
| Area | Impact | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `pubspec.yaml` | Modified | Add SAF/traversal dep; enable `permission_handler` |
|
||||||
|
| `android/app/src/main/AndroidManifest.xml` | Modified | Declare `READ_MEDIA_AUDIO` / SAF permission |
|
||||||
|
| `android/.../MainActivity.kt` | Modified | Extend platform channel for tree traversal (reuse `file_actions` pattern) |
|
||||||
|
| `lib/modelos/pista_local.dart` | New | `PistaLocal` model |
|
||||||
|
| `lib/servicios/` (folder source) | New | SAF pick + persisted URI + recursive enumeration |
|
||||||
|
| `lib/servicios/navegacion_auto.dart` | Modified | Local-music root folder + `pista:`/`carpeta_local:` routing |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|------|------------|------------|
|
||||||
|
| SAF permission revoked outside app | Med | Tolerant reads; degrade to empty-but-valid tree (existing precedent) |
|
||||||
|
| Large nested trees exceed row caps | Med | Enforce `_maxItemsPorCarpeta`; defer pagination story to a later phase |
|
||||||
|
| SAF/native code not runtime-testable here | High | Static-review-only; isolate pure-Dart logic for full unit coverage |
|
||||||
|
| Scope creep pulling metadata/shuffle into Phase 1 | Med | Hard phase boundary; metadata is Phase 2 |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
Additive change behind a new browse root. Revert by removing the local-music root folder registration + id-prefix routing in `navegacion_auto.dart`, the new model/service files, and the manifest/pubspec additions. Existing station browse/play is untouched, so rollback removes only new surface with no regression to shipped behavior.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- A SAF tree-traversal package (e.g. `shared_storage`) OR a hand-rolled `DocumentFile` platform channel — decide in sdd-design.
|
||||||
|
- `permission_handler` re-enabled in `pubspec.yaml`.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] User can pick a root folder; permission persists across app restarts.
|
||||||
|
- [ ] Nested audio files enumerate recursively into `PistaLocal` instances (unit-tested).
|
||||||
|
- [ ] A local-music root folder appears in Android Auto with browsable subfolders and playable track leaves.
|
||||||
|
- [ ] Tapping a track plays it through the existing pipeline with working play/pause/stop and fallback album art.
|
||||||
|
- [ ] No regression to existing station browse/play; pure-Dart logic fully unit-tested, native code static-reviewed.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Delta for Android Auto Media
|
||||||
|
|
||||||
|
Scope: Phase 1 (foundational plumbing) of `android-auto-local-music` only. See "Not in this delta" for explicit exclusions.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Local Music Root Access and Permission Persistence
|
||||||
|
|
||||||
|
The system MUST let the user select a local-music root folder via SAF (`ACTION_OPEN_DOCUMENT_TREE`), persist the granted URI permission, and MUST NOT crash or leave the local-music root folder in a broken state if that permission is later lost or was never granted.
|
||||||
|
|
||||||
|
#### Scenario: User picks a local music root folder
|
||||||
|
|
||||||
|
- GIVEN the user opens the local-music setup flow
|
||||||
|
- WHEN they complete the SAF folder picker and grant access
|
||||||
|
- THEN the selected folder's URI permission is persisted
|
||||||
|
- AND the local-music root becomes browsable in Android Auto without re-prompting the picker
|
||||||
|
|
||||||
|
#### Scenario: Persisted permission survives app restart
|
||||||
|
|
||||||
|
- GIVEN a local music root was previously picked and its permission persisted
|
||||||
|
- WHEN the app is restarted (cold start)
|
||||||
|
- THEN the local-music root remains browsable in Android Auto without re-prompting the SAF picker
|
||||||
|
|
||||||
|
#### Scenario: Permission revoked or never granted
|
||||||
|
|
||||||
|
- GIVEN the SAF permission for the local-music root was revoked outside the app, or no root was ever picked
|
||||||
|
- WHEN the local-music root folder is browsed in Android Auto
|
||||||
|
- THEN `getChildren` returns an empty or explanatory list, not an error
|
||||||
|
- AND the audio handler does not throw or crash
|
||||||
|
|
||||||
|
### Requirement: Local Music Browsable Tree
|
||||||
|
|
||||||
|
The Android Auto browse tree MUST expose a new non-playable local-music root folder alongside the existing station folders. Browsing that root and any nested subfolder MUST recursively mirror the picked SAF folder's structure: subfolders as non-playable `carpeta_local:<id>` items and audio files as playable `pista:<id>` items resolving to `PistaLocal` instances, using the raw filename as the displayed title.
|
||||||
|
|
||||||
|
#### Scenario: Car browses the local-music root
|
||||||
|
|
||||||
|
- GIVEN a local music root folder was picked and its permission is valid
|
||||||
|
- WHEN `getChildren` is called with the local-music root folder id
|
||||||
|
- THEN it returns the root's immediate subfolders as `carpeta_local:<id>` items and audio files as `pista:<id>` playable items
|
||||||
|
- AND each playable item's title is the file's raw filename
|
||||||
|
|
||||||
|
#### Scenario: Car browses a nested subfolder
|
||||||
|
|
||||||
|
- GIVEN a `carpeta_local:<id>` folder returned from a prior browse call
|
||||||
|
- WHEN `getChildren` is called with that folder id
|
||||||
|
- THEN it returns that subfolder's own contents (nested folders and/or tracks), recursively mirroring the on-device structure
|
||||||
|
- AND browsing an empty subfolder returns an empty list, not an error
|
||||||
|
|
||||||
|
### Requirement: Local Track Playback Reuses Existing Pipeline
|
||||||
|
|
||||||
|
Selecting a `pista:<id>` item MUST resolve to the corresponding `PistaLocal` and play it through the existing `playMediaItem`/`PluriWaveAudioHandler` pipeline, unchanged, including the shared EQ signal chain.
|
||||||
|
|
||||||
|
#### Scenario: User selects a local track
|
||||||
|
|
||||||
|
- GIVEN the user taps a `pista:<id>` playable item on the car head unit
|
||||||
|
- WHEN `playFromMediaId(id)` is called
|
||||||
|
- THEN the id resolves to a `PistaLocal` and playback starts through the existing `playMediaItem` internal path
|
||||||
|
- AND standard transport (play/pause/stop) and fallback album art work as they do for stations
|
||||||
|
|
||||||
|
#### Scenario: EQ still applies to local track playback (regression guard)
|
||||||
|
|
||||||
|
- GIVEN a non-flat EQ preset is currently the active/principal preset
|
||||||
|
- WHEN a `pista:<id>` item is played
|
||||||
|
- THEN the audible output passes through the same shared EQ signal chain used for station playback, with no separate or bypassed path for local tracks
|
||||||
|
|
||||||
|
#### Scenario: Unknown or stale track id
|
||||||
|
|
||||||
|
- GIVEN `playFromMediaId` is called with a `pista:<id>` that no longer resolves to a known `PistaLocal`
|
||||||
|
- WHEN resolution fails
|
||||||
|
- THEN playback does not start and no unhandled exception propagates from the handler
|
||||||
|
|
||||||
|
### Requirement: Local Music Folder Item Cap
|
||||||
|
|
||||||
|
A local-music folder (root or nested) whose immediate children exceed `_maxItemsPorCarpeta` (50) MUST be capped to that limit rather than returning unbounded results; pagination is out of scope for this delta.
|
||||||
|
|
||||||
|
#### Scenario: Folder exceeds the item cap
|
||||||
|
|
||||||
|
- GIVEN a picked or nested local-music folder contains more than 50 immediate children (subfolders and/or tracks combined)
|
||||||
|
- WHEN `getChildren` is called with that folder id
|
||||||
|
- THEN at most 50 items are returned, consistent with the existing `_maxItemsPorCarpeta` cap applied to station folders
|
||||||
|
- AND no error or crash occurs as a result of the truncation
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Browsable Media Tree
|
||||||
|
|
||||||
|
`getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the new local-music root) containing playable items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders; `Todas las emisoras` and `Mis emisoras` remain flat. The `Ecualizador` folder is flat, non-playable, and contains only the 6 fixed EQ preset items. The local-music root folder is non-playable and may itself be nested (see "Local Music Browsable Tree").
|
||||||
|
(Previously: root contained exactly four folders — Favoritos, Todas las emisoras, Mis emisoras, Ecualizador — with no local-music root.)
|
||||||
|
|
||||||
|
#### Scenario: Car requests the root
|
||||||
|
|
||||||
|
- GIVEN the car head unit connects and requests the root (`AudioService.browsableRootId`)
|
||||||
|
- WHEN `getChildren` is called with the root id
|
||||||
|
- THEN it returns five folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root), each with `playable: false`
|
||||||
|
|
||||||
|
#### Scenario: Car requests a folder with no stations
|
||||||
|
|
||||||
|
- GIVEN the user has zero favorite stations
|
||||||
|
- WHEN `getChildren` is called with the Favoritos folder id
|
||||||
|
- THEN it returns an empty list, not an error
|
||||||
|
|
||||||
|
#### Scenario: Browse requested before app state is loaded
|
||||||
|
|
||||||
|
- GIVEN the audio handler starts cold and station/favorites Provider state has not finished loading
|
||||||
|
- WHEN `getChildren` is called (root or any folder)
|
||||||
|
- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service
|
||||||
|
|
||||||
|
#### Scenario: Station has known codec and bitrate
|
||||||
|
|
||||||
|
- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known (non-null)
|
||||||
|
- WHEN it is mapped to a playable `MediaItem`
|
||||||
|
- THEN `displaySubtitle` SHALL contain a human-readable quality hint combining bitrate and codec
|
||||||
|
|
||||||
|
#### Scenario: Station has unknown codec or bitrate
|
||||||
|
|
||||||
|
- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` (or both) is null/unknown
|
||||||
|
- WHEN it is mapped to a playable `MediaItem`
|
||||||
|
- THEN `displaySubtitle` SHALL omit the quality hint gracefully, and MUST NOT render literal placeholder text such as "null kbps"
|
||||||
|
|
||||||
|
#### Scenario: Ungrouped station appears exactly as before (regression guard)
|
||||||
|
|
||||||
|
- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId`
|
||||||
|
- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed
|
||||||
|
- THEN that station appears as a playable `emisora:<uuid>` item exactly as before, unaffected by the local-music root's existence or content
|
||||||
|
|
||||||
|
## Not in this delta
|
||||||
|
|
||||||
|
Phase 1 does NOT specify: local-track metadata display beyond the raw filename (no title/artist tagging), album art beyond the existing generic on-brand fallback, sort/filter of local tracks or folders, or shuffle. These are deferred to Phase 2/3 per the proposal and MUST NOT be implemented against this spec.
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# Tasks: Android Auto Local Music — Phase 1
|
||||||
|
|
||||||
|
**Apply status: all 12 task groups complete (single-pass batch, `size:exception`).**
|
||||||
|
See `openspec/changes/android-auto-local-music/apply-progress.md` for the
|
||||||
|
full implementation report, TDD evidence table, and deviations.
|
||||||
|
|
||||||
|
Scope: Phase 1 only (foundational plumbing). Grounded against live code as of this
|
||||||
|
writing — see "Grounding notes" per task for exact file:line anchors re-verified
|
||||||
|
during this pass (not trusted from spec/design alone).
|
||||||
|
|
||||||
|
Strict TDD Mode is ACTIVE for this project. Every pure-Dart, behavior-changing task
|
||||||
|
below follows red → green → refactor. Native Kotlin and any on-device SAF picker flow
|
||||||
|
is **static-review-only** (this project's established precedent — same as the
|
||||||
|
Android Auto EQ-presets and browsable-tree changes) and is flagged explicitly per
|
||||||
|
task. `flutter build`/`flutter analyze`/`flutter gen-l10n` are NOT executable tasks
|
||||||
|
here — they are manual/CI follow-ups, same convention as prior archived changes.
|
||||||
|
|
||||||
|
## Grounding corrections vs. spec/design (read first)
|
||||||
|
|
||||||
|
- **Root folder count test**: `test/servicios/navegacion_auto_test.dart:221-244`
|
||||||
|
currently asserts `raiz()` returns exactly 4 folders (`hasLength(4)`), a `Set` of
|
||||||
|
the 4 ids, and `raiz.last.id == idEcualizador`. This MUST become 5, same pattern
|
||||||
|
as the EQ-presets change had to update this same block. Confirmed live, not
|
||||||
|
assumed from design.
|
||||||
|
- **`raiz()` signature**: `lib/servicios/navegacion_auto.dart:171-176` currently
|
||||||
|
takes no parameters. Design's `raiz(incluirMusicaLocal: ...)` is a real signature
|
||||||
|
change — every existing call site of `raiz()` must be checked
|
||||||
|
(`lib/servicios/servicio_audio.dart:739` is the only call site found).
|
||||||
|
- **`file_actions` channel**: `MainActivity.kt:215-250` currently has exactly 3
|
||||||
|
methods (`openDirectory`, `viewDirectory`, `openFile`), all synchronous, all using
|
||||||
|
`startActivity` (never `startActivityForResult`). There is **no existing
|
||||||
|
`onActivityResult` override in this Activity** — confirmed via full-file read.
|
||||||
|
Adding `pickMusicFolder` via `startActivityForResult` requires adding an
|
||||||
|
`onActivityResult` override (or an `ActivityResultLauncher`) that does not exist
|
||||||
|
today. This is new surface on `MainActivity`, exactly as design's "Open Questions"
|
||||||
|
flags — call this out again at task level since it's the highest-risk native piece.
|
||||||
|
- **Manifest/pubspec claim — CONFIRMED correct**: `AndroidManifest.xml` (root,
|
||||||
|
1-122) has no `READ_MEDIA_AUDIO`/storage permission today, and `pubspec.yaml`
|
||||||
|
already lists `file_picker: ^8.1.7` (used elsewhere for `_SeccionGrabaciones`'s
|
||||||
|
path picker, NOT for SAF tree URIs) with no `shared_storage` or similar. Design's
|
||||||
|
"no manifest/pubspec changes" claim holds — `ACTION_OPEN_DOCUMENT_TREE` +
|
||||||
|
`takePersistableUriPermission` need no manifest entry. Flagging as verified, not
|
||||||
|
assumed.
|
||||||
|
- **Design deliberately does NOT reuse `file_picker`'s `getDirectoryPath()`** (the
|
||||||
|
pattern `_SeccionGrabaciones` uses at `pantalla_ajustes.dart:96`) for the local
|
||||||
|
music root — that API returns a plain path, not a URI with a persistable grant.
|
||||||
|
The new `_SeccionMusicaLocal` therefore calls the NEW native `pickMusicFolder`
|
||||||
|
method directly via the `file_actions` `MethodChannel`, not `FilePicker.platform`.
|
||||||
|
- **`_SeccionGrabaciones` is the closest UI precedent** (`pantalla_ajustes.dart:89-279`):
|
||||||
|
`PluriGlassSurface` card, `Row` header with icon + title, `FutureBuilder` for the
|
||||||
|
current path, `Wrap` of `OutlinedButton.icon`/`FilledButton.tonalIcon` actions,
|
||||||
|
`ScaffoldMessenger` snackbar feedback. `_SeccionMusicaLocal` should mirror this
|
||||||
|
shape (registered in the `PantallaAjustes` sections list at
|
||||||
|
`pantalla_ajustes.dart:63-83`).
|
||||||
|
- **SharedPreferences DI pattern**: `servicio_ecualizador.dart:37,54,57` — constructor
|
||||||
|
takes an optional injected `SharedPreferences? prefs`, falls back to
|
||||||
|
`SharedPreferences.getInstance()`. New code (`FuenteMusicaLocalAuto` impl / a
|
||||||
|
settings-side service) should follow this exact injectable pattern for testability.
|
||||||
|
|
||||||
|
## 1. `PistaLocal` / `NodoLocal` models (pure Dart — unit-testable)
|
||||||
|
|
||||||
|
- [x] 1.1 Create `lib/modelos/pista_local.dart` with `NodoLocal` (`documentId`,
|
||||||
|
`nombre`, `esDirectorio`) and `PistaLocal` (`documentId`, `titulo`
|
||||||
|
derived-at-construction or computed, `contentUri`) per design's minimal
|
||||||
|
Phase 1 shape — no metadata fields (artist/album/duration) per spec's
|
||||||
|
"Not in this delta".
|
||||||
|
- [x] 1.2 Unit tests for any parsing/equality helpers on these models (if added).
|
||||||
|
If the models are pure DTOs with no logic, skip — do not write tests for
|
||||||
|
getter-only classes with no behavior.
|
||||||
|
- Requirement: Local Music Browsable Tree (spec, `PistaLocal` resolution).
|
||||||
|
- Parallel: yes — no dependency on other tasks.
|
||||||
|
|
||||||
|
## 2. `esArchivoAudio` — Dart-side re-validation (pure Dart — unit-testable, TDD)
|
||||||
|
|
||||||
|
- [x] 2.1 RED: write failing tests in a new/extended test file (or
|
||||||
|
`navegacion_auto_test.dart` if colocated) for `esArchivoAudio(mime, nombre)`:
|
||||||
|
accepts `audio/*` MIME, rejects `null`/non-audio MIME even with an audio-like
|
||||||
|
extension, rejects blank/null inputs — belt-and-suspenders per design's
|
||||||
|
"Interfaces / Contracts" note (native already filters, Dart re-validates).
|
||||||
|
- [x] 2.2 GREEN: implement `esArchivoAudio` (location: `navegacion_auto.dart` or
|
||||||
|
`musica_local_auto.dart`, per task 4's file placement) to pass.
|
||||||
|
- Requirement: Local Music Browsable Tree (spec — audio files as playable items).
|
||||||
|
- Parallel: yes, can run alongside task 1.
|
||||||
|
|
||||||
|
## 3. Media-id scheme — encode/decode (pure Dart — unit-testable, TDD)
|
||||||
|
|
||||||
|
- [x] 3.1 RED: tests for `musica_local` root id, `carpeta_local:<docId>` /
|
||||||
|
`pista:<docId>` predicates and id-stripping, mirroring the existing
|
||||||
|
`esPresetMediaId`/`esCarpetaGrupo` test patterns
|
||||||
|
(`navegacion_auto_test.dart:246-264`). Include collision tests against
|
||||||
|
`emisora:`, `grupo:`, `eq_preset:` and the bare folder-id constants
|
||||||
|
(`idFavoritos`, `idTodas`, `idMisEmisoras`, `idEcualizador`) — same
|
||||||
|
collision-free requirement the existing prefixes document at
|
||||||
|
`navegacion_auto.dart:14-24,149`.
|
||||||
|
Cover the "prefix stripped by length, not by string ops that would mangle a
|
||||||
|
documentId containing `:`" case explicitly (design's stated rationale for
|
||||||
|
length-based stripping) — pick a docId fixture containing a `:` (SAF
|
||||||
|
documentIds commonly look like `primary:Music/Local`).
|
||||||
|
- [x] 3.2 GREEN: implement `idMusicaLocal`, `_prefijoCarpetaLocal`,
|
||||||
|
`_prefijoPista` constants + `esCarpetaLocalMediaId`/`esPistaMediaId`
|
||||||
|
predicates in `navegacion_auto.dart`, following the exact shape of
|
||||||
|
`_prefijoGrupo`/`esCarpetaGrupo` (`navegacion_auto.dart:149,225`) and
|
||||||
|
`_prefijoPresetEq`/`esPresetMediaId` (`navegacion_auto.dart:18,24`).
|
||||||
|
- Requirement: Local Music Browsable Tree (spec — media-id scheme, collision-free).
|
||||||
|
- Sequential: blocks tasks 5 and 7 (they route on these predicates).
|
||||||
|
|
||||||
|
## 4. `FuenteMusicaLocalAuto` abstraction + channel-backed impl
|
||||||
|
|
||||||
|
- [x] 4.1 RED: unit tests for a FAKE `FuenteMusicaLocalAuto` implementation
|
||||||
|
exercising the pure orchestration logic that will consume it (folded into
|
||||||
|
task 5/6's tests) — the interface itself
|
||||||
|
(`hayCarpetaConfigurada`/`hijos`/`uriContenidoDePista`) has no logic to
|
||||||
|
red/green in isolation; test it through its consumers.
|
||||||
|
- [x] 4.2 GREEN: create `lib/servicios/musica_local_auto.dart` with the
|
||||||
|
`FuenteMusicaLocalAuto` abstract class (design's "Interfaces / Contracts")
|
||||||
|
and a channel-backed implementation that calls the `file_actions`
|
||||||
|
`MethodChannel`'s `listAudioChildren`/`resolvePlayableUri`/
|
||||||
|
`hasPersistedPermission` methods (task 8's native methods), wrapping every
|
||||||
|
channel call in try/catch → never-throws per design (mirrors
|
||||||
|
`FuenteEmisorasAutoLocal`'s cold-start-safe try/catch shape,
|
||||||
|
`navegacion_auto.dart:421-471`).
|
||||||
|
- Requirement: Local Music Root Access and Permission Persistence (spec).
|
||||||
|
- Sequential: depends on task 8 (native method names/wire shape) for the real
|
||||||
|
impl, but the interface + a FAKE impl can be written in parallel with task 8.
|
||||||
|
|
||||||
|
## 5. `ConstructorArbolAuto` / tree extension (pure Dart — unit-testable, TDD)
|
||||||
|
|
||||||
|
- [x] 5.1 RED: extend `navegacion_auto_test.dart`'s
|
||||||
|
`group('ConstructorArbolAuto.raiz', ...)` (`:221-244`) — the count MUST
|
||||||
|
become 5, order MUST be Favoritos, Todas, Mis emisoras, **Música Local**,
|
||||||
|
Ecualizador (Música Local now 4th, Ecualizador remains last per design ADR
|
||||||
|
"local root ... placed before Ecualizador"). Add a SEPARATE test group for
|
||||||
|
the hidden-until-configured case: `raiz(incluirMusicaLocal: false)` (or
|
||||||
|
equivalent) omits the folder — 4 folders, matching the OLD assertion shape,
|
||||||
|
so the "unconfigured" case is byte-identical to pre-change behavior
|
||||||
|
(regression guard, same pattern as the empty-favorite-group precedent design
|
||||||
|
cites).
|
||||||
|
- [x] 5.2 RED: tests for `itemsLocales` (native node list → `MediaItem` mapping):
|
||||||
|
alphabetical sort, `_maxItemsCarpetaLocal = 50` truncation cap (mirror
|
||||||
|
`test/.../navegacion_auto_test.dart:398`'s `hasLength(50)` pattern for the
|
||||||
|
existing `_maxItemsPorCarpeta` cap), title = filename minus last `.ext`
|
||||||
|
(with "whole name if no dot" and "non-empty fallback constant if blank/null"
|
||||||
|
cases each as their own case), `artUriLocal` = existing `station_art_*`
|
||||||
|
rotation seeded by `documentId` via `indiceArtePara` (reuse, do not
|
||||||
|
reimplement — assert against `artUriPara`'s existing rotation constant
|
||||||
|
order, `_nombresArte` at `navegacion_auto.dart:33`).
|
||||||
|
- [x] 5.3 RED: test empty-subfolder browse returns `[]` not an error (spec
|
||||||
|
"browsing an empty subfolder").
|
||||||
|
- [x] 5.4 GREEN: implement `idMusicaLocal` addition to `raiz()`
|
||||||
|
(`navegacion_auto.dart:171-176`, now parameterized), `itemsLocales(nodos)`,
|
||||||
|
title-stripping helper, `artUriLocal(documentId)` reusing
|
||||||
|
`indiceArtePara`/`_nombresArte`.
|
||||||
|
- Requirement: Local Music Browsable Tree; Local Music Folder Item Cap;
|
||||||
|
MODIFIED "Browsable Media Tree" (spec — 5-folder root, order, cap).
|
||||||
|
- Sequential: depends on task 3 (predicates/constants) and task 1 (`NodoLocal`).
|
||||||
|
|
||||||
|
## 6. `getChildren` dispatch wiring (pure Dart — unit-testable, TDD)
|
||||||
|
|
||||||
|
- [x] 6.1 RED: tests (can live in a new test file exercising
|
||||||
|
`PluriWaveAudioHandler.getChildren` the way existing tests exercise it, or
|
||||||
|
as pure-function tests if the dispatch logic is extracted into
|
||||||
|
`navegacion_auto.dart` first — prefer extraction, matching the existing
|
||||||
|
"thin delegation" pattern at `servicio_audio.dart:731-765`) for: root
|
||||||
|
request includes/excludes Música Local per `hayCarpetaConfigurada()`;
|
||||||
|
`musica_local` id → `fuente.hijos('')`; `carpeta_local:<id>` → `fuente.hijos(id)`;
|
||||||
|
cold-start (`fuente` local source unset/errors) → `[]`, never throws,
|
||||||
|
mirroring the existing root `try/catch → const []` shape
|
||||||
|
(`servicio_audio.dart:736-764`).
|
||||||
|
- [x] 6.2 GREEN: add the new branches to `getChildren`
|
||||||
|
(`servicio_audio.dart:731-765`) — insert BEFORE the generic
|
||||||
|
`_listaParaCarpeta` fallthrough at the bottom, same branch-ordering
|
||||||
|
convention as the existing `idFavoritos`/`esCarpetaGrupo` special-cases.
|
||||||
|
- Requirement: Local Music Browsable Tree; MODIFIED "Browsable Media Tree" —
|
||||||
|
"Browse requested before app state is loaded" regression scenario.
|
||||||
|
- Sequential: depends on tasks 3, 4, 5.
|
||||||
|
|
||||||
|
## 7. `playFromMediaId` wiring for `pista:<docId>` (pure Dart — unit-testable, TDD)
|
||||||
|
|
||||||
|
- [x] 7.1 RED: tests for `reproducirPistaLocal` (or equivalently named function,
|
||||||
|
mirroring `reproducirPorMediaId`'s shape at `navegacion_auto.dart:301-325`):
|
||||||
|
resolves via `fuente.uriContenidoDePista(docId)`, builds a `MediaItem` and
|
||||||
|
delegates to an injected `reproducir` callback; stale/unknown docId
|
||||||
|
(`uriContenidoDePista` returns `null`) is a no-op — `reproducir` is never
|
||||||
|
called, no exception (spec "Unknown or stale track id"). Same fake-callback
|
||||||
|
test shape as the existing `reproducirPorMediaId` tests.
|
||||||
|
- [x] 7.2 RED: EQ regression-guard test (spec "EQ still applies to local track
|
||||||
|
playback") — asserts the local-track play path calls the SAME
|
||||||
|
`playMediaItem` injection point stations use, with no separate/bypassed
|
||||||
|
path. This can be asserted structurally (same signature shape as
|
||||||
|
`reproducirPorMediaId`, no alternate EQ seam) plus a wiring test at the
|
||||||
|
`PluriWaveAudioHandler.playFromMediaId` level confirming `pista:` routes
|
||||||
|
into `playMediaItem` exactly like the existing `emisora:` branch at
|
||||||
|
`servicio_audio.dart:805-811`.
|
||||||
|
- [x] 7.3 GREEN: implement `reproducirPistaLocal` in `navegacion_auto.dart`; wire
|
||||||
|
the `pista:` branch into `playFromMediaId`
|
||||||
|
(`servicio_audio.dart:781-820`) — insert as a branch parallel to the
|
||||||
|
existing `esPresetMediaId(...)` early-return
|
||||||
|
(`servicio_audio.dart:792-804`) and the trailing `reproducirPorMediaId`
|
||||||
|
call, preserving the existing outer try/catch (`:786-819`) so a thrown
|
||||||
|
resolution error still can't propagate from the handler.
|
||||||
|
- Requirement: Local Track Playback Reuses Existing Pipeline (spec, all 3
|
||||||
|
scenarios).
|
||||||
|
- Sequential: depends on tasks 3 and 4.
|
||||||
|
|
||||||
|
## 8. Native `file_actions` channel extension (Kotlin — static-review-only)
|
||||||
|
|
||||||
|
- [x] 8.1 STATIC REVIEW ONLY. Add `pickMusicFolder` to `MainActivity.kt`'s
|
||||||
|
`file_actions` handler (`:218-250`): launch
|
||||||
|
`Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)` via `startActivityForResult`
|
||||||
|
(NEW to this Activity — no existing `onActivityResult` override exists
|
||||||
|
today, confirmed via full-file read; this task must ADD one), call
|
||||||
|
`contentResolver.takePersistableUriPermission(uri, FLAG_GRANT_READ_URI_PERMISSION)`
|
||||||
|
on result, and return the picked tree URI (or `null` on cancel) back to
|
||||||
|
Dart via the pending `MethodChannel.Result` held across the
|
||||||
|
activity-result round trip. Follow the existing `result.success(...)` /
|
||||||
|
`Log.d(tag, "file_actions.<method> ...")` conventions used by
|
||||||
|
`openDirectory`/`viewDirectory`/`openFile`.
|
||||||
|
- [x] 8.2 STATIC REVIEW ONLY. Add `listAudioChildren(treeUri, parentDocumentId)`:
|
||||||
|
resolve the tree via `DocumentFile.fromTreeUri`, walk ONE level (lazy, per
|
||||||
|
design "never an eager tree dump"), filter files to `audio/*` MIME, return
|
||||||
|
`[{documentId, nombre, esDirectorio}]`.
|
||||||
|
- [x] 8.3 STATIC REVIEW ONLY. Add `resolvePlayableUri(treeUri, documentId)`:
|
||||||
|
resolve a leaf documentId to its playable `content://` URI.
|
||||||
|
- [x] 8.4 STATIC REVIEW ONLY. Add `hasPersistedPermission(treeUri)`: checks
|
||||||
|
`contentResolver.persistedUriPermissions` for the stored tree URI, used for
|
||||||
|
cold-start / revoked-permission detection (task 9).
|
||||||
|
- [x] 8.5 STATIC REVIEW ONLY. Register all 4 new methods in the existing `when
|
||||||
|
(call.method)` block (`:219-249`), preserving the existing
|
||||||
|
`else -> result.notImplemented()` fallthrough.
|
||||||
|
- Requirement: Local Music Root Access and Permission Persistence (spec, all 3
|
||||||
|
scenarios); Local Music Browsable Tree.
|
||||||
|
- Parallel: independent of the Dart tasks above except for wire-shape agreement
|
||||||
|
with task 4's channel-call argument/return names — coordinate field names
|
||||||
|
(`documentId`, `nombre`, `esDirectorio`) exactly between 4.2 and 8.2/8.3.
|
||||||
|
- FLAG: highest native risk in this delta — `startActivityForResult` +
|
||||||
|
`onActivityResult` is genuinely new plumbing on `MainActivity`, cannot be
|
||||||
|
runtime-verified in this pass (design's own "Open Questions" says the same).
|
||||||
|
Reviewer should pay particular attention to: result-code handling on user
|
||||||
|
cancel, and correctly returning to the SAME pending `MethodChannel.Result`
|
||||||
|
(not a stale one) if the user backgrounds the app during the picker.
|
||||||
|
|
||||||
|
## 9. Phone-side settings UI — `_SeccionMusicaLocal` (Flutter widget — manual/limited-test)
|
||||||
|
|
||||||
|
- [x] 9.1 Create `_SeccionMusicaLocal` in `pantalla_ajustes.dart`, mirroring
|
||||||
|
`_SeccionGrabaciones`'s shape (`:89-279`): `PluriGlassSurface` card,
|
||||||
|
`FutureBuilder`-driven current-folder display (or "not configured" state),
|
||||||
|
a "Choose folder" `OutlinedButton.icon` that invokes the native
|
||||||
|
`pickMusicFolder` channel method directly (NOT `FilePicker.platform` — see
|
||||||
|
"Grounding corrections"), snackbar feedback via `ScaffoldMessenger`
|
||||||
|
following the exact try/catch/snackbar shape at `:100-111`.
|
||||||
|
- [x] 9.2 Persist the picked tree URI to SharedPreferences under
|
||||||
|
`musica_local_uri`, using the injectable-prefs pattern from
|
||||||
|
`servicio_ecualizador.dart:37,54,57` (constructor-injected
|
||||||
|
`SharedPreferences?`, falls back to `.getInstance()`) — whichever
|
||||||
|
service/class owns this read/write (likely `FuenteMusicaLocalAuto`'s
|
||||||
|
concrete impl or a small dedicated settings service).
|
||||||
|
- [x] 9.3 Register `_SeccionMusicaLocal()` in `PantallaAjustes`'s section list
|
||||||
|
(`pantalla_ajustes.dart:63-83`) — placement is a phone-UI decision, not
|
||||||
|
constrained by the car's root-folder ordering; place near
|
||||||
|
`_SeccionGrabaciones` given the shared "local files" theme.
|
||||||
|
- [x] 9.4 Add new l10n keys to `lib/l10n/app_en.arb` and `lib/l10n/app_es.arb`
|
||||||
|
(folder-picker dialog title, "not configured" state text, success/error
|
||||||
|
snackbar text — mirroring `recordingsFolderDialogTitle`,
|
||||||
|
`recordingsPathUpdated`, `recordingsPathSaveError` keys). The other 12
|
||||||
|
locale `.arb` files (`app_ru.arb`, `app_zh.arb`, `app_ja.arb`, `app_pt.arb`,
|
||||||
|
`app_fr.arb`, `app_hi.arb`, `app_id.arb`, `app_it.arb`, `app_de.arb`,
|
||||||
|
`app_ar.arb`, `app_bn.arb`, plus `gen-l10n` regeneration of
|
||||||
|
`lib/l10n/gen/*`) are DEVIATED / manual follow-up — same convention as
|
||||||
|
prior archived changes, NOT an executable task here.
|
||||||
|
- Requirement: Local Music Root Access and Permission Persistence (spec, "User
|
||||||
|
picks a local music root folder").
|
||||||
|
- Sequential: depends on task 8 (channel method must exist for 9.1 to call) —
|
||||||
|
but the widget SHELL/layout can be built against a stubbed channel call in
|
||||||
|
parallel with task 8's implementation.
|
||||||
|
- Note: this is Flutter widget code with a native-channel side effect and an
|
||||||
|
actual SAF picker dialog — genuinely on-device-only verification for the
|
||||||
|
full picker flow (same as task 8's flag). The widget layout/state-management
|
||||||
|
logic itself can get light `flutter_test` widget-test coverage (folder-display
|
||||||
|
states, button presence) if useful, but the SAF round-trip cannot be unit
|
||||||
|
tested — call this out in the PR description.
|
||||||
|
|
||||||
|
## 10. Cold-start / permission-revoked safety (pure Dart — unit-testable, TDD)
|
||||||
|
|
||||||
|
- [x] 10.1 RED: tests asserting `hayCarpetaConfigurada()` returning `false` (no
|
||||||
|
folder ever picked) yields `raiz()` WITHOUT the Música Local folder — same
|
||||||
|
assertion as task 5.1's hidden-folder case, cross-referenced here for the
|
||||||
|
"never picked" scenario specifically (spec "Permission revoked or never
|
||||||
|
granted", first half).
|
||||||
|
- [x] 10.2 RED: tests asserting a FAKE `FuenteMusicaLocalAuto` whose
|
||||||
|
`hijos()`/`uriContenidoDePista()` simulate a revoked-permission failure
|
||||||
|
(channel throws or returns empty) degrade to `[]`/`null` — never throws out
|
||||||
|
of `getChildren`/`playFromMediaId` — mirroring
|
||||||
|
`FuenteEmisorasAutoLocal.favoritos()`'s try/catch → `const []` cold-start
|
||||||
|
pattern (`navegacion_auto.dart:421-431`).
|
||||||
|
- [x] 10.3 GREEN: any missing guard clauses from tasks 4/6/7 to satisfy 10.1/10.2
|
||||||
|
(should mostly already be covered if those tasks' try/catch wrapping is
|
||||||
|
done correctly — this task exists to make the safety net EXPLICIT and
|
||||||
|
independently tested, not just incidentally covered).
|
||||||
|
- Requirement: Local Music Root Access and Permission Persistence (spec,
|
||||||
|
"Permission revoked or never granted"); MODIFIED "Browsable Media Tree"
|
||||||
|
("Browse requested before app state is loaded").
|
||||||
|
- Sequential: depends on tasks 4, 5, 6, 7.
|
||||||
|
|
||||||
|
## 11. Root-folder-count regression update (pure Dart — TDD, explicit "don't forget")
|
||||||
|
|
||||||
|
- [x] 11.1 **DO NOT SKIP** — same trap as the EQ-presets change: update
|
||||||
|
`test/servicios/navegacion_auto_test.dart:221-244`'s
|
||||||
|
`group('ConstructorArbolAuto.raiz', ...)` from asserting 4 folders to 5
|
||||||
|
(configured case) — already covered by task 5.1, listed here again
|
||||||
|
standalone so it cannot be silently dropped if task 5 is split across
|
||||||
|
commits/PRs.
|
||||||
|
- Requirement: MODIFIED "Browsable Media Tree" — "Car requests the root" scenario.
|
||||||
|
- Sequential: must land in the SAME commit as task 5's `raiz()` change (a
|
||||||
|
green-but-stale test count is a false-positive regression risk otherwise).
|
||||||
|
|
||||||
|
## 12. Full regression pass (pure Dart — unit-testable, run don't write)
|
||||||
|
|
||||||
|
- [x] 12.1 Run the full `navegacion_auto_test.dart` suite plus any
|
||||||
|
`servicio_audio_test.dart`/EQ-related tests after tasks 1-11 land —
|
||||||
|
confirm no existing radio/favorite-groups/EQ-presets assertions broke from
|
||||||
|
the new 5th folder or the extended `getChildren`/`playFromMediaId`
|
||||||
|
dispatch. This is a verification run, not new test-writing — flag any
|
||||||
|
break found as a task-11-adjacent fix, not a new task.
|
||||||
|
- Requirement: all existing spec requirements (regression guard, implicit).
|
||||||
|
- Sequential: last — depends on everything above.
|
||||||
|
|
||||||
|
## Deviated / manual follow-up (not executable tasks here)
|
||||||
|
|
||||||
|
- `flutter analyze`, `flutter build`, `flutter gen-l10n` — CI/manual, same
|
||||||
|
convention as prior archived changes.
|
||||||
|
- On-device manual verification of the SAF folder-picker flow (task 8/9) — cannot
|
||||||
|
be unit tested, requires an actual Android Auto head unit or emulator + a real
|
||||||
|
device folder with audio files.
|
||||||
|
- 12 non-English/non-Spanish `.arb` locale files (task 9.4) — translation is
|
||||||
|
out of scope for this delta.
|
||||||
|
|
||||||
|
## Review Workload Forecast
|
||||||
|
|
||||||
|
**Estimated changed lines**: ~750-950 (additions + deletions), across:
|
||||||
|
|
||||||
|
| Area | File(s) | Est. lines |
|
||||||
|
|---|---|---|
|
||||||
|
| New Dart model | `lib/modelos/pista_local.dart` | ~30-50 |
|
||||||
|
| New Dart service | `lib/servicios/musica_local_auto.dart` | ~90-130 |
|
||||||
|
| Modified Dart | `lib/servicios/navegacion_auto.dart` | ~120-170 (new constants, predicates, `raiz()` signature change, `itemsLocales`, `reproducirPistaLocal`, title/art helpers) |
|
||||||
|
| Modified Dart | `lib/servicios/servicio_audio.dart` | ~40-60 (getChildren branches, playFromMediaId branch) |
|
||||||
|
| Modified Dart | `lib/pantallas/pantalla_ajustes.dart` | ~130-180 (new `_SeccionMusicaLocal` class, mirrors `_SeccionGrabaciones`'s ~190 lines but narrower scope) |
|
||||||
|
| New Kotlin | `MainActivity.kt` | ~120-170 (4 new methods + `onActivityResult` override + pending-result plumbing — genuinely new to this file) |
|
||||||
|
| Tests | `navegacion_auto_test.dart` + new test file(s) | ~180-250 |
|
||||||
|
| l10n | `app_en.arb`, `app_es.arb` (+ generated) | ~20-30 |
|
||||||
|
|
||||||
|
**Chained PRs recommended: Yes.** This is the largest and most structurally novel
|
||||||
|
change this session — it is the first delta in this project that adds NEW native
|
||||||
|
platform-channel surface (`startActivityForResult`/`onActivityResult`, absent
|
||||||
|
today) rather than extending an already-lazy, already-tested Dart dispatch pattern
|
||||||
|
alone (unlike the EQ-presets and browsable-tree changes, which were pure-Dart
|
||||||
|
extensions of existing seams). Combined with a new settings UI screen section and
|
||||||
|
a new Dart model, a single PR is very likely to exceed the 400-line budget and mixes
|
||||||
|
three independently reviewable/rollback-able concerns (pure-Dart tree logic,
|
||||||
|
native channel, phone UI).
|
||||||
|
|
||||||
|
**400-line budget risk: High.**
|
||||||
|
|
||||||
|
**Suggested slice boundaries** (if `delivery_strategy` calls for chaining):
|
||||||
|
1. Tasks 1-3 (models + `esArchivoAudio` + media-id scheme) — pure Dart, small,
|
||||||
|
independently mergeable, ~150-200 lines.
|
||||||
|
2. Tasks 4-7 (Dart orchestration: `FuenteMusicaLocalAuto` interface, tree
|
||||||
|
extension, `getChildren`/`playFromMediaId` wiring, cold-start safety, test
|
||||||
|
updates) — pure Dart, the bulk of the testable logic, ~350-450 lines. Depends
|
||||||
|
on slice 1.
|
||||||
|
3. Task 8 (native Kotlin channel extension) — static-review-only, isolated
|
||||||
|
rollback surface, ~120-170 lines. Can be built in parallel with slice 2 but
|
||||||
|
should be its OWN PR given the review-attention flag on
|
||||||
|
`startActivityForResult`/`onActivityResult`.
|
||||||
|
4. Task 9 (settings UI) — depends on slice 3's channel methods existing;
|
||||||
|
~150-210 lines including l10n.
|
||||||
|
|
||||||
|
**Decision needed before apply: Yes** — recommend `sdd-apply` be scoped to ONE
|
||||||
|
slice at a time per the orchestrator's Review Workload Guard, using the cached
|
||||||
|
`delivery_strategy`/`chain_strategy`, rather than attempting all of Phase 1 in a
|
||||||
|
single work session/PR.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Verification Report: Android Auto Local Music - Phase 1
|
||||||
|
|
||||||
|
**Verdict: PASS WITH WARNINGS**
|
||||||
|
|
||||||
|
## Test Evidence (independently re-run, not trusted from apply-progress)
|
||||||
|
|
||||||
|
```
|
||||||
|
flutter test test/servicios/navegacion_auto_test.dart test/servicios/musica_local_auto_test.dart --concurrency=1 --timeout=60s
|
||||||
|
-> 70/70 passing (66 + 4). Matches apply-progress claim exactly.
|
||||||
|
|
||||||
|
flutter test test/servicios/servicio_audio_reconnect_test.dart test/servicios/servicio_audio_session_test.dart test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_audio_eq_reapply_test.dart --concurrency=1 --timeout=60s
|
||||||
|
-> 21/21 passing. Matches apply-progress claim exactly.
|
||||||
|
|
||||||
|
flutter test test/estado/estado_radio_test.dart --concurrency=1 --timeout=60s
|
||||||
|
-> all passing (extra regression check requested by orchestrator, not part of apply-progress own 91-count claim).
|
||||||
|
```
|
||||||
|
|
||||||
|
Total: 91/91 confirmed accurate. No discrepancy found this time, unlike two prior verify passes in this session that caught inflated claims. Both test-run commands were re-executed by this verify pass independently, not copy-pasted from apply-progress.
|
||||||
|
|
||||||
|
## Spec Compliance
|
||||||
|
|
||||||
|
All ADDED/MODIFIED requirements in specs/android-auto-media/spec.md are implemented and covered by passing tests:
|
||||||
|
|
||||||
|
- Root access/permission persistence: hayCarpetaConfigurada() in FuenteMusicaLocalAutoImpl never throws, wrapped in try/catch, degrades to false on any native-channel failure (cold start, revoked permission, never-granted). Traced the actual guard clause, not a docstring claim.
|
||||||
|
- Browsable tree: 5-folder root (Favoritos, Todas, Mis emisoras, Musica Local, Ecualizador), Musica Local genuinely OMITTED (not shown empty) when incluirMusicaLocal is false, same 4-folder byte-identical shape as pre-change. Recursive carpeta_local:/pista: browsing confirmed; empty subfolder returns [], not an error.
|
||||||
|
- Local playback reuses existing pipeline: pista: branch (servicio_audio.dart:835) and station emisora: branch (servicio_audio.dart:844) both call the exact same playMediaItem method, genuine shared EQ chain, confirmed in code, not just asserted by test name. EQ regression-guard test additionally confirms no alternate/bypassed playback seam exists in reproducirPistaLocal signature.
|
||||||
|
- 50-item cap: enforced and tested with a real boundary fixture (60 shuffled nodes to 50 returned, alphabetically sorted, first two titles asserted), not just a test-name assertion.
|
||||||
|
- Root folder count test: actual current lines are 223-260 in navegacion_auto_test.dart (task grounding notes cited a stale 221-244 anchor - file grew since the apply agent grounding pass, but the underlying test update itself is correct): hasLength(5) configured case, hasLength(4) hidden-when-unconfigured case.
|
||||||
|
|
||||||
|
## Media-ID Collision Safety (verified in code, not by comment)
|
||||||
|
|
||||||
|
idMusicaLocal = "musica_local", _prefijoCarpetaLocal = "carpeta_local:", _prefijoPista = "pista:" are all structurally distinct from emisora:/grupo:/eq_preset: and the bare folder-id constants. An explicit test (esCarpetaLocalMediaId / esPistaMediaId) checks the full collision matrix plus a ":"-in-documentId fixture (primary:Music/Local) proving prefix-stripping is length-based, not string-op based.
|
||||||
|
|
||||||
|
## Self-Reported Deviations - Both Verified Genuine
|
||||||
|
|
||||||
|
1. main.dart registration (registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs)) at lib/main.dart:58) - confirmed real: without this call, _fuenteMusicaLocalGlobal stays null forever and the local-music root would never appear regardless of what the user configures. Not a false-alarm fix.
|
||||||
|
2. Title-from-documentId (_tituloDesdeDocumentId) - confirmed pure string manipulation on the SAF documentId trailing path segment (lastIndexOf("/") plus existing _tituloDesdeNombre extension-stripping). No second native/metadata round trip. Not scope creep beyond Phase 1.
|
||||||
|
|
||||||
|
## Art Rotation Reuse
|
||||||
|
|
||||||
|
artUriLocal(documentId) confirmed to reuse the exact same formula/order as artUriPara/_nombresArte/indiceArtePara (station art), just seeded by documentId instead of station uuid, not a reimplementation. Verified by a parity test asserting the rotation index matches exactly.
|
||||||
|
|
||||||
|
## Native Kotlin - Static Review (unverified at runtime, as expected)
|
||||||
|
|
||||||
|
MainActivity.kt onActivityResult override correctly calls super.onActivityResult(requestCode, resultCode, data) for non-matching request codes, preserving delegation to other Flutter plugins ActivityResultListeners, structurally correct for a FlutterActivity/AudioServiceActivity subclass. listAudioChildren uses correct real DocumentsContract APIs (buildChildDocumentsUriUsingTree, buildDocumentUriUsingTree, COLUMN_DOCUMENT_ID/COLUMN_DISPLAY_NAME/COLUMN_MIME_TYPE, MIME_TYPE_DIR). Cursor is closed via Kotlin .use{} (no resource leak). Pending MethodChannel.Result overwrite on a stale re-triggered picker call is handled (resolves the old call with null before reassigning). startActivityForResult/takePersistableUriPermission calls are wrapped in try/catch. No obviously wrong API usage found on structural read - still genuinely unverified at runtime, as both design.md and apply-progress already flag.
|
||||||
|
|
||||||
|
## Manifest / Pubspec
|
||||||
|
|
||||||
|
git diff on AndroidManifest.xml and pubspec.yaml is empty - confirmed zero changes, matching the design/tasks claim.
|
||||||
|
|
||||||
|
## Diff Size Cross-Check
|
||||||
|
|
||||||
|
git diff --stat: 1046 insertions(+) / 29 deletions(-) across 8 tracked files - matches claim exactly. New untracked files (pista_local.dart 46 lines, musica_local_auto.dart 180 lines, musica_local_auto_test.dart 41 lines) = 267 lines - matches claim exactly. Working tree confirmed with nothing committed (only unstaged/untracked changes) - consistent with the size:exception single-PR-pending-orchestrator-commit plan.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
No AI attribution, no debug prints, no TODO/FIXME/hack markers, no mojibake/encoding corruption in any touched file (literal-encoding scan run and clean).
|
||||||
|
|
||||||
|
## WARNING: l10n locale-completeness claim is unsubstantiated / likely inaccurate
|
||||||
|
|
||||||
|
apply-progress and tasks.md both claim: "12 non-English/non-Spanish .arb locale files ... DEVIATED / manual follow-up - same convention as prior archived changes."
|
||||||
|
|
||||||
|
This is not actually the established project convention. Git history shows the real precedent (pre-this-session alarm commits ffd09a2, 4819448) updates all 13 locale files in the same commit whenever new translatable strings are added. Neither of this session two prior archived changes (EQ-presets 90cd232, favorite-groups f368bcc) touched l10n files at all, because their car-tree folder labels are hardcoded Spanish, never routed through AppLocalizations (confirmed by an explicit code comment in navegacion_auto.dart). So this delta is actually the first change this session to add genuinely new translatable phone-UI strings (_SeccionMusicaLocal 7 keys), and the "same convention as prior archived changes" justification for skipping 11 locales is not backed by any real prior precedent in this session - the only actual precedent (pre-session, alarm feature) contradicts it.
|
||||||
|
|
||||||
|
app_en.arb/app_es.arb additions themselves are well-formed: valid JSON, matching keys and placeholders between the two files (verified with a JSON parse).
|
||||||
|
|
||||||
|
Recommendation: either add the missing 11 locales before archive, or have the user explicitly accept this as a new, intentional precedent, distinct from the currently-recorded (inaccurate) justification.
|
||||||
|
|
||||||
|
## No CRITICAL issues found.
|
||||||
|
|
||||||
|
## SUGGESTION (minor, non-blocking)
|
||||||
|
|
||||||
|
_SeccionMusicaLocal folder-display subtitle shows the raw SAF tree URI string (e.g. content://com.android.externalstorage.documents/tree/...) rather than a human-friendly path. _SeccionGrabaciones (the mirrored precedent) shows a real filesystem path because file_picker getDirectoryPath() returns one - SAF URIs are inherently not human-readable this way. Cosmetic only; not a spec violation (Phase 1 spec does not require a friendly display).
|
||||||
|
|
||||||
|
## Files Reviewed
|
||||||
|
|
||||||
|
- lib/servicios/navegacion_auto.dart
|
||||||
|
- lib/servicios/servicio_audio.dart
|
||||||
|
- lib/servicios/musica_local_auto.dart
|
||||||
|
- lib/modelos/pista_local.dart
|
||||||
|
- lib/main.dart
|
||||||
|
- lib/pantallas/pantalla_ajustes.dart
|
||||||
|
- android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
|
||||||
|
- test/servicios/navegacion_auto_test.dart
|
||||||
|
- test/servicios/musica_local_auto_test.dart
|
||||||
|
- lib/l10n/app_en.arb, lib/l10n/app_es.arb
|
||||||
|
- android/app/src/main/AndroidManifest.xml (diff empty)
|
||||||
|
- pubspec.yaml (diff empty)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('esArchivoAudio', () {
|
||||||
|
test('acepta cualquier MIME audio/*, en cualquier capitalización', () {
|
||||||
|
expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue);
|
||||||
|
expect(esArchivoAudio('audio/flac', 'cancion.flac'), isTrue);
|
||||||
|
expect(esArchivoAudio('AUDIO/OGG', 'cancion.ogg'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'rechaza MIME no-audio aunque el nombre tenga una extensión de '
|
||||||
|
'audio (defensa contra un MIME nativo incorrecto)',
|
||||||
|
() {
|
||||||
|
expect(esArchivoAudio('video/mp4', 'cancion.mp3'), isFalse);
|
||||||
|
expect(
|
||||||
|
esArchivoAudio('application/octet-stream', 'cancion.mp3'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(esArchivoAudio('text/plain', 'notas.mp3'), isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('rechaza MIME null, vacío o en blanco', () {
|
||||||
|
expect(esArchivoAudio(null, 'cancion.mp3'), isFalse);
|
||||||
|
expect(esArchivoAudio('', 'cancion.mp3'), isFalse);
|
||||||
|
expect(esArchivoAudio(' ', 'cancion.mp3'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'rechaza nombre null, vacío o en blanco aunque el MIME sea audio '
|
||||||
|
'válido',
|
||||||
|
() {
|
||||||
|
expect(esArchivoAudio('audio/mpeg', null), isFalse);
|
||||||
|
expect(esArchivoAudio('audio/mpeg', ''), isFalse);
|
||||||
|
expect(esArchivoAudio('audio/mpeg', ' '), isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import 'package:audio_service/audio_service.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pluriwave/modelos/emisora.dart';
|
import 'package:pluriwave/modelos/emisora.dart';
|
||||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||||
|
import 'package:pluriwave/modelos/pista_local.dart';
|
||||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
|
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -219,9 +221,41 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('ConstructorArbolAuto.raiz', () {
|
group('ConstructorArbolAuto.raiz', () {
|
||||||
test('devuelve exactamente 4 carpetas no reproducibles con los ids '
|
test(
|
||||||
'esperados, terminando en Ecualizador', () {
|
'con incluirMusicaLocal: true devuelve exactamente 5 carpetas no '
|
||||||
final raiz = ConstructorArbolAuto().raiz();
|
'reproducibles con los ids esperados, Música Local en 4to lugar, '
|
||||||
|
'terminando en Ecualizador',
|
||||||
|
() {
|
||||||
|
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true);
|
||||||
|
|
||||||
|
expect(raiz, hasLength(5));
|
||||||
|
final ids = raiz.map((item) => item.id).toSet();
|
||||||
|
expect(
|
||||||
|
ids,
|
||||||
|
equals({
|
||||||
|
ConstructorArbolAuto.idFavoritos,
|
||||||
|
ConstructorArbolAuto.idTodas,
|
||||||
|
ConstructorArbolAuto.idMisEmisoras,
|
||||||
|
ConstructorArbolAuto.idMusicaLocal,
|
||||||
|
ConstructorArbolAuto.idEcualizador,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (final item in raiz) {
|
||||||
|
expect(item.playable, isFalse);
|
||||||
|
expect(item.title, isNotEmpty);
|
||||||
|
}
|
||||||
|
expect(raiz[3].id, ConstructorArbolAuto.idMusicaLocal);
|
||||||
|
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
|
||||||
|
expect(raiz.last.playable, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'con incluirMusicaLocal: false devuelve exactamente 4 carpetas, '
|
||||||
|
'byte-idéntico al comportamiento previo al cambio (regresión) — '
|
||||||
|
'Música Local queda OCULTA, no vacía',
|
||||||
|
() {
|
||||||
|
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false);
|
||||||
|
|
||||||
expect(raiz, hasLength(4));
|
expect(raiz, hasLength(4));
|
||||||
final ids = raiz.map((item) => item.id).toSet();
|
final ids = raiz.map((item) => item.id).toSet();
|
||||||
@@ -234,13 +268,11 @@ void main() {
|
|||||||
ConstructorArbolAuto.idEcualizador,
|
ConstructorArbolAuto.idEcualizador,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
for (final item in raiz) {
|
expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal)));
|
||||||
expect(item.playable, isFalse);
|
|
||||||
expect(item.title, isNotEmpty);
|
|
||||||
}
|
|
||||||
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
|
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
|
||||||
expect(raiz.last.playable, isFalse);
|
expect(raiz.last.playable, isFalse);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('esPresetMediaId', () {
|
group('esPresetMediaId', () {
|
||||||
@@ -263,6 +295,387 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('esCarpetaLocalMediaId / esPistaMediaId', () {
|
||||||
|
test(
|
||||||
|
'reconocen sus prefijos, rechazan el resto y no colisionan entre sí '
|
||||||
|
'ni con emisora:/grupo:/eq_preset:/los ids de carpeta fijos',
|
||||||
|
() {
|
||||||
|
final builder = ConstructorArbolAuto();
|
||||||
|
|
||||||
|
expect(builder.esCarpetaLocalMediaId('carpeta_local:doc1'), isTrue);
|
||||||
|
expect(builder.esCarpetaLocalMediaId('carpeta_local:'), isTrue);
|
||||||
|
expect(esPistaMediaId('pista:doc1'), isTrue);
|
||||||
|
expect(esPistaMediaId('pista:'), isTrue);
|
||||||
|
|
||||||
|
final noLocales = <String>[
|
||||||
|
'emisora:x',
|
||||||
|
'grupo:g1',
|
||||||
|
'eq_preset:Rock',
|
||||||
|
ConstructorArbolAuto.idFavoritos,
|
||||||
|
ConstructorArbolAuto.idTodas,
|
||||||
|
ConstructorArbolAuto.idMisEmisoras,
|
||||||
|
ConstructorArbolAuto.idEcualizador,
|
||||||
|
ConstructorArbolAuto.idMusicaLocal,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
for (final id in noLocales) {
|
||||||
|
expect(
|
||||||
|
builder.esCarpetaLocalMediaId(id),
|
||||||
|
isFalse,
|
||||||
|
reason: 'esCarpetaLocalMediaId($id) debería ser false',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
esPistaMediaId(id),
|
||||||
|
isFalse,
|
||||||
|
reason: 'esPistaMediaId($id) debería ser false',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// carpeta_local: y pista: nunca colisionan entre sí.
|
||||||
|
expect(builder.esCarpetaLocalMediaId('pista:doc1'), isFalse);
|
||||||
|
expect(esPistaMediaId('carpeta_local:doc1'), isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'idCarpetaLocalDesde recorta por longitud, preservando un documentId '
|
||||||
|
'que contiene ":" (SAF documentIds reales, p.ej. '
|
||||||
|
'"primary:Music/Local")',
|
||||||
|
() {
|
||||||
|
final builder = ConstructorArbolAuto();
|
||||||
|
const docId = 'primary:Music/Local';
|
||||||
|
|
||||||
|
expect(builder.idCarpetaLocalDesde('carpeta_local:$docId'), docId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('artUriLocal', () {
|
||||||
|
test(
|
||||||
|
'reproduce la misma rotación que artUriPara/indiceArtePara, sembrada '
|
||||||
|
'por documentId en vez de por uuid',
|
||||||
|
() {
|
||||||
|
const documentIds = ['doc-a', 'doc-b', 'doc-c', 'doc-d'];
|
||||||
|
const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova'];
|
||||||
|
|
||||||
|
for (final docId in documentIds) {
|
||||||
|
final indice = indiceArtePara(docId);
|
||||||
|
expect(
|
||||||
|
artUriLocal(docId),
|
||||||
|
'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||||
|
'station_art_${nombresPorIndice[indice]}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ConstructorArbolAuto.itemsLocales', () {
|
||||||
|
test('ordena alfabéticamente por nombre y capea a 50', () {
|
||||||
|
final nodos = List.generate(
|
||||||
|
60,
|
||||||
|
(i) => NodoLocal(
|
||||||
|
documentId: 'doc-$i',
|
||||||
|
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
|
||||||
|
esDirectorio: false,
|
||||||
|
),
|
||||||
|
)..shuffle();
|
||||||
|
|
||||||
|
final items = ConstructorArbolAuto().itemsLocales(nodos);
|
||||||
|
|
||||||
|
expect(items, hasLength(50));
|
||||||
|
expect(items.first.title, 'cancion_00');
|
||||||
|
expect(items[1].title, 'cancion_01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subcarpeta vacía devuelve lista vacía, no un error', () {
|
||||||
|
expect(ConstructorArbolAuto().itemsLocales(const []), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'una carpeta mapea a un item no reproducible carpeta_local:<id> con '
|
||||||
|
'el nombre crudo (sin recortar extensión)',
|
||||||
|
() {
|
||||||
|
const nodo = NodoLocal(
|
||||||
|
documentId: 'doc-carpeta',
|
||||||
|
nombre: 'Mi Carpeta',
|
||||||
|
esDirectorio: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||||
|
|
||||||
|
expect(item.id, 'carpeta_local:doc-carpeta');
|
||||||
|
expect(item.playable, isFalse);
|
||||||
|
expect(item.title, 'Mi Carpeta');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'un archivo mapea a un item reproducible pista:<id> con título = '
|
||||||
|
'nombre sin la última extensión',
|
||||||
|
() {
|
||||||
|
const nodo = NodoLocal(
|
||||||
|
documentId: 'doc-pista',
|
||||||
|
nombre: 'Cancion Genial.mp3',
|
||||||
|
esDirectorio: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||||
|
|
||||||
|
expect(item.id, 'pista:doc-pista');
|
||||||
|
expect(item.playable, isTrue);
|
||||||
|
expect(item.title, 'Cancion Genial');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('un archivo sin punto en el nombre conserva el nombre completo', () {
|
||||||
|
const nodo = NodoLocal(
|
||||||
|
documentId: 'doc-sin-ext',
|
||||||
|
nombre: 'CancionSinExtension',
|
||||||
|
esDirectorio: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||||
|
|
||||||
|
expect(item.title, 'CancionSinExtension');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'un archivo con nombre en blanco cae al título de reserva, nunca '
|
||||||
|
'queda vacío',
|
||||||
|
() {
|
||||||
|
for (final nombre in ['', ' ']) {
|
||||||
|
final nodo = NodoLocal(
|
||||||
|
documentId: 'doc-blanco',
|
||||||
|
nombre: nombre,
|
||||||
|
esDirectorio: false,
|
||||||
|
);
|
||||||
|
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||||
|
expect(item.title, isNotEmpty);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'un archivo cuyo nombre es solo la extensión (punto en posición 0, '
|
||||||
|
'p.ej. ".mp3") conserva el nombre completo en vez de recortar a '
|
||||||
|
'blanco',
|
||||||
|
() {
|
||||||
|
const nodo = NodoLocal(
|
||||||
|
documentId: 'doc-oculto',
|
||||||
|
nombre: '.mp3',
|
||||||
|
esDirectorio: false,
|
||||||
|
);
|
||||||
|
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||||
|
expect(item.title, '.mp3');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'el artUri de un archivo usa la misma rotación que artUriLocal, '
|
||||||
|
'sembrada con el documentId',
|
||||||
|
() {
|
||||||
|
const nodo = NodoLocal(
|
||||||
|
documentId: 'doc-arte',
|
||||||
|
nombre: 'Cancion.mp3',
|
||||||
|
esDirectorio: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||||
|
|
||||||
|
expect(item.artUri.toString(), artUriLocal('doc-arte'));
|
||||||
|
expect(
|
||||||
|
item.artUri.toString(),
|
||||||
|
startsWith(
|
||||||
|
'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||||
|
'station_art_',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('hijosMusicaLocal', () {
|
||||||
|
test(
|
||||||
|
"id musica_local delega en fuente.hijos('') y mapea vía itemsLocales",
|
||||||
|
() async {
|
||||||
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||||
|
hijosPorDocId: {
|
||||||
|
'': const [
|
||||||
|
NodoLocal(
|
||||||
|
documentId: 'd1',
|
||||||
|
nombre: 'Cancion.mp3',
|
||||||
|
esDirectorio: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final items = await hijosMusicaLocal(
|
||||||
|
ConstructorArbolAuto.idMusicaLocal,
|
||||||
|
fuente: fuente,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(items, isNotNull);
|
||||||
|
expect(items!.single.id, 'pista:d1');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'id carpeta_local:<id> recorta el prefijo y delega en '
|
||||||
|
'fuente.hijos(id)',
|
||||||
|
() async {
|
||||||
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||||
|
hijosPorDocId: {
|
||||||
|
'sub1': const [
|
||||||
|
NodoLocal(
|
||||||
|
documentId: 'd2',
|
||||||
|
nombre: 'Otra.mp3',
|
||||||
|
esDirectorio: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final items = await hijosMusicaLocal(
|
||||||
|
'carpeta_local:sub1',
|
||||||
|
fuente: fuente,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(items, isNotNull);
|
||||||
|
expect(items!.single.id, 'pista:d2');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'un id que no es de música local devuelve null (deja pasar al '
|
||||||
|
'siguiente branch del caller)',
|
||||||
|
() async {
|
||||||
|
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||||
|
|
||||||
|
expect(await hijosMusicaLocal('favoritos', fuente: fuente), isNull);
|
||||||
|
expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'fuente null (cold-start, nunca registrada) devuelve lista vacía '
|
||||||
|
'para un id de música local válido, no null y sin lanzar',
|
||||||
|
() async {
|
||||||
|
final resultado = await hijosMusicaLocal(
|
||||||
|
ConstructorArbolAuto.idMusicaLocal,
|
||||||
|
fuente: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultado, isNotNull);
|
||||||
|
expect(resultado, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'una fuente que lanza (permiso revocado) degrada a lista vacía, sin '
|
||||||
|
'propagar la excepción',
|
||||||
|
() async {
|
||||||
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||||
|
errorEnHijos: Exception('permiso revocado'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final resultado = await hijosMusicaLocal(
|
||||||
|
ConstructorArbolAuto.idMusicaLocal,
|
||||||
|
fuente: fuente,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultado, isNotNull);
|
||||||
|
expect(resultado, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('reproducirPistaLocal', () {
|
||||||
|
test(
|
||||||
|
'resuelve el content uri y delega a reproducir con un MediaItem '
|
||||||
|
'reproducible',
|
||||||
|
() async {
|
||||||
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||||
|
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
||||||
|
);
|
||||||
|
MediaItem? recibido;
|
||||||
|
|
||||||
|
await reproducirPistaLocal(
|
||||||
|
'pista:doc1',
|
||||||
|
fuente: fuente,
|
||||||
|
reproducir: (item) async {
|
||||||
|
recibido = item;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recibido, isNotNull);
|
||||||
|
expect(recibido!.id, 'content://provider/doc1');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'id obsoleto/desconocido (uriContenidoDePista devuelve null) no '
|
||||||
|
'llama a reproducir ni lanza excepción',
|
||||||
|
() async {
|
||||||
|
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||||
|
var llamadas = 0;
|
||||||
|
|
||||||
|
await reproducirPistaLocal(
|
||||||
|
'pista:doc-fantasma',
|
||||||
|
fuente: fuente,
|
||||||
|
reproducir: (item) async {
|
||||||
|
llamadas++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(llamadas, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('id sin el prefijo pista: es un no-op, no llama a reproducir', () async {
|
||||||
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||||
|
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
||||||
|
);
|
||||||
|
var llamadas = 0;
|
||||||
|
|
||||||
|
await reproducirPistaLocal(
|
||||||
|
'emisora:doc1',
|
||||||
|
fuente: fuente,
|
||||||
|
reproducir: (item) async {
|
||||||
|
llamadas++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(llamadas, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'regresión EQ (estructural): reproducirPistaLocal expone el mismo '
|
||||||
|
'seam `reproducir` que reproducirPorMediaId usa para playMediaItem — '
|
||||||
|
'no existe un parámetro/seam alternativo para música local, por lo '
|
||||||
|
'que la reproducción local pasa por la MISMA cadena de EQ',
|
||||||
|
() async {
|
||||||
|
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||||
|
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
||||||
|
);
|
||||||
|
final llamadasReproducir = <MediaItem>[];
|
||||||
|
|
||||||
|
await reproducirPistaLocal(
|
||||||
|
'pista:doc1',
|
||||||
|
fuente: fuente,
|
||||||
|
reproducir: (item) async => llamadasReproducir.add(item),
|
||||||
|
// NOTE: no existe ningún parámetro alternativo de reproducción —
|
||||||
|
// esa ausencia ES la prueba de que no hay un camino separado que
|
||||||
|
// evite el EQ (mismo patrón que el test estructural de
|
||||||
|
// aplicarPresetPorMediaId).
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(llamadasReproducir, hasLength(1));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
group('ConstructorArbolAuto.itemPresetEq', () {
|
group('ConstructorArbolAuto.itemPresetEq', () {
|
||||||
test('mapea un PresetEcualizador a un item reproducible con id '
|
test('mapea un PresetEcualizador a un item reproducible con id '
|
||||||
'eq_preset:<nombre>', () {
|
'eq_preset:<nombre>', () {
|
||||||
@@ -890,3 +1303,38 @@ class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
|
|||||||
List<GrupoFavoritos>? grupos,
|
List<GrupoFavoritos>? grupos,
|
||||||
}) {}
|
}) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||||
|
_FakeFuenteMusicaLocalAuto({
|
||||||
|
bool configurada = true,
|
||||||
|
Map<String, List<NodoLocal>>? hijosPorDocId,
|
||||||
|
Map<String, String?>? uriPorDocId,
|
||||||
|
Object? errorEnHijos,
|
||||||
|
Object? errorEnUriContenido,
|
||||||
|
}) : _configurada = configurada,
|
||||||
|
_hijosPorDocId = hijosPorDocId ?? const {},
|
||||||
|
_uriPorDocId = uriPorDocId ?? const {},
|
||||||
|
_errorEnHijos = errorEnHijos,
|
||||||
|
_errorEnUriContenido = errorEnUriContenido;
|
||||||
|
|
||||||
|
final bool _configurada;
|
||||||
|
final Map<String, List<NodoLocal>> _hijosPorDocId;
|
||||||
|
final Map<String, String?> _uriPorDocId;
|
||||||
|
final Object? _errorEnHijos;
|
||||||
|
final Object? _errorEnUriContenido;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hayCarpetaConfigurada() async => _configurada;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<NodoLocal>> hijos(String documentId) async {
|
||||||
|
if (_errorEnHijos != null) throw _errorEnHijos;
|
||||||
|
return _hijosPorDocId[documentId] ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> uriContenidoDePista(String documentId) async {
|
||||||
|
if (_errorEnUriContenido != null) throw _errorEnUriContenido;
|
||||||
|
return _uriPorDocId[documentId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user