From 6ae7e378c4da7e6a9b9201c6a91379e699f19eef Mon Sep 17 00:00:00 2001 From: freetlab Date: Sun, 19 Jul 2026 20:30:50 +0200 Subject: [PATCH] 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. --- .../es/freetimelab/pluriwave/MainActivity.kt | 193 +++++++ lib/l10n/app_ar.arb | 15 +- lib/l10n/app_bn.arb | 15 +- lib/l10n/app_de.arb | 15 +- lib/l10n/app_en.arb | 15 +- lib/l10n/app_es.arb | 15 +- lib/l10n/app_fr.arb | 15 +- lib/l10n/app_hi.arb | 15 +- lib/l10n/app_id.arb | 15 +- lib/l10n/app_it.arb | 15 +- lib/l10n/app_ja.arb | 15 +- lib/l10n/app_pt.arb | 15 +- lib/l10n/app_ru.arb | 15 +- lib/l10n/app_zh.arb | 15 +- lib/main.dart | 8 + lib/modelos/pista_local.dart | 46 ++ lib/pantallas/pantalla_ajustes.dart | 115 ++++ lib/servicios/musica_local_auto.dart | 180 +++++++ lib/servicios/navegacion_auto.dart | 203 +++++++- lib/servicios/servicio_audio.dart | 36 +- .../apply-progress.md | 170 ++++++ .../android-auto-local-music/design.md | 120 +++++ .../android-auto-local-music/proposal.md | 71 +++ .../specs/android-auto-media/spec.md | 128 +++++ .../changes/android-auto-local-music/tasks.md | 381 ++++++++++++++ .../android-auto-local-music/verify-report.md | 88 ++++ test/servicios/musica_local_auto_test.dart | 41 ++ test/servicios/navegacion_auto_test.dart | 490 +++++++++++++++++- 28 files changed, 2425 insertions(+), 40 deletions(-) create mode 100644 lib/modelos/pista_local.dart create mode 100644 lib/servicios/musica_local_auto.dart create mode 100644 openspec/changes/android-auto-local-music/apply-progress.md create mode 100644 openspec/changes/android-auto-local-music/design.md create mode 100644 openspec/changes/android-auto-local-music/proposal.md create mode 100644 openspec/changes/android-auto-local-music/specs/android-auto-media/spec.md create mode 100644 openspec/changes/android-auto-local-music/tasks.md create mode 100644 openspec/changes/android-auto-local-music/verify-report.md create mode 100644 test/servicios/musica_local_auto_test.dart diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 33d5835..8b3ee21 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -38,6 +38,7 @@ class MainActivity : AudioServiceActivity() { private val visualizerPermissionRequestCode = 4821 private val notificationPermissionRequestCode = 4822 private val bluetoothConnectPermissionRequestCode = 4823 + private val pickMusicFolderRequestCode = 4824 private val bluetoothMacPlaceholder = "02:00:00:00:00:00" private var visualizer: Visualizer? = null private var pendingSink: EventChannel.EventSink? = null @@ -45,6 +46,13 @@ class MainActivity : AudioServiceActivity() { private var alarmMethodChannel: MethodChannel? = null private val mainHandler = Handler(Looper.getMainLooper()) + // Local-music SAF folder picker (android-auto-local-music, static + // review only — see file_actions.pickMusicFolder / onActivityResult): + // the MethodChannel.Result held across the startActivityForResult round + // trip, so the eventual onActivityResult callback can respond to the + // SAME pending Dart call instead of a stale one. + private var pendingMusicFolderResult: MethodChannel.Result? = null + // Audio devices channel state private var audioDevicesSink: EventChannel.EventSink? = null private var audioDeviceCallback: AudioDeviceCallback? = null @@ -245,11 +253,196 @@ class MainActivity : AudioServiceActivity() { result.success(openFile(path, mimeType)) } } + // ---- android-auto-local-music (static review only) ---- + "pickMusicFolder" -> { + Log.d(tag, "file_actions.pickMusicFolder launching picker") + // A stale pending call (e.g. the user backgrounded the + // app mid-picker and re-triggered it) resolves as + // cancelled first, so no Dart-side `Future` is left + // dangling and `pendingMusicFolderResult` always points + // at the LATEST call by the time onActivityResult fires. + pendingMusicFolderResult?.success(null) + pendingMusicFolderResult = result + try { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + @Suppress("DEPRECATION") + startActivityForResult(intent, pickMusicFolderRequestCode) + } catch (error: Throwable) { + Log.e(tag, "file_actions.pickMusicFolder launch failed", error) + pendingMusicFolderResult?.success(null) + pendingMusicFolderResult = null + } + } + "listAudioChildren" -> { + val treeUri = call.argument("treeUri") + val parentDocumentId = call.argument("parentDocumentId") ?: "" + Log.d( + tag, + "file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId" + ) + if (treeUri.isNullOrBlank()) { + result.success(emptyList>()) + } else { + result.success(listAudioChildren(treeUri, parentDocumentId)) + } + } + "resolvePlayableUri" -> { + val treeUri = call.argument("treeUri") + val documentId = call.argument("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("treeUri") + Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri") + result.success( + if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri) + ) + } else -> result.notImplemented() } } } + /** + * Handles the [pickMusicFolderRequestCode] round trip from + * `file_actions.pickMusicFolder` (android-auto-local-music, static + * review only — no existing `onActivityResult` override existed on this + * Activity before this change). On a successful pick, persists the + * granted read permission via [android.content.ContentResolver.takePersistableUriPermission] + * and resolves the pending [MethodChannel.Result] with the tree URI + * string; on cancel, missing data, or a persistence failure, resolves + * with `null` instead of throwing. Any other request code is delegated + * to `super` untouched. + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == pickMusicFolderRequestCode) { + val pending = pendingMusicFolderResult + pendingMusicFolderResult = null + val treeUri = data?.data + if (resultCode == RESULT_OK && treeUri != null) { + try { + contentResolver.takePersistableUriPermission( + treeUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + Log.d(tag, "file_actions.pickMusicFolder picked uri=$treeUri") + pending?.success(treeUri.toString()) + } catch (error: Throwable) { + Log.e( + tag, + "file_actions.pickMusicFolder takePersistableUriPermission failed", + error + ) + pending?.success(null) + } + } else { + Log.d( + tag, + "file_actions.pickMusicFolder cancelled or no data resultCode=$resultCode" + ) + pending?.success(null) + } + return + } + super.onActivityResult(requestCode, resultCode, data) + } + + /** + * Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music, + * static review only — Design "Lazy per-folder enumeration, never an + * eager tree dump"): [parentDocumentId] blank means the tree root + * itself, otherwise the given subfolder's documentId. Filters files to + * `audio/*` MIME at the native layer (lean payload); each returned row + * also carries `mime` so the Dart side can re-validate via + * `esArchivoAudio` (defense-in-depth). Any query failure degrades to an + * empty list rather than throwing. + */ + private fun listAudioChildren(treeUri: String, parentDocumentId: String): List> { + 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>() + contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> + val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + while (cursor.moveToNext()) { + val documentId = cursor.getString(idxDocId) ?: continue + val nombre = cursor.getString(idxNombre) ?: continue + val mime = cursor.getString(idxMime) ?: "" + val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR + if (!esDirectorio && !mime.startsWith("audio/")) continue + resultado.add( + mapOf( + "documentId" to documentId, + "nombre" to nombre, + "esDirectorio" to esDirectorio, + "mime" to mime + ) + ) + } + } + resultado + } catch (error: Throwable) { + Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error) + emptyList() + } + } + + /** + * Resolves a leaf [documentId] within [treeUri] to its playable + * `content://` URI (android-auto-local-music, static review only). + * Returns `null` on any failure instead of throwing. + */ + private fun resolvePlayableUri(treeUri: String, documentId: String): String? { + return try { + val parsedTree = Uri.parse(treeUri) + DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString() + } catch (error: Throwable) { + Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error) + null + } + } + + /** + * Checks whether [treeUri]'s read permission is still among + * [android.content.ContentResolver.getPersistedUriPermissions] + * (android-auto-local-music, static review only) — used for cold-start + * / revoked-permission detection (Spec "Permission revoked or never + * granted"). Returns `false` (never throws) on a malformed [treeUri] or + * any other failure. + */ + private fun hasPersistedPermission(treeUri: String): Boolean { + return try { + val parsed = Uri.parse(treeUri) + contentResolver.persistedUriPermissions.any { it.uri == parsed && it.isReadPermission } + } catch (error: Throwable) { + Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error) + false + } + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 20fb74f..367edc7 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "اسم الجهاز", "eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة", "eqDeviceNameConfirm": "حفظ", - "eqDeviceConnected": "متصل" + "eqDeviceConnected": "متصل", + "localMusicSectionTitle": "الموسيقى المحلية (Android Auto)", + "localMusicSectionDescription": "اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.", + "localMusicFolderNotConfigured": "لم يتم تحديد مجلد", + "localMusicFolderTitle": "مجلد الموسيقى المحلية", + "localMusicChoosePath": "اختيار مجلد", + "localMusicChangePath": "تغيير المجلد", + "localMusicFolderUpdated": "تم تحديث مجلد الموسيقى المحلية", + "localMusicFolderSaveError": "تعذّر حفظ المجلد: {error}", + "@localMusicFolderSaveError": { + "placeholders": { + "error": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 0fe4dc1..f94955c 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "ডিভাইসের নাম", "eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার", "eqDeviceNameConfirm": "সংরক্ষণ করুন", - "eqDeviceConnected": "সংযুক্ত" + "eqDeviceConnected": "সংযুক্ত", + "localMusicSectionTitle": "স্থানীয় সঙ্গীত (Android Auto)", + "localMusicSectionDescription": "গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।", + "localMusicFolderNotConfigured": "কোনো ফোল্ডার নির্বাচিত হয়নি", + "localMusicFolderTitle": "স্থানীয় সঙ্গীত ফোল্ডার", + "localMusicChoosePath": "ফোল্ডার বেছে নিন", + "localMusicChangePath": "ফোল্ডার পরিবর্তন করুন", + "localMusicFolderUpdated": "স্থানীয় সঙ্গীত ফোল্ডার আপডেট হয়েছে", + "localMusicFolderSaveError": "ফোল্ডার সংরক্ষণ করা যায়নি: {error}", + "@localMusicFolderSaveError": { + "placeholders": { + "error": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b408de8..b82f8f7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "Gerätename", "eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher", "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": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ecd6674..92422b6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "Device name", "eqDeviceNameHint": "e.g. Living Room Speaker", "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": {} + } + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 58ad8ed..e536378 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -619,5 +619,18 @@ "eqDeviceNameLabel": "Nombre del dispositivo", "eqDeviceNameHint": "Ej: Altavoz del living", "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": {} + } + } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7f7f173..bc4d7f1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "Nom de l'appareil", "eqDeviceNameHint": "ex. : Enceinte salon", "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": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 49e89c2..51a8768 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "डिवाइस का नाम", "eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर", "eqDeviceNameConfirm": "सहेजें", - "eqDeviceConnected": "कनेक्टेड" + "eqDeviceConnected": "कनेक्टेड", + "localMusicSectionTitle": "स्थानीय संगीत (Android Auto)", + "localMusicSectionDescription": "गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।", + "localMusicFolderNotConfigured": "कोई फ़ोल्डर चुना नहीं गया", + "localMusicFolderTitle": "स्थानीय संगीत फ़ोल्डर", + "localMusicChoosePath": "फ़ोल्डर चुनें", + "localMusicChangePath": "फ़ोल्डर बदलें", + "localMusicFolderUpdated": "स्थानीय संगीत फ़ोल्डर अपडेट हुआ", + "localMusicFolderSaveError": "फ़ोल्डर सहेजा नहीं जा सका: {error}", + "@localMusicFolderSaveError": { + "placeholders": { + "error": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 910ceca..3e80b92 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "Nama perangkat", "eqDeviceNameHint": "Mis: Speaker ruang tamu", "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": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 73dd0e3..cd21df7 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "Nome del dispositivo", "eqDeviceNameHint": "Es: Cassa del salotto", "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": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index fe0a69f..8f07918 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "デバイス名", "eqDeviceNameHint": "例:リビングのスピーカー", "eqDeviceNameConfirm": "保存", - "eqDeviceConnected": "接続中" + "eqDeviceConnected": "接続中", + "localMusicSectionTitle": "ローカル音楽(Android Auto)", + "localMusicSectionDescription": "この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。", + "localMusicFolderNotConfigured": "フォルダーが選択されていません", + "localMusicFolderTitle": "ローカル音楽フォルダー", + "localMusicChoosePath": "フォルダーを選択", + "localMusicChangePath": "フォルダーを変更", + "localMusicFolderUpdated": "ローカル音楽フォルダーを更新しました", + "localMusicFolderSaveError": "フォルダーを保存できませんでした: {error}", + "@localMusicFolderSaveError": { + "placeholders": { + "error": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 90146cc..760f78b 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "Nome do dispositivo", "eqDeviceNameHint": "Ex: Caixa da sala", "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": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f0672cf..6fc3abe 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "Название устройства", "eqDeviceNameHint": "Напр.: Колонка в гостиной", "eqDeviceNameConfirm": "Сохранить", - "eqDeviceConnected": "Подключено" + "eqDeviceConnected": "Подключено", + "localMusicSectionTitle": "Локальная музыка (Android Auto)", + "localMusicSectionDescription": "Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.", + "localMusicFolderNotConfigured": "Папка не выбрана", + "localMusicFolderTitle": "Папка локальной музыки", + "localMusicChoosePath": "Выбрать папку", + "localMusicChangePath": "Изменить папку", + "localMusicFolderUpdated": "Папка локальной музыки обновлена", + "localMusicFolderSaveError": "Не удалось сохранить папку: {error}", + "@localMusicFolderSaveError": { + "placeholders": { + "error": {} + } + } } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 95ddef1..b788e4f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -656,5 +656,18 @@ "eqDeviceNameLabel": "设备名称", "eqDeviceNameHint": "例:客厅音箱", "eqDeviceNameConfirm": "保存", - "eqDeviceConnected": "已连接" + "eqDeviceConnected": "已连接", + "localMusicSectionTitle": "本地音乐(Android Auto)", + "localMusicSectionDescription": "选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。", + "localMusicFolderNotConfigured": "未选择文件夹", + "localMusicFolderTitle": "本地音乐文件夹", + "localMusicChoosePath": "选择文件夹", + "localMusicChangePath": "更改文件夹", + "localMusicFolderUpdated": "本地音乐文件夹已更新", + "localMusicFolderSaveError": "无法保存文件夹:{error}", + "@localMusicFolderSaveError": { + "placeholders": { + "error": {} + } + } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5d189cc..cf76597 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'servicios/musica_local_auto.dart'; import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio_session.dart'; @@ -49,6 +50,13 @@ Future main() async { final fuenteAuto = FuenteEmisorasAutoLocal(); 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 // radio; headphones unplugged pauses it. final sesionAudio = ServicioAudioSession(objetivo: handler); diff --git a/lib/modelos/pista_local.dart b/lib/modelos/pista_local.dart new file mode 100644 index 0000000..2b34bd6 --- /dev/null +++ b/lib/modelos/pista_local.dart @@ -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; +} diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 03b9a0f..5ebae2d 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -18,6 +18,7 @@ import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/preset_ecualizador.dart'; +import '../servicios/musica_local_auto.dart'; import '../widgets/ecualizador_widget.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; @@ -66,6 +67,8 @@ class _AjustesContent extends StatelessWidget { SizedBox(height: 12), _SeccionGrabaciones(), SizedBox(height: 12), + _SeccionMusicaLocal(), + SizedBox(height: 12), _SeccionTimerSueno(), SizedBox(height: 12), _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 _carpetaActual; + + @override + void initState() { + super.initState(); + _carpetaActual = _fuente.carpetaActual(); + } + + Future _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( + 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( + 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 { const _SeccionTimerSueno(); diff --git a/lib/servicios/musica_local_auto.dart b/lib/servicios/musica_local_auto.dart new file mode 100644 index 0000000..75146fa --- /dev/null +++ b/lib/servicios/musica_local_auto.dart @@ -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 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> 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 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 _resolverPrefs() async => + _prefs ?? SharedPreferences.getInstance(); + + Future _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 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 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 elegirCarpeta() async { + try { + final uri = await _canal.invokeMethod('pickMusicFolder'); + if (uri == null || uri.isEmpty) return null; + await guardarCarpeta(uri); + return uri; + } catch (_) { + return null; + } + } + + @override + Future hayCarpetaConfigurada() async { + try { + final uri = await _uriPersistida(); + if (uri == null || uri.isEmpty) return false; + final valido = await _canal.invokeMethod( + '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> hijos(String documentId) async { + try { + final uri = await _uriPersistida(); + if (uri == null || uri.isEmpty) return const []; + final crudos = await _canal.invokeMethod>( + 'listAudioChildren', + {'treeUri': uri, 'parentDocumentId': documentId}, + ); + if (crudos == null) return const []; + return crudos + .whereType>() + .map(_nodoDesdeMapa) + .whereType() + .toList(); + } catch (_) { + // Cold-start / revoked-permission safety (Spec "Permission revoked or + // never granted", "Browse requested before app state is loaded"). + return const []; + } + } + + @override + Future uriContenidoDePista(String documentId) async { + try { + final uri = await _uriPersistida(); + if (uri == null || uri.isEmpty) return null; + return await _canal.invokeMethod('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 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, + ); + } +} diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 5013633..3e34908 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -7,7 +7,9 @@ import 'package:path_provider/path_provider.dart'; import '../estado/orden_emisoras.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; +import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; +import 'musica_local_auto.dart'; import 'persistencia_tolerante.dart'; import 'servicio_favoritos.dart'; @@ -23,6 +25,17 @@ const _prefijoPresetEq = 'eq_preset:'; /// predicate. 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 /// **verbatim** (same formula, same order) from /// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367) @@ -141,6 +154,13 @@ class ConstructorArbolAuto { /// generic station-list `hijos()` path. 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 _maxItemsPorCarpeta = 50; @@ -148,12 +168,23 @@ class ConstructorArbolAuto { /// free against [_prefijoEmisora] and the bare folder id constants above. 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 /// "group-folder ordering and cap"): a folder tap costs more driver /// attention than a station scroll, so this is tunable independently of /// [_maxItemsPorCarpeta]. 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 /// (1) for the root's folders, grid (2) for playable station items. static const _contentStyleLista = { @@ -164,14 +195,21 @@ class ConstructorArbolAuto { 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2, }; - /// The 4 root folders (Favoritos, Todas las emisoras, Mis emisoras, - /// Ecualizador), all non-playable. `Ecualizador` is deliberately LAST - /// (Design ADR-2): content-browsing folders are the primary car task and - /// stay first, the EQ tool trails them. - List raiz() => [ + /// The root folders (Favoritos, Todas las emisoras, Mis emisoras, + /// optionally Música Local, Ecualizador), all non-playable. `Ecualizador` + /// is deliberately LAST (Design ADR-2): content-browsing folders are the + /// primary car task and stay first, the EQ tool trails them. `Música + /// 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 raiz({required bool incluirMusicaLocal}) => [ _carpeta(idFavoritos, 'Favoritos'), _carpeta(idTodas, 'Todas las emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'), + if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), _carpeta(idEcualizador, 'Ecualizador'), ]; @@ -229,6 +267,43 @@ class ConstructorArbolAuto { MediaItem itemGrupo(GrupoFavoritos g) => _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:` items with their raw name; files map to playable + /// `pista:` 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 itemsLocales(List 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 /// `eq_preset:` (Design ADR-1). MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem( @@ -324,6 +399,124 @@ Future reproducirPorMediaId( 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:` 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?> 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:` 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 reproducirPistaLocal( + String id, { + required FuenteMusicaLocalAuto fuente, + required Future 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:` [id] to the matching [PresetEcualizador] /// in [presets] by exact name (Design ADR-1, mirrors /// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix, diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 52aacf4..b3e23f9 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -11,6 +11,7 @@ import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/preset_ecualizador.dart'; import 'controlador_reconexion.dart'; +import 'musica_local_auto.dart'; import 'navegacion_auto.dart'; import 'servicio_audio_session.dart'; import 'servicio_ecualizador.dart'; @@ -47,6 +48,17 @@ void registrarFuenteNavegacion(FuenteEmisorasAuto 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. class ServicioAudio { PluriWaveAudioHandler get _handler { @@ -735,12 +747,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler ]) async { try { final constructor = ConstructorArbolAuto(); + final fuenteLocal = _fuenteMusicaLocalGlobal; if (parentMediaId == AudioService.browsableRootId) { - return constructor.raiz(); + final incluirMusicaLocal = + fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada(); + return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal); } if (parentMediaId == ConstructorArbolAuto.idEcualizador) { return constructor.presetsEq(PresetEcualizador.presets); } + final musicaLocal = await hijosMusicaLocal( + parentMediaId, + fuente: fuenteLocal, + ); + if (musicaLocal != null) return musicaLocal; final fuente = _fuenteNavegacionGlobal; if (fuente == null) return const []; if (parentMediaId == ConstructorArbolAuto.idFavoritos) { @@ -802,6 +822,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler ); 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; if (fuente == null) return; await reproducirPorMediaId( diff --git a/openspec/changes/android-auto-local-music/apply-progress.md b/openspec/changes/android-auto-local-music/apply-progress.md new file mode 100644 index 0000000..3a09296 --- /dev/null +++ b/openspec/changes/android-auto-local-music/apply-progress.md @@ -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` diff --git a/openspec/changes/android-auto-local-music/design.md b/openspec/changes/android-auto-local-music/design.md new file mode 100644 index 0000000..3a48205 --- /dev/null +++ b/openspec/changes/android-auto-local-music/design.md @@ -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:`; tracks +`pista:`. 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: ─▶ fuente.hijos(docId) ─▶ itemsLocales(nodos) [native lists 1 level] + Car taps pista: ─▶ 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 hayCarpetaConfigurada(); + Future> hijos(String documentId); // '' = tree root; never throws + Future 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. diff --git a/openspec/changes/android-auto-local-music/proposal.md b/openspec/changes/android-auto-local-music/proposal.md new file mode 100644 index 0000000..e6275c4 --- /dev/null +++ b/openspec/changes/android-auto-local-music/proposal.md @@ -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. diff --git a/openspec/changes/android-auto-local-music/specs/android-auto-media/spec.md b/openspec/changes/android-auto-local-music/specs/android-auto-media/spec.md new file mode 100644 index 0000000..ae8f973 --- /dev/null +++ b/openspec/changes/android-auto-local-music/specs/android-auto-media/spec.md @@ -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:` items and audio files as playable `pista:` 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:` items and audio files as `pista:` playable items +- AND each playable item's title is the file's raw filename + +#### Scenario: Car browses a nested subfolder + +- GIVEN a `carpeta_local:` 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:` 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:` 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:` 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:` 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:` 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. diff --git a/openspec/changes/android-auto-local-music/tasks.md b/openspec/changes/android-auto-local-music/tasks.md new file mode 100644 index 0000000..27c513c --- /dev/null +++ b/openspec/changes/android-auto-local-music/tasks.md @@ -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:` / + `pista:` 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:` → `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:` (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. ...")` 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. diff --git a/openspec/changes/android-auto-local-music/verify-report.md b/openspec/changes/android-auto-local-music/verify-report.md new file mode 100644 index 0000000..60d04db --- /dev/null +++ b/openspec/changes/android-auto-local-music/verify-report.md @@ -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) diff --git a/test/servicios/musica_local_auto_test.dart b/test/servicios/musica_local_auto_test.dart new file mode 100644 index 0000000..b29d950 --- /dev/null +++ b/test/servicios/musica_local_auto_test.dart @@ -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); + }, + ); + }); +} diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index c207331..0731e37 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2,7 +2,9 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/emisora.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/servicios/musica_local_auto.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart'; void main() { @@ -219,28 +221,58 @@ void main() { }); group('ConstructorArbolAuto.raiz', () { - test('devuelve exactamente 4 carpetas no reproducibles con los ids ' - 'esperados, terminando en Ecualizador', () { - final raiz = ConstructorArbolAuto().raiz(); + test( + 'con incluirMusicaLocal: true devuelve exactamente 5 carpetas no ' + 'reproducibles con los ids esperados, Música Local en 4to lugar, ' + 'terminando en Ecualizador', + () { + final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true); - expect(raiz, hasLength(4)); - final ids = raiz.map((item) => item.id).toSet(); - expect( - ids, - equals({ - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idEcualizador, - }), - ); - for (final item in raiz) { - expect(item.playable, isFalse); - expect(item.title, isNotEmpty); - } - expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); - expect(raiz.last.playable, isFalse); - }); + 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)); + final ids = raiz.map((item) => item.id).toSet(); + expect( + ids, + equals({ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idEcualizador, + }), + ); + expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal))); + expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); + expect(raiz.last.playable, isFalse); + }, + ); }); 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 = [ + '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: 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: 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: 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 = []; + + 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', () { test('mapea un PresetEcualizador a un item reproducible con id ' 'eq_preset:', () { @@ -890,3 +1303,38 @@ class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto { List? grupos, }) {} } + +class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { + _FakeFuenteMusicaLocalAuto({ + bool configurada = true, + Map>? hijosPorDocId, + Map? uriPorDocId, + Object? errorEnHijos, + Object? errorEnUriContenido, + }) : _configurada = configurada, + _hijosPorDocId = hijosPorDocId ?? const {}, + _uriPorDocId = uriPorDocId ?? const {}, + _errorEnHijos = errorEnHijos, + _errorEnUriContenido = errorEnUriContenido; + + final bool _configurada; + final Map> _hijosPorDocId; + final Map _uriPorDocId; + final Object? _errorEnHijos; + final Object? _errorEnUriContenido; + + @override + Future hayCarpetaConfigurada() async => _configurada; + + @override + Future> hijos(String documentId) async { + if (_errorEnHijos != null) throw _errorEnHijos; + return _hijosPorDocId[documentId] ?? const []; + } + + @override + Future uriContenidoDePista(String documentId) async { + if (_errorEnUriContenido != null) throw _errorEnUriContenido; + return _uriPorDocId[documentId]; + } +}