From 3449e2cb79761fdd2a915581e29e87304271ad9e Mon Sep 17 00:00:00 2001 From: freetlab Date: Mon, 31 Aug 2026 14:32:26 +0200 Subject: [PATCH] fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo. 1. Ecualizador: el estado no tenia dueño unico El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable unicamente desde el arbol de widgets, que un arranque headless de Android Auto nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras disco e interfaz decian OFF. Ahora registrarHandler siembra el flag desde disco en todos los motores y setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa a ser adopcion pura de interfaz. Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost real: la causa del "suena muy alto con el boton apagado". Reescrito para escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0. El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op mientras la plataforma esta desacoplada. 2. Musica Local no aparecia en el arbol de Android Auto hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'), registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay handler, invokeMethod lanza MissingPluginException y el catch la confundia con "permiso revocado", omitiendo el nodo. No dependia del entitlement. La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local. El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en su constructor, asi que el canal queda registrado en ambos motores. Repuntar el manifest a una subclase de AudioService no era viable: AudioServicePlugin enlaza por ComponentName explicito y la app perderia el audio. EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no disponible"; la raiz decide por la URI persistida y el subarbol muestra un item explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se dispara al reanudar con el coche ya suscrito; el guardia anterior miraba View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el arranque headless y no volvia a dispararse. 3. El paywall bloqueaba las compras restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba, noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta en produccion. Como comprar y restaurar comparten ese flag, un usuario sin compras que pulsaba restaurar se quedaba sin poder comprar. Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes. --- .../es/freetimelab/pluriwave/MainActivity.kt | 310 +------- android/gradle.properties | 4 + lib/estado/estado_ecualizador.dart | 28 +- lib/estado/estado_entitlement.dart | 4 +- lib/main.dart | 114 ++- .../pantalla_ajustes_musica_local.dart | 10 + lib/servicios/arranque_audio.dart | 16 +- lib/servicios/cola_local.dart | 6 +- lib/servicios/musica_local_auto.dart | 77 +- lib/servicios/navegacion_auto.dart | 41 +- lib/servicios/servicio_audio.dart | 690 ++++++++++++++---- lib/servicios/servicio_compras.dart | 19 +- lib/servicios/servicio_ecualizador.dart | 18 + .../android/build.gradle.kts | 44 ++ .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 2 + .../fileactions/FileActionsHandler.kt | 363 +++++++++ .../fileactions/PluriWaveFileActionsPlugin.kt | 76 ++ .../lib/pluriwave_file_actions.dart | 20 + packages/pluriwave_file_actions/pubspec.yaml | 23 + pubspec.lock | 7 + pubspec.yaml | 9 + ...arranque_invalidacion_arbol_auto_test.dart | 203 ++++++ test/estado/estado_ecualizador_test.dart | 21 +- test/estado/estado_entitlement_test.dart | 28 + .../pantalla_ajustes_musica_local_test.dart | 80 ++ .../musica_local_auto_prefs_test.dart | 54 ++ test/servicios/musica_local_auto_test.dart | 126 ++++ test/servicios/navegacion_auto_test.dart | 74 +- .../servicio_audio_eq_estado_unico_test.dart | 474 ++++++++++++ .../servicio_audio_eq_ganancia_test.dart | 125 ++++ .../servicios/servicio_audio_gating_test.dart | 173 ++++- test/servicios/servicio_compras_test.dart | 108 ++- test/widgets/hoja_premium_test.dart | 73 ++ 34 files changed, 2948 insertions(+), 473 deletions(-) create mode 100644 packages/pluriwave_file_actions/android/build.gradle.kts create mode 100644 packages/pluriwave_file_actions/android/settings.gradle create mode 100644 packages/pluriwave_file_actions/android/src/main/AndroidManifest.xml create mode 100644 packages/pluriwave_file_actions/android/src/main/kotlin/es/freetimelab/pluriwave/fileactions/FileActionsHandler.kt create mode 100644 packages/pluriwave_file_actions/android/src/main/kotlin/es/freetimelab/pluriwave/fileactions/PluriWaveFileActionsPlugin.kt create mode 100644 packages/pluriwave_file_actions/lib/pluriwave_file_actions.dart create mode 100644 packages/pluriwave_file_actions/pubspec.yaml create mode 100644 test/arranque_invalidacion_arbol_auto_test.dart create mode 100644 test/servicios/musica_local_auto_prefs_test.dart create mode 100644 test/servicios/servicio_audio_eq_estado_unico_test.dart create mode 100644 test/servicios/servicio_audio_eq_ganancia_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 b80032b..bf6163b 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -10,7 +10,6 @@ import android.content.pm.PackageManager import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager -import android.media.MediaMetadataRetriever import android.net.Uri import android.media.audiofx.Visualizer import android.app.AlarmManager @@ -26,6 +25,7 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.core.content.FileProvider import com.ryanheise.audioservice.AudioServiceActivity +import es.freetimelab.pluriwave.fileactions.FileActionsHandler import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel @@ -269,10 +269,31 @@ class MainActivity : AudioServiceActivity() { } activeInstance = this + // fix/android-auto-musica-local: los cuatro metodos SAF que solo + // necesitan un ContentResolver viven en FileActionsHandler, dentro del + // paquete plugin `packages/pluriwave_file_actions`. Alli + // PluriWaveFileActionsPlugin los registra en TODOS los engines via + // GeneratedPluginRegistrant -- incluido el headless que audio_service + // crea para Android Auto, donde este configureFlutterEngine nunca + // corre. + // + // Este engine SI tiene Activity, asi que instala UN solo handler para + // todo el canal, superconjunto del del plugin: primero delega en el + // handler compartido (misma y unica implementacion) y, si este no + // reconoce el metodo, atiende sus propios metodos ligados a la + // Activity (picker SAF e intents de la carpeta de grabaciones). + // + // El orden esta garantizado: GeneratedPluginRegistrant corre DENTRO + // del constructor de FlutterEngine, y configureFlutterEngine solo + // puede ejecutarse despues, con el engine ya construido. Este handler + // siempre pisa al del plugin en una Activity, nunca al reves. + val fileActionsHandler = FileActionsHandler(applicationContext) + MethodChannel( flutterEngine.dartExecutor.binaryMessenger, fileActionsChannel ).setMethodCallHandler { call, result -> + if (fileActionsHandler.manejar(call, result)) return@setMethodCallHandler when (call.method) { "openDirectory" -> { val path = call.argument("path") @@ -327,53 +348,11 @@ class MainActivity : AudioServiceActivity() { 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) - ) - } - // ---- android-auto-local-music-phase2 (static review only) ---- - "readAudioMetadataBatch" -> { - val treeUri = call.argument("treeUri") - val documentIds = call.argument>("documentIds") ?: emptyList() - Log.d( - tag, - "file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}" - ) - if (treeUri.isNullOrBlank()) { - result.success(emptyList>()) - } else { - result.success(readAudioMetadataBatch(treeUri, documentIds)) - } - } + // listAudioChildren / resolvePlayableUri / + // hasPersistedPermission / readAudioMetadataBatch los + // atiende FileActionsHandler arriba (item 3): no necesitan + // Activity, asi que tienen que poder registrarse tambien en + // un engine que no la tiene. else -> result.notImplemented() } } @@ -423,241 +402,6 @@ class MainActivity : AudioServiceActivity() { 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 types 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 - } - } - - /** - * Batched embedded-metadata extraction (android-auto-local-music-phase2, - * static review only — Design "Interfaces / Contracts"): for each of - * [documentIds], extracts title/artist/bitrate/sample-rate and the - * embedded picture via [extraerMetadatosPista]. Never throws across the - * channel boundary — a malformed [treeUri] (or any other unexpected - * failure) degrades the WHOLE call to `[]`; a per-entry failure is - * already isolated inside [extraerMetadatosPista]. - */ - private fun readAudioMetadataBatch(treeUri: String, documentIds: List): List> { - return try { - val parsedTree = Uri.parse(treeUri) - documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) } - } catch (error: Throwable) { - Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error) - emptyList() - } - } - - /** - * Extracts one [documentId]'s embedded metadata via - * [MediaMetadataRetriever] (android-auto-local-music-phase2, static - * review only — mirrors [listAudioChildren]/[resolvePlayableUri]'s - * never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no - * public constant below API 31) is gated behind - * `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is - * available since API 10 and read unconditionally. A resolvable - * embedded picture is handed to [cachearArteEmbebido]; art-cache - * failures degrade that single field to `null` without failing the - * whole entry. On ANY failure for this [documentId] (unsupported - * format, permission edge case, corrupt file), the row degrades to an - * all-null-but-`documentId` entry instead of throwing — - * `retriever.release()` always runs via `finally`. - */ - private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map { - val retriever = MediaMetadataRetriever() - return try { - val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId) - retriever.setDataSource(this, documentUri) - - val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) - val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) - val bitrate = retriever - .extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) - ?.toIntOrNull() - val sampleRate = if (Build.VERSION.SDK_INT >= 31) { - // METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no - // public constant exists on this minSdk, so the raw key is - // used directly, guarded by the version check above. - retriever.extractMetadata(38)?.toIntOrNull() - } else { - null - } - val artUri = try { - retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) } - } catch (error: Throwable) { - Log.e( - tag, - "file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId", - error - ) - null - } - - mapOf( - "documentId" to documentId, - "titulo" to titulo, - "artista" to artista, - "bitrate" to bitrate, - "sampleRate" to sampleRate, - "artUri" to artUri - ) - } catch (error: Throwable) { - Log.e( - tag, - "file_actions.readAudioMetadataBatch entry failed documentId=$documentId", - error - ) - mapOf( - "documentId" to documentId, - "titulo" to null, - "artista" to null, - "bitrate" to null, - "sampleRate" to null, - "artUri" to null - ) - } finally { - try { - retriever.release() - } catch (_: Throwable) { - // release() failing is not actionable — the retriever is - // being discarded regardless. - } - } - } - - /** - * Embedded-art cache write + LRU trim (android-auto-local-music-phase2, - * static review only — Design ADR-1). Writes [picture] bytes to - * `cacheDir/pluriwave_art/` (skips the write when the - * file already exists, so re-parsing the same track reuses it), returns - * the `content://` URI served via the EXISTING - * `${applicationId}.fileprovider` authority - * (`AndroidManifest.xml:97-105`, `pluriwave_file_paths.xml`'s - * `cache-path path="."` — confirmed present, zero manifest changes - * needed) and trims `pluriwave_art/` via [trimArtCache]. `hash` uses - * SHA-256 hex because a raw `documentId` may contain `:`/`/`, which are - * illegal in filenames on most filesystems. - */ - private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? { - return try { - val artDir = File(cacheDir, "pluriwave_art").apply { mkdirs() } - val artFile = File(artDir, hashDocumentId(documentId)) - if (!artFile.exists()) { - artFile.writeBytes(picture) - } - trimArtCache(artDir) - FileProvider.getUriForFile(this, "$packageName.fileprovider", artFile).toString() - } catch (error: Throwable) { - Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error) - null - } - } - - private fun hashDocumentId(documentId: String): String { - val digest = java.security.MessageDigest.getInstance("SHA-256") - val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8)) - return bytes.joinToString("") { "%02x".format(it) } - } - - /** - * LRU-by-`lastModified` trim of the embedded-art cache dir (Design - * ADR-1): after each write, keeps at most 256 files AND at most 32 MB - * total, deleting the OLDEST-by-mtime entries first. Kept as a - * trivially reviewable loop — these files are native-owned, so - * round-tripping names to Dart to pick deletions would add channel - * chatter with no testability gain (the `delete()` is native - * regardless, per ADR-1's rationale). - */ - private fun trimArtCache(artDir: File) { - val maxArchivos = 256 - val maxBytes = 32L * 1024 * 1024 - val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList() - ?: return - var totalBytes = archivos.sumOf { it.length() } - while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) { - val masViejo = archivos.removeAt(archivos.size - 1) - totalBytes -= masViejo.length() - masViejo.delete() - } - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5da727 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index aa2323d..1ce946e 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -98,6 +98,13 @@ class EstadoEcualizador extends ChangeNotifier { /// `_actualizarControlesEq()`, regardless of who triggered it), compare /// the handler's current EQ state against our cached copy and adopt it on /// divergence. + /// + /// Since eq-estado-unico this is a DISPLAY concern only. The handler owns + /// the flag and persists it itself, so this subscription no longer closes + /// a persistence gap — it just keeps the phone's toggle showing what the + /// engine is really doing. It also cannot be the fix on its own: it exists + /// only while an [EstadoEcualizador] does, and the headless Android Auto + /// engine that produced the bug report never builds one. StreamSubscription? _suscripcionEstadoAudioEq; PresetEcualizador get presetActual => _presetActual; @@ -370,8 +377,16 @@ class EstadoEcualizador extends ChangeNotifier { /// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the /// handler's own `_actualizarControlesEq()` re-push, which would tick /// [ServicioAudio.estadoStream] again and re-enter this method forever. - /// Only a local field write, [servicio] persistence and [notifyListeners] - /// happen here, so a divergence is resolved in a single pass. + /// Only a local field write and [notifyListeners] happen here, so a + /// divergence is resolved in a single pass. + /// + /// It is now a PURE UI ADOPT — it does not persist (eq-estado-unico item + /// B). `PluriWaveAudioHandler` writes its own toggle through the port + /// `registrarHandler` injects, so the value is saved on every engine + /// rather than only on one that happens to have built a widget tree. This + /// method could never have been the owner of that fact: it only runs while + /// an [EstadoEcualizador] exists, and on the headless Android Auto engine + /// behind the bug report none ever does. /// /// Wrapped in try/catch like every other handler-facing read in this /// class (e.g. [_sembrarDispositivoActual]): a test double or an @@ -387,13 +402,10 @@ class EstadoEcualizador extends ChangeNotifier { if (!activoDiverge && !presetDiverge) return; if (activoDiverge) { + // Display-only adopt: the handler already persisted this value + // through its own write port before it ever reached us. See the + // doc above. _activo = activoHandler; - // Closes the persistence gap: `PluriWaveAudioHandler` never - // persists anything itself (it must stay headless-constructible, - // with zero SharedPreferences/Provider access) — [servicio] is the - // only owner of EQ persistence, so a car/notification toggle must - // be saved HERE or it is lost on the next process restart. - await servicio.guardarActivo(activoHandler); } if (presetDiverge) { _presetActual = presetHandler; diff --git a/lib/estado/estado_entitlement.dart b/lib/estado/estado_entitlement.dart index cc1e804..cbeadf8 100644 --- a/lib/estado/estado_entitlement.dart +++ b/lib/estado/estado_entitlement.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../servicios/servicio_audio.dart' show notificarDesbloqueoAuto; +import '../servicios/servicio_audio.dart' show invalidarArbolAuto; import '../servicios/servicio_compras.dart'; /// Versioned persistence key (Design ADR-1) for the permanent, non-consumable @@ -181,7 +181,7 @@ class EstadoEntitlement extends ChangeNotifier { // Orchestrator-resolved open question (design.md): actively // invalidate the Android Auto browse cache on the free -> premium // transition, rather than waiting for the head unit's own re-bind. - notificarDesbloqueoAuto(); + invalidarArbolAuto(); } } diff --git a/lib/main.dart b/lib/main.dart index ca1f338..17e725a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio_session.dart'; import 'servicios/servicio_compras.dart'; import 'servicios/servicio_consentimiento.dart'; +import 'servicios/servicio_ecualizador.dart'; import 'servicios/servicio_presets_personalizados.dart'; import 'tema/pluriwave_tokens.dart'; @@ -88,7 +89,7 @@ Future main() async { // // Regression this fixes, self-inflicted by the reordering above: the root // menu decides whether to offer "Música Local" with - // `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`. + // `fuenteLocal != null && await fuenteLocal.estadoCarpeta() != noConfigurada`. // Moving ONLY the station source above the awaits meant the car could get // a root response in the window before this line ran, find a null source, // and be told there is no local music — and Android Auto caches the browse @@ -103,7 +104,7 @@ Future main() async { registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl()); // Cosmetic, and deliberately NOT awaited: a display preference must never - // gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies + // gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies // it again as soon as a real view exists, which is the only moment it can // actually take effect anyway. unawaited(aplicarPoliticaOrientacion()); @@ -151,6 +152,18 @@ Future main() async { final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs); registrarFuentePresetsPersonalizados(presetsPersonalizados.listar); + // eq-estado-unico items A/B: the handler's own link to the equalizer's + // persisted on/off flag. `ServicioEcualizador` needs nothing but the + // `prefs` instance resolved just above — no widget tree, no Provider — so + // it is available on EVERY engine, including the headless one Android Auto + // starts. Before this, the persisted value only reached the handler + // through `EstadoEcualizador.cargarPersistido()`, which that engine never + // runs: the handler played with the equalizer forced on while disk and the + // phone UI both said off, and a toggle made in the car was lost on + // restart. Passed as two narrow function ports, mirroring the + // read-function convention used for the preset folder right above. + final ecualizador = ServicioEcualizador(prefs: prefs); + // Silent-error channel (fix/notificacion-media): `AudioService.asyncError` // had ZERO subscribers app-wide, and a `PublishSubject` with no listeners // drops what it is given — so every exception `audio_service` catches @@ -179,7 +192,11 @@ Future main() async { // radio; headphones unplugged pauses it. Shared by both the on-time and // degraded/late-completion paths below. void conectarHandler(PluriWaveAudioHandler handler) { - registrarHandler(handler); + registrarHandler( + handler, + leerEqActivoPersistido: ecualizador.leerActivo, + guardarEqActivoPersistido: ecualizador.guardarActivo, + ); // The handler is the only thing this app ever tears down // (`onTaskRemoved`), so the asyncError subscription's `cancel` travels // with it and can never leak — same "register from main.dart" convention @@ -189,7 +206,7 @@ Future main() async { unawaited(sesionAudio.configurar()); } - Widget construirApp() => _OrientacionResponsiveApp( + Widget construirApp() => OrientacionResponsiveApp( child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras), ); @@ -268,20 +285,80 @@ Future aplicarPoliticaOrientacion({ } } -class _OrientacionResponsiveApp extends StatefulWidget { - const _OrientacionResponsiveApp({required this.child}); +/// Whether the Android Auto browse tree must be invalidated right now +/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger). +/// +/// The trigger used to be `View.maybeOf(context) != null` inside +/// `didChangeDependencies`, latched once, on the premise that «a View means +/// there is an Activity». That premise is FALSE: `runApp` unconditionally +/// wraps the tree in a `View` built from +/// `platformDispatcher.implicitView` and throws a `StateError` when there is +/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So +/// on the headless `audio_service` engine — which demonstrably reaches +/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at +/// the first `didChangeDependencies`. The one-shot latch was spent at the +/// exact moment it could accomplish nothing (`_childrenSubjects` still +/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never +/// fire again, because `didChangeDependencies` does not re-run when an +/// Activity later attaches to that same cached engine. +/// +/// Two conditions replace it, both required: +/// +/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely +/// means «an Activity is attached and in the foreground». It reaches Dart +/// exclusively through `SystemChannels.lifecycle` (or +/// `PlatformDispatcher.initialLifecycleState`, which buffers the same +/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends +/// it, driven by the Activity's own `onResume`. +/// `AudioServicePlugin.getFlutterEngine` builds its engine from the +/// APPLICATION context and runs the Dart entrypoint immediately, with no +/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends +/// it on the headless engine. +/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least +/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch +/// worth spending, and it is also the belt to `resumed`'s braces: even if +/// a lifecycle event did somehow arrive during a headless cold start, +/// nothing has subscribed yet, so the latch survives for the moment an +/// Activity really does attach. +/// +/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must +/// not send twenty `notifyChildrenChanged` storms to the car. +/// +/// Pure, so the whole policy is testable without an engine. +@visibleForTesting +bool debeInvalidarArbolAutoAlReanudar({ + required AppLifecycleState estado, + required bool hayCocheSuscrito, + required bool yaInvalidado, +}) => + !yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed; + +/// Root wrapper that keeps the orientation policy applied and owns the +/// Android Auto browse-tree recovery hook. +/// +/// Public only so a test can mount it and drive real lifecycle events +/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous +/// trigger shipped broken precisely because nothing could reach it. +@visibleForTesting +class OrientacionResponsiveApp extends StatefulWidget { + const OrientacionResponsiveApp({super.key, required this.child}); final Widget child; @override - State<_OrientacionResponsiveApp> createState() => + State createState() => _OrientacionResponsiveAppState(); } -class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp> +class _OrientacionResponsiveAppState extends State with WidgetsBindingObserver { ui.Display? _display; + /// fix/android-auto-musica-local, item 4: la invalidación del árbol del + /// coche se dispara UNA sola vez. Ver + /// [debeInvalidarArbolAutoAlReanudar]. + bool _arbolAutoInvalidado = false; + @override void initState() { super.initState(); @@ -295,6 +372,27 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp> unawaited(aplicarPoliticaOrientacion(display: _display)); } + /// `resumed` es lo único que significa de verdad «ya hay Activity + /// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que + /// `MainActivity.configureFlutterEngine` instala. Si el coche había + /// navegado la raíz ANTES (arranque headless), la cacheó sin poder + /// resolver la música local; Android Auto no vuelve a preguntar por su + /// cuenta, así que se lo decimos aquí. Ver + /// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (!debeInvalidarArbolAutoAlReanudar( + estado: state, + hayCocheSuscrito: hayCocheSuscritoAlArbol(), + yaInvalidado: _arbolAutoInvalidado, + )) { + return; + } + _arbolAutoInvalidado = true; + invalidarArbolAuto(); + } + @override void didChangeMetrics() { unawaited(aplicarPoliticaOrientacion(display: _display)); diff --git a/lib/pantallas/ajustes/pantalla_ajustes_musica_local.dart b/lib/pantallas/ajustes/pantalla_ajustes_musica_local.dart index c34324a..399fda7 100644 --- a/lib/pantallas/ajustes/pantalla_ajustes_musica_local.dart +++ b/lib/pantallas/ajustes/pantalla_ajustes_musica_local.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../servicios/musica_local_auto.dart'; +import '../../servicios/servicio_audio.dart' show invalidarArbolAuto; import '../../widgets/pluri_glass_surface.dart'; import '../../widgets/pluri_layout.dart'; import '../../widgets/pluri_push_scaffold.dart'; @@ -54,6 +55,15 @@ class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> { final uri = await _fuente.elegirCarpeta(); if (uri == null) return; // Cancelled — no snackbar, matches the SAF // picker's own "nothing changed" affordance. + + // fix/android-auto-musica-local, item 4: acaba de aparecer música + // local donde antes no había. Android Auto cachea la raíz y no + // vuelve a preguntar por su cuenta, así que sin esto el coche seguía + // sin ofrecer «Música Local» hasta el siguiente re-bind — que puede + // no llegar en toda la sesión. Fuera del `context.mounted` de abajo: + // el árbol del coche no depende de que esta pantalla siga viva. + invalidarArbolAuto(); + if (!context.mounted) return; setState(() { _carpetaActual = Future.value(uri); diff --git a/lib/servicios/arranque_audio.dart b/lib/servicios/arranque_audio.dart index 17f3956..a797742 100644 --- a/lib/servicios/arranque_audio.dart +++ b/lib/servicios/arranque_audio.dart @@ -5,11 +5,17 @@ import 'package:flutter/material.dart'; import '../tema/pluriwave_tokens.dart'; /// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design -/// "Timeout without re-init"): the vendored `audio_service` plugin's -/// self-bind has no native timeout and an unhandled `onConnectionSuspended` -/// case, so under bind contention (Android Auto cold start) the handshake -/// can hang forever. Top-level const so tests can reference the production -/// value without duplicating it. +/// "Timeout without re-init"): the `audio_service` plugin's self-bind has no +/// native timeout and an unhandled `onConnectionSuspended` case, so under +/// bind contention (Android Auto cold start) the handshake can hang forever. +/// Top-level const so tests can reference the production value without +/// duplicating it. +/// +/// This doc called the plugin "vendored". It is not: `pubspec.lock` pins the +/// hosted pub.dev `audio_service` 0.18.18 and `pubspec.yaml` declares no +/// `dependency_overrides`. Anyone reading the sentence above would go looking +/// for a local copy to patch, and there is none — the behaviour described is +/// upstream's, so the workaround has to live here. const timeoutArranqueAudio = Duration(seconds: 8); /// Outcome of racing an `AudioService.init` future against diff --git a/lib/servicios/cola_local.dart b/lib/servicios/cola_local.dart index 98924fc..57aa956 100644 --- a/lib/servicios/cola_local.dart +++ b/lib/servicios/cola_local.dart @@ -6,8 +6,10 @@ import '../modelos/pista_local.dart'; /// instance rather than mutating in place, mirroring how /// `ControladorReconexion` was extracted from `PluriWaveAudioHandler` /// (`controlador_reconexion.dart`) so this stays fully unit-testable without -/// the handler (which cannot be instantiated in unit tests — see this -/// module's sibling test file's doc comment). +/// the handler. (That last clause used to read "which cannot be instantiated +/// in unit tests"; it can — see `construirControlesTransporte`'s doc in +/// `servicio_audio.dart`. Keeping the queue logic out of the handler is +/// still worth it, but for design reasons, not for that one.) class ColaLocal { const ColaLocal({required this.pistas, this.indice = 0}); diff --git a/lib/servicios/musica_local_auto.dart b/lib/servicios/musica_local_auto.dart index cfd2595..4ffde5e 100644 --- a/lib/servicios/musica_local_auto.dart +++ b/lib/servicios/musica_local_auto.dart @@ -1,5 +1,6 @@ import 'dart:collection'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -71,6 +72,24 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) { return recortado.isEmpty ? nombreGenerico : recortado; } +/// Three-valued answer to «¿hay música local usable?» +/// (fix/android-auto-musica-local). +/// +/// Sustituye al `bool` anterior, que colapsaba dos causas MUY distintas en +/// el mismo `false`: +/// +/// * [noConfigurada] — no hay URI persistida, o el nativo respondió que el +/// permiso ya no es válido (el usuario nunca eligió carpeta, o la +/// revocó). Es la única respuesta que justifica ocultar el nodo. +/// * [configurada] — hay URI persistida y el nativo confirma el permiso. +/// * [canalNoDisponible] — hay URI persistida pero el canal +/// `pluriwave/file_actions` NO tiene handler nativo, así que no se puede +/// saber nada del permiso. Es lo que ocurre en el motor Flutter headless +/// que `audio_service` levanta cuando Android Auto arranca la app sin +/// Activity: `MainActivity.configureFlutterEngine` (único sitio donde se +/// registra ese canal) nunca corre. NO significa «no hay carpeta». +enum EstadoCarpetaLocal { noConfigurada, configurada, canalNoDisponible } + /// 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 @@ -78,9 +97,11 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) { /// 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(); + /// still valid — o si esa pregunta no se puede contestar porque el canal + /// nativo no existe en este motor. Never throws: cualquier fallo degrada + /// a un valor de [EstadoCarpetaLocal], nunca a una excepción (Spec + /// "Permission revoked or never granted"). + Future estadoCarpeta(); /// Immediate children of [documentId] (`''` = the tree root itself), one /// SAF level deep (Design "Lazy per-folder enumeration, never an eager @@ -197,18 +218,56 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto { } @override - Future hayCarpetaConfigurada() async { + Future estadoCarpeta() async { + // Its OWN try, deliberately not merged with the channel one below. + // + // Never-throws restoration: the three-valued refactor moved this read + // outside the try, and the only caller (`getChildren`'s root branch) + // awaits it inline — so a prefs failure took the whole browse root down + // and emptied the car, against this method's own interface doc. + // + // Kept SEPARATE because a prefs failure and a channel failure both + // surface as `MissingPluginException`: one shared `on + // MissingPluginException` clause would answer `canalNoDisponible` — + // «hay carpeta pero no puedo comprobar el permiso» — for a store that + // never told us whether a folder exists at all. That would put an + // unreachable «Música Local» node in the car explaining a channel + // problem that is not happening, which is precisely the collapse the + // three-valued [EstadoCarpetaLocal] exists to prevent. + // + // `noConfigurada` is the honest answer here (the app cannot prove a + // folder was ever picked) and is what this path returned before the + // refactor, when the read still sat inside the catch-all below. + final String? uri; + try { + uri = await _uriPersistida(); + } catch (e) { + debugPrint('[PluriWave][musica_local] no se pudo leer la URI local: $e'); + return EstadoCarpetaLocal.noConfigurada; + } + if (uri == null || uri.isEmpty) return EstadoCarpetaLocal.noConfigurada; try { - final uri = await _uriPersistida(); - if (uri == null || uri.isEmpty) return false; final valido = await _canal.invokeMethod('hasPersistedPermission', { 'treeUri': uri, }); - return valido ?? false; - } catch (_) { + return valido == true + ? EstadoCarpetaLocal.configurada + : EstadoCarpetaLocal.noConfigurada; + } on MissingPluginException catch (e) { + // El canal no tiene handler en ESTE motor. Antes esto caía en el + // mismo `catch (_)` que un permiso revocado y devolvía `false`, que + // es exactamente por lo que «Música Local» desaparecía del árbol de + // Android Auto cuando el coche arrancaba la app sin Activity. + debugPrint( + '[PluriWave][musica_local] hasPersistedPermission sin handler ' + 'nativo (motor sin Activity): $e', + ); + return EstadoCarpetaLocal.canalNoDisponible; + } catch (e) { // Cold-start / revoked-permission safety (Spec "Permission revoked or // never granted"): never throw, degrade to "not configured". - return false; + debugPrint('[PluriWave][musica_local] hasPersistedPermission ERROR $e'); + return EstadoCarpetaLocal.noConfigurada; } } diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 3685be9..842fa0b 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -229,6 +229,17 @@ class ConstructorArbolAuto { /// hidden. static const idEcualizador = 'ecualizador'; + /// Non-playable "no puedo leer la carpeta desde aquí" item + /// (fix/android-auto-musica-local). La raíz ya no oculta [idMusicaLocal] + /// cuando el canal nativo `pluriwave/file_actions` no está disponible en + /// este motor, así que abrir la carpeta tenía que dejar de mostrar una + /// lista vacía: vacío se lee como «no tengo música», que es justo la + /// conclusión equivocada. Este item dice qué pasa de verdad. + /// + /// Colisión imposible con los prefijos `carpeta_local:` / `pista:` / + /// `emisora:` / `grupo:` — no lleva ninguno de ellos. + static const idLocalNoLista = 'musica_local_no_disponible'; + static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras}; static const _maxItemsPorCarpeta = 50; @@ -331,8 +342,10 @@ class ConstructorArbolAuto { /// /// `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. + /// is configured") — the caller lo deriva de + /// `fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada` + /// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el + /// nodo), keeping this builder itself synchronous and side-effect free. /// /// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact /// same visible folder labels for every tier — "keeps the same visible @@ -370,6 +383,17 @@ class ConstructorArbolAuto { extras: _contentStyleLista, ); + /// El item de [idLocalNoLista]. Etiqueta en castellano hardcodeado, como + /// TODAS las etiquetas del árbol del coche en este archivo (ver + /// [itemPremiumBloqueado]): convención establecida, nunca + /// `AppLocalizations`. No reproducible — seleccionarlo es un no-op. + MediaItem itemLocalNoDisponible() => MediaItem( + id: idLocalNoLista, + title: 'Abre PluriWave en el móvil para leer tu música', + playable: false, + extras: _contentStyleLista, + ); + MediaItem _carpeta(String id, String titulo) => MediaItem( id: id, title: titulo, @@ -1584,13 +1608,24 @@ Future?> hijosMusicaLocal( if (fuente == null) return const []; try { final nodos = await fuente.hijos(documentId); - return await constructor.itemsLocales( + final items = await constructor.itemsLocales( nodos, documentIdPadre: documentId, pagina: pagina, metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), fuente: fuente, ); + // fix/android-auto-musica-local: si no salió NADA, el motivo importa. + // Con el canal nativo caído (motor sin Activity) `hijos` degrada a `[]` + // igual que una carpeta realmente vacía, y una carpeta vacía en el + // coche se lee como «no tengo música». El estado se consulta SOLO en + // ese caso vacío, así que la ruta normal no paga ningún round trip + // extra. + if (items.isEmpty && + await fuente.estadoCarpeta() == EstadoCarpetaLocal.canalNoDisponible) { + return [constructor.itemLocalNoDisponible()]; + } + return items; } catch (_) { return const []; } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 08d9fe6..60951f1 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -36,13 +36,93 @@ enum EstadoReproduccion { // ───────────────────────────────────────────────────────────────────────────── PluriWaveAudioHandler? _handlerGlobal; -void registrarHandler(PluriWaveAudioHandler handler) { +/// Read port for the persisted equalizer on/off flag (eq-estado-unico item A). +/// In production `main.dart` binds it to `ServicioEcualizador.leerActivo`, +/// which needs nothing but the `SharedPreferences` instance already resolved +/// before `AudioService.init`. `null` for any caller that has no disk (widget +/// tests, fakes) — seeding is then skipped entirely. +typedef LeerEqActivoPersistido = Future Function(); + +/// Write port for the same flag (eq-estado-unico item B). Bound to +/// `ServicioEcualizador.guardarActivo`. +typedef GuardarEqActivoPersistido = Future Function(bool activo); + +/// Last value read from disk for the equalizer on/off flag, or `null` while +/// nothing has been read yet. +/// +/// This exists purely to close the construction window: `AudioService.init` +/// builds the handler through its `builder` callback, and only AFTER that +/// future resolves does `main.dart` reach [registrarHandler]. A car tap +/// landing inside that window would otherwise hit a handler whose flag had +/// never seen disk. Once one engine has read the value, any handler built +/// afterwards starts from it instead of from a hardcoded default. +bool? _eqActivoPersistido; + +/// The equalizer's initial on/off state for a freshly started engine. +/// +/// Pure seam (eq-estado-unico item A): [PluriWaveAudioHandler] used to +/// hardcode `_ecualizadorActivo = true`, so a process started HEADLESSLY by +/// Android Auto — no Activity, no Provider tree, so no +/// `EstadoEcualizador.cargarPersistido()` — played with the equalizer forced +/// on while disk and the phone UI both said off. That is the reported «suena +/// muy alto con el boton desactivado». +/// +/// `null` means "nothing was ever persisted" (first install, or a wiped +/// preference) and keeps the historical default of ON. It must NOT be +/// confused with "off": a user who has never touched the toggle expects the +/// equalizer on, and the app has always behaved that way. +bool estadoEqInicial({required bool? persistido}) => persistido ?? true; + +/// Reads the persisted equalizer flag through [leer] exactly once and seeds +/// [handler] with it, without ever writing back. +/// +/// Never throws: an unreadable preference store leaves the handler on +/// [estadoEqInicial]'s default rather than taking down the audio bootstrap. +Future _sembrarEcualizadorDesdeDisco( + PluriWaveAudioHandler handler, + LeerEqActivoPersistido leer, +) async { + bool? persistido; + try { + persistido = await leer(); + } catch (e) { + debugPrint( + '[PluriWave][ServicioAudio] no se pudo leer el estado EQ persistido: $e', + ); + persistido = null; + } + _eqActivoPersistido = persistido; + await handler.sembrarEcualizadorActivo( + estadoEqInicial(persistido: persistido), + ); +} + +/// Wires the freshly built handler into the module-level seams. +/// +/// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler +/// its own, UI-independent link to the equalizer's persisted on/off flag +/// (eq-estado-unico items A and B). Before them the flag reached the handler +/// only through `EstadoEcualizador.cargarPersistido()`, i.e. only on an +/// engine that had actually built the widget tree — which a headless Android +/// Auto bind never does. Both are optional so every existing caller (widget +/// tests, fakes) keeps compiling and behaving exactly as before. +void registrarHandler( + PluriWaveAudioHandler handler, { + LeerEqActivoPersistido? leerEqActivoPersistido, + GuardarEqActivoPersistido? guardarEqActivoPersistido, +}) { _handlerGlobal = handler; - // iap-freemium-unlock (design.md Open Questions, orchestrator-resolved): - // on the free -> premium transition, actively invalidate every root-level - // browse id a head unit may have cached while locked, rather than waiting - // for its own re-bind — see [registrarNotificacionDesbloqueoAuto]'s doc. - registrarNotificacionDesbloqueoAuto(() { + // Registered BEFORE the seeding below is awaited so that a toggle arriving + // during the disk read is still persisted. + handler.registrarPersistenciaEq(guardarEqActivoPersistido); + if (leerEqActivoPersistido != null) { + unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido)); + } + // iap-freemium-unlock (design.md Open Questions, orchestrator-resolved), + // generalizado en fix/android-auto-musica-local item 4: invalida + // activamente todo id de nivel raíz que un head unit pueda tener cacheado + // en vez de esperar a su propio re-bind — ver [registrarInvalidacionArbolAuto]. + registrarInvalidacionArbolAuto(() { handler.notificarHijosCambiaron(AudioService.browsableRootId); handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos); handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas); @@ -153,29 +233,56 @@ void registrarLimpiezaArranque(Future Function() limpieza) { _limpiezaArranqueGlobal = limpieza; } -/// Free -> premium Android Auto cache-invalidation hook (design.md Open -/// Questions, orchestrator-resolved): registered from [registrarHandler] so -/// `estado_entitlement.dart` can trigger it WITHOUT ever touching -/// `PluriWaveAudioHandler` directly (that type cannot be constructed in a -/// unit test — see [PluriWaveAudioHandler]'s own doc). `null` until a -/// handler registers (headless cold bind, or a widget-only test that never -/// wires audio) — [notificarDesbloqueoAuto] tolerates that silently. -void Function()? _alDesbloquearAutoGlobal; +/// Android Auto browse-cache invalidation hook (design.md Open Questions, +/// orchestrator-resolved): registered from [registrarHandler] so callers +/// can trigger it WITHOUT ever touching `PluriWaveAudioHandler` directly (a +/// layering choice — the entitlement layer has no business knowing the +/// handler type; it is not, as this doc used to claim, because the handler +/// cannot be constructed in a unit test, which is false — see +/// [construirControlesTransporte]). `null` until a handler registers +/// (headless cold bind, or a widget-only test that never wires audio) — +/// [invalidarArbolAuto] tolerates that silently. +/// +/// GENERALIZADO (fix/android-auto-musica-local, item 4): nació atado a la +/// transición free -> premium, y ese nombre escondía para qué sirve de +/// verdad. Android Auto CACHEA la raíz, así que hay que invalidarla cada +/// vez que el árbol pasa a poder mostrar algo que antes no podía. Hoy lo +/// disparan tres sitios: la compra premium (`estado_entitlement.dart`), la +/// primera vez que existe una View de verdad — es decir, cuando por fin hay +/// Activity y con ella el handler nativo de `pluriwave/file_actions` +/// (`main.dart`) — y la elección de carpeta de música local +/// (`pantalla_ajustes_musica_local.dart`). +void Function()? _invalidarArbolAutoGlobal; -/// Registers the hook [notificarDesbloqueoAuto] invokes. Exposed at module +/// Registers the hook [invalidarArbolAuto] invokes. Exposed at module /// level (like every other `registrar*` seam in this file) purely so tests /// can inject a fake hook and assert it fires, without instantiating a real /// [PluriWaveAudioHandler]. -void registrarNotificacionDesbloqueoAuto(void Function() alDesbloquear) { - _alDesbloquearAutoGlobal = alDesbloquear; +void registrarInvalidacionArbolAuto(void Function() alInvalidar) { + _invalidarArbolAutoGlobal = alInvalidar; } -/// Fires the registered free -> premium Android Auto invalidation hook, if +/// Fires the registered Android Auto browse-cache invalidation hook, if /// any. A no-op before a handler ever registers — never throws. -void notificarDesbloqueoAuto() { - _alDesbloquearAutoGlobal?.call(); +void invalidarArbolAuto() { + _invalidarArbolAutoGlobal?.call(); } +/// Whether a head unit has actually SUBSCRIBED to at least one browse id on +/// the live handler (fix/android-auto-musica-local, item 4 — corrected). +/// +/// This is the precondition that makes [invalidarArbolAuto] worth firing at +/// all: [PluriWaveAudioHandler.notificarHijosCambiaron] is +/// `_childrenSubjects[id]?.add(...)`, so invalidating before the car has +/// subscribed to ANYTHING is provably a silent no-op — which is exactly how +/// the old `View.maybeOf(context) != null` trigger managed to burn its +/// one-shot latch during the headless cold start and never fire again. +/// +/// Module-level, like every other seam in this file, so `main.dart` can ask +/// the question without importing the handler type, and `false` when no +/// handler has registered yet (headless cold bind, widget-only tests). +bool hayCocheSuscritoAlArbol() => _handlerGlobal?.hayCocheSuscrito ?? false; + /// Pure Android Auto play-path gate decision (iap-freemium-unlock, Design /// ADR-4): whether a station-switch dispatch (`playFromMediaId`, /// `playFromSearch`, `skipToNext`, `skipToPrevious`) must no-op for @@ -291,6 +398,95 @@ AudioProcessingState mapearEstadoProceso( /// media id). const accionEqToggle = 'eq_toggle'; +/// What an [accionEqToggle] tap resolves to (eq-estado-unico item C). +class DecisionToggleEq { + const DecisionToggleEq({ + required this.nuevoActivo, + required this.requiereLlamadaNativa, + }); + + /// The on/off value the handler must end up holding. + final bool nuevoActivo; + + /// Whether the native `AndroidEqualizer` effect must also be told. `false` + /// on a device with no usable Equalizer effect: the flag still flips (so + /// the car button never looks inert and the label still updates) but + /// nothing is pushed to the platform. + final bool requiereLlamadaNativa; + + @override + bool operator ==(Object other) => + other is DecisionToggleEq && + other.nuevoActivo == nuevoActivo && + other.requiereLlamadaNativa == requiereLlamadaNativa; + + @override + int get hashCode => Object.hash(nuevoActivo, requiereLlamadaNativa); + + @override + String toString() => + 'DecisionToggleEq(nuevoActivo: $nuevoActivo, ' + 'requiereLlamadaNativa: $requiereLlamadaNativa)'; +} + +/// The equalizer toggle decision, extracted out of `customAction` and +/// `setEcualizadorActivo` so it can be tested on its own (eq-estado-unico +/// item C — this dispatch had ZERO tests: `rg "customAction\(" test/` +/// returned nothing). +/// +/// Reported: «pulsando sobre el boton de ecualizar en Android Auto tampoco +/// activaba ni desactivaba». Note what this function deliberately does NOT +/// do: gate the flip on [eqDisponible]. The flag always flips, because the +/// notification/car label is built from it — a tap that changed nothing at +/// all is exactly the "the button does nothing" symptom. +DecisionToggleEq decidirToggleEq({ + required bool activoActual, + required bool eqDisponible, +}) => DecisionToggleEq( + nuevoActivo: !activoActual, + requiereLlamadaNativa: eqDisponible, +); + +/// Translates a gain on the app's fixed ±12 dB slider scale to the range the +/// device's native equalizer actually reports +/// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from +/// `Equalizer.getBandLevelRange()`). +/// +/// Top-level and pure so the mapping is testable without a device. +/// +/// THE DEFECT THIS REPLACES, and the likely source of the reported «suena muy +/// alto»: the previous implementation normalised across the whole range and +/// interpolated linearly, +/// +/// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels) +/// +/// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when +/// the range is symmetric, and Android guarantees no such thing — the +/// Equalizer contract only promises a min/max pair. On a device reporting, +/// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of +/// real boost: audibly louder, with the on/off button still reading "off" +/// and nothing in the UI to explain it. +/// +/// The contract here instead: 0 dB is always exactly 0, and each side of the +/// scale is stretched independently against its own end of the native range, +/// so a cut can never become a boost. A range with no headroom on one side +/// (or none at all) collapses that side to 0 rather than inverting it. +double mapearGananciaNativa( + double db, { + required double minDecibels, + required double maxDecibels, +}) { + final limitado = db.clamp(-12.0, 12.0); + if (limitado == 0) return 0; + if (limitado > 0) { + // Only genuine headroom above unity counts as boost. + final techo = maxDecibels > 0 ? maxDecibels : 0.0; + return (limitado / 12.0) * techo; + } + final suelo = minDecibels < 0 ? minDecibels : 0.0; + return (limitado.abs() / 12.0) * suelo; +} + /// Advances to the NEXT factory preset after [actual] in [presets] order /// (Design "EQ custom actions — cycling presets", item 4): wraps around /// after the last one. When [actual] is not found in [presets] (e.g. a @@ -381,9 +577,18 @@ List controlesEcualizadorPersonalizados({ /// its shape. `servicio_audio_controles_notificacion_test.dart` used to /// re-declare the list inline, which meant it stayed green while asserting a /// shape `lib/` no longer produced — a guard that cannot see the thing it -/// guards. `PluriWaveAudioHandler` itself cannot be instantiated in a unit -/// test (a real `just_audio.AudioPlayer` needs platform MethodChannels), so -/// pulling the pure part out is the only way to test the real thing. +/// guards. +/// +/// This doc used to add that `PluriWaveAudioHandler` "cannot be instantiated +/// in a unit test (a real `just_audio.AudioPlayer` needs platform +/// MethodChannels)". That is NOT true with just_audio 0.9.46: +/// `AudioPlayer`'s constructor resolves its platform lazily and only becomes +/// `_active` on a `setUrl`, so the handler constructs fine under +/// `flutter test` and `servicio_audio_eq_estado_unico_test.dart` drives its +/// real `customAction` dispatch. Only calls that reach the native effect stay +/// out of reach (they sit behind `_eqDisponible`, `false` off-device). +/// Extracting the pure part is still worth it — it is cheaper and states the +/// contract explicitly — but it is no longer the ONLY way. /// /// ORDER MATTERS, and only for the car. /// @@ -582,11 +787,18 @@ class ServicioAudio { bool get ecualizadorDisponible => _handler.ecualizadorDisponible; PresetEcualizador get presetActual => _handler.presetActual; - /// Forwards the handler's own on/off flag (eq-sync-superficies): a - /// car/notification toggle (`accionEqToggle`) mutates - /// `PluriWaveAudioHandler._ecualizadorActivo` directly, bypassing - /// [setEcualizadorActivo] entirely. [EstadoEcualizador] polls this getter - /// on every [estadoStream] tick to detect and resync that divergence. + /// Forwards the handler's own on/off flag, which since eq-estado-unico is + /// the flag's SINGLE in-memory owner: `EstadoEcualizador._activo` is a + /// display mirror of this getter and `ServicioEcualizador` is its durable + /// copy. + /// + /// Corrects a stale claim that stood here: a car/notification toggle does + /// NOT bypass [setEcualizadorActivo]. `PluriWaveAudioHandler.customAction` + /// resolves `accionEqToggle` through `decidirToggleEq` and then calls + /// `setEcualizadorActivo` — the same entry point the phone settings screen + /// uses — so every surface shares one write path, and that path is what + /// persists the value. [EstadoEcualizador] still polls this getter on every + /// [estadoStream] tick, but only to keep its own display in sync. bool get ecualizadorActivo => _handler.ecualizadorActivo; Future aplicarPreset(PresetEcualizador preset) => @@ -702,13 +914,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// `BehaviorSubject` per id, created lazily on first subscription; /// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic) /// value to trigger the platform notification for that id. + /// + /// SIN semilla (fix/android-auto-musica-local, item 5). Antes se creaba + /// con `.seeded({})`, y un `BehaviorSubject` reenvía su + /// valor actual a cada nuevo suscriptor: el listener interno de + /// `audio_service` se suscribe la primera vez que el head unit navega un + /// id, recibía esa semilla al instante y la reenviaba como + /// `notifyChildrenChanged` — o sea, el primer browse de CADA id disparaba + /// un `getChildren` extra que nadie pidió. En la raíz eso era un segundo + /// round trip de permisos por `pluriwave/file_actions`, justo en la ruta + /// que ya estaba fallando en el motor sin Activity. Sin semilla no hay + /// nada que reenviar y la invalidación explícita sigue igual. final _childrenSubjects = >>{}; @override ValueStream> subscribeToChildren(String parentMediaId) => _childrenSubjects.putIfAbsent( parentMediaId, - () => BehaviorSubject>.seeded({}), + BehaviorSubject>.new, ); /// Invalidates a head unit's cached browse listing for [parentMediaId] @@ -719,6 +942,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler _childrenSubjects[parentMediaId]?.add({}); } + /// True once a head unit has subscribed to at least one browse id, i.e. + /// once [notificarHijosCambiaron] can actually reach the car. Read through + /// the module-level [hayCocheSuscritoAlArbol]; see its doc for why the + /// browse-tree invalidation is gated on it. + bool get hayCocheSuscrito => _childrenSubjects.isNotEmpty; + /// True while the handler is inside the reconnect window. [ServicioAudio] /// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading /// indicator instead of an error during retries (S7-R3). @@ -728,9 +957,42 @@ class PluriWaveAudioHandler extends BaseAudioHandler AndroidEqualizer? get ecualizador => _eq; bool _eqDisponible = false; bool get ecualizadorDisponible => _eqDisponible; - bool _ecualizadorActivo = true; + + /// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE + /// in-memory owner. `EstadoEcualizador._activo` is now a pure display + /// mirror of this field, and `ServicioEcualizador` is its durable copy. + /// + /// It used to be an unconditional `true`, which is exactly why a headless + /// Android Auto engine played with the equalizer on while both the phone + /// UI and disk said off. It now starts from whatever the last disk read + /// produced ([_eqActivoPersistido]); [registrarHandler] then seeds it + /// again from the read port, which is the authoritative path. + bool _ecualizadorActivo = estadoEqInicial(persistido: _eqActivoPersistido); bool get ecualizadorActivo => _ecualizadorActivo; + /// Write port for [_ecualizadorActivo] (eq-estado-unico item B). Injected + /// by [registrarHandler] so a car/notification toggle is persisted even + /// when no `EstadoEcualizador` has ever been built — which is precisely + /// the headless-bind case where the divergence used to be created. + GuardarEqActivoPersistido? _persistirEqActivo; + + /// See [_persistirEqActivo]. Accepts `null` to clear the port (the default + /// for every caller that has no disk). + void registrarPersistenciaEq(GuardarEqActivoPersistido? guardar) { + _persistirEqActivo = guardar; + } + + /// The player's live position, used to keep `updatePosition` honest on + /// every `playbackState` push. Exposed so tests can assert the re-push + /// without reaching into the private player. + Duration get posicionActual => _player.position; + + /// True while the platform player is attached, i.e. while `just_audio` + /// actually forwards `AudioEffect.setEnabled` to the device + /// (`just_audio.dart:3842-3848` gates it on `_player._active`). Tracked so + /// [debeReasertarEcualizadorNativo] can spot the idle -> active edge. + bool _reproductorActivo = false; + PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; int? get androidAudioSessionId => _androidAudioSessionId; @@ -758,94 +1020,123 @@ class PluriWaveAudioHandler extends BaseAudioHandler ); } - void _conectarStreamsPlayer() { - _estadoPlayerSub = _player.playerStateStream.listen((state) { - final playing = state.playing; - final proc = state.processingState; - // First line of the listener (Design ADR-3, Phase 3 task 3.3): - // double-gated on `completed` + an active local queue, so this is a - // no-op for radio (which never emits `completed`) and for - // single-track local playback (which never sets `_colaLocal`). - _manejarFinPista(proc); - if (playing && proc == ProcessingState.ready) { - // Successful (re)connection: reset the backoff so the next stall - // starts over, and leave the reconnect window (S7-R7). - _reconexion.restablecer(); - _reconectando = false; - // Local queue (Design ADR-3): the next queued track reached a - // stable playing state — clear the re-entry latch so a LATER - // completion can advance again. A no-op for radio, which never - // sets `_avanzandoCola`. - _avanzandoCola = false; - } - // Local queue transport (Design "Transport wiring"): skip controls - // are only offered while a queue is active — when `_colaLocal` is - // `null` this list/set/index is byte-identical to the pre-change - // radio behavior (regression guard). - final colaActiva = _colaLocal != null; - playbackState.add( - playbackState.value.copyWith( - controls: _controlesTransporte( - colaActiva: colaActiva, - playing: playing, - ), - // Android for Cars, "Enable playback control": «Android Auto and - // AAOS display playback controls based on the actions that are - // enabled in the PlaybackStateCompat object. By default, your app - // must support the following actions: ACTION_PLAY, ACTION_PAUSE, - // ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.» - // - // This set had carried only `seek` + `stop` since the very first - // commit, so the required transport actions were never advertised. - // The car got away with it for a long time — but Android Auto is a - // separate app that updates itself, so a tolerance it used to have - // can disappear without a single line changing on our side. That - // matches the report exactly: "it used to work, and in the latest - // versions it doesn't", with no audio commit in between that could - // explain it. - // - // The phone notification never depended on any of this: it builds - // its play/pause button from `controls`, which is why the symptom - // is car-only. - systemActions: { - MediaAction.play, - MediaAction.pause, - MediaAction.playPause, - MediaAction.stop, - MediaAction.playFromMediaId, - MediaAction.playFromSearch, - MediaAction.seek, - // Previous/next are advertised ALWAYS now, not only for a local - // queue. Android Auto reserves those two slots and only hands the - // space to custom actions when the app declares no support, so - // this is what puts prev/next on the car's transport row -- and - // `skipToNext`/`skipToPrevious` fall back to station-to-station - // skipping when there is no queue, so neither button is inert. - MediaAction.skipToPrevious, - MediaAction.skipToNext, - }, - androidCompactActionIndices: [colaActiva ? 1 : 0], - processingState: mapearEstadoProceso( - proc, - cambiandoFuente: _cambiandoFuente, - ), + /// The `playerStateStream` listener's whole body, as a named method. + /// + /// Extracted verbatim so a test can drive a real player-state transition + /// through the REAL handler. It used to be an anonymous closure, which is + /// why the equalizer's idle -> active re-assert below shipped with + /// producer-only coverage: [debeReasertarEcualizadorNativo] had five tests + /// and not one of them could reach this wiring, so deleting the re-assert + /// block left the suite green. The only thing left outside a test's reach + /// is the one-line `.listen(manejarEstadoPlayer)` subscription in + /// [_conectarStreamsPlayer]. + @visibleForTesting + void manejarEstadoPlayer(PlayerState state) { + final playing = state.playing; + final proc = state.processingState; + // First line of the listener (Design ADR-3, Phase 3 task 3.3): + // double-gated on `completed` + an active local queue, so this is a + // no-op for radio (which never emits `completed`) and for + // single-track local playback (which never sets `_colaLocal`). + _manejarFinPista(proc); + // eq-estado-unico item D: `AudioEffect.setEnabled` is a no-op while + // the platform player is detached, so any toggle made while stopped + // never landed natively. Re-assert the value we own on the idle -> + // active edge. See [debeReasertarEcualizadorNativo]. + if (debeReasertarEcualizadorNativo( + estado: proc, + reproductorActivoAntes: _reproductorActivo, + eqDisponible: _eqDisponible, + )) { + unawaited(_reasertarEcualizadorNativo()); + } + // Turns a stream of many events into a single idle -> active EDGE: the + // re-assert above fires once per activation, not on every event. + _reproductorActivo = proc != ProcessingState.idle; + if (playing && proc == ProcessingState.ready) { + // Successful (re)connection: reset the backoff so the next stall + // starts over, and leave the reconnect window (S7-R7). + _reconexion.restablecer(); + _reconectando = false; + // Local queue (Design ADR-3): the next queued track reached a + // stable playing state — clear the re-entry latch so a LATER + // completion can advance again. A no-op for radio, which never + // sets `_avanzandoCola`. + _avanzandoCola = false; + } + // Local queue transport (Design "Transport wiring"): skip controls + // are only offered while a queue is active — when `_colaLocal` is + // `null` this list/set/index is byte-identical to the pre-change + // radio behavior (regression guard). + final colaActiva = _colaLocal != null; + playbackState.add( + playbackState.value.copyWith( + controls: _controlesTransporte( + colaActiva: colaActiva, playing: playing, - // Reported: in Android Auto the progress bar and the time labels of - // a local track never move. `updatePosition` was NEVER set anywhere - // in this file, so it stayed at its `Duration.zero` default while - // `copyWith` refreshed `updateTime` to now on every push - // (audio_service.dart:411-413, :256). A client extrapolates - // `updatePosition + (now - updateTime) * speed`, so it was told - // "position 0, as of right now" over and over — a bar pinned at the - // start. The phone UI never noticed because it reads - // `_player.positionStream` directly. - updatePosition: _player.position, - bufferedPosition: _player.bufferedPosition, - speed: _player.speed, ), - ); - _trazarEstadoPublicado(); - }); + // Android for Cars, "Enable playback control": «Android Auto and + // AAOS display playback controls based on the actions that are + // enabled in the PlaybackStateCompat object. By default, your app + // must support the following actions: ACTION_PLAY, ACTION_PAUSE, + // ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.» + // + // This set had carried only `seek` + `stop` since the very first + // commit, so the required transport actions were never advertised. + // The car got away with it for a long time — but Android Auto is a + // separate app that updates itself, so a tolerance it used to have + // can disappear without a single line changing on our side. That + // matches the report exactly: "it used to work, and in the latest + // versions it doesn't", with no audio commit in between that could + // explain it. + // + // The phone notification never depended on any of this: it builds + // its play/pause button from `controls`, which is why the symptom + // is car-only. + systemActions: { + MediaAction.play, + MediaAction.pause, + MediaAction.playPause, + MediaAction.stop, + MediaAction.playFromMediaId, + MediaAction.playFromSearch, + MediaAction.seek, + // Previous/next are advertised ALWAYS now, not only for a local + // queue. Android Auto reserves those two slots and only hands the + // space to custom actions when the app declares no support, so + // this is what puts prev/next on the car's transport row -- and + // `skipToNext`/`skipToPrevious` fall back to station-to-station + // skipping when there is no queue, so neither button is inert. + MediaAction.skipToPrevious, + MediaAction.skipToNext, + }, + androidCompactActionIndices: [colaActiva ? 1 : 0], + processingState: mapearEstadoProceso( + proc, + cambiandoFuente: _cambiandoFuente, + ), + playing: playing, + // Reported: in Android Auto the progress bar and the time labels of + // a local track never move. `updatePosition` was NEVER set anywhere + // in this file, so it stayed at its `Duration.zero` default while + // `copyWith` refreshed `updateTime` to now on every push + // (audio_service.dart:411-413, :256). A client extrapolates + // `updatePosition + (now - updateTime) * speed`, so it was told + // "position 0, as of right now" over and over — a bar pinned at the + // start. The phone UI never noticed because it reads + // `_player.positionStream` directly. + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + ), + ); + _trazarEstadoPublicado(); + } + + void _conectarStreamsPlayer() { + _estadoPlayerSub = _player.playerStateStream.listen( + manejarEstadoPlayer, + ); _bufferedSub = _player.bufferedPositionStream.listen((pos) { playbackState.add( @@ -964,10 +1255,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler colaActiva: _colaLocal != null, playing: playbackState.value.playing, ), + // Must ride along, exactly as in the two sibling emissions in + // `_conectarStreamsPlayer`: `copyWith` stamps a fresh `updateTime` + // but keeps the OLD `updatePosition`, so a push without it tells the + // client "you are at , as of right now". Every + // equalizer tap therefore snapped the car's progress bar backwards + // to wherever it stood at the last real player event. + updatePosition: _player.position, ), ); } + /// Re-states [_ecualizadorActivo] (and the current preset's gains) on the + /// native effect now that the platform player is attached again + /// (eq-estado-unico item D). Delegates to [_activarEcualizador], which is + /// already idempotent and already re-asserts the CURRENT value rather than + /// forcing the equalizer on. + Future _reasertarEcualizadorNativo() async { + _reasercionesEcualizador++; + debugPrint( + '[PluriWave][ServicioAudio] reasertando EQ nativo ' + 'activo=$_ecualizadorActivo', + ); + await _activarEcualizador(); + } + /// Gestiona cualquier error de reproducción de ExoPlayer. /// /// Network-class failures while the user still intends to play enter the @@ -1360,6 +1672,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler _eq = AndroidEqualizer(); _eqDisponible = false; + // Resets alongside its siblings above: the fresh player starts detached, + // so the next non-idle event is a genuine idle -> active edge that + // [debeReasertarEcualizadorNativo] must see. A value stuck at `true` + // across the rebuild would swallow exactly the re-assert this exists for. + _reproductorActivo = false; _androidAudioSessionId = null; _ultimaSessionIdEq = null; _player = _crearPlayer(); @@ -1384,6 +1701,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler try { final params = await _eq.parameters; _eqDisponible = params.bands.isNotEmpty; + // eq-estado-unico item E: the ONE number that decides whether + // [mapearGananciaNativa] can be silently boosting a FLAT preset on + // this device. `Equalizer.getBandLevelRange()` is not required to be + // symmetric, and nothing else in the app can observe what it returned. + // `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in + // the release build, which is the only one that ever runs in a car: + // + // adb logcat | grep PluriWave + debugPrint( + '[PluriWave][ServicioAudio] eq rango bandas=${params.bands.length} ' + 'minDecibels=${params.minDecibels} maxDecibels=${params.maxDecibels} ' + 'activo=$_ecualizadorActivo preset=${_presetActual.nombre}', + ); if (_eqDisponible) { await _eq.setEnabled(_ecualizadorActivo); await aplicarPreset(_presetActual); @@ -1411,6 +1741,58 @@ class PluriWaveAudioHandler extends BaseAudioHandler required bool eqDisponible, }) => sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible; + /// Pure decision for re-asserting the on/off state on the NATIVE effect + /// when the platform player becomes active again (eq-estado-unico item D). + /// No side effects. + /// + /// Why it is needed: `just_audio`'s `AudioEffect.setEnabled` + /// (`just_audio.dart:3842-3848`) only reaches the platform while + /// `_player._active` is true. After a `stop()` — or any transition to + /// `idle` — the Dart-side intent is updated but the native effect is not. + /// A user who turns the equalizer off while stopped, then presses play, + /// would get audio that is still equalized with the button reading "off". + /// + /// The native effect is treated as WRITE-ONLY throughout: `just_audio` + /// exposes no read-back of `Equalizer.getEnabled()`, so this never + /// compares against the device — it simply re-states the value the app + /// already owns, which is idempotent and cheap. + /// + /// [reproductorActivoAntes] is the tracked state BEFORE [estado] arrived, + /// so only the idle -> active edge fires; a player already active does not + /// re-assert on every one of its many events. + @visibleForTesting + static bool debeReasertarEcualizadorNativo({ + required ProcessingState estado, + required bool reproductorActivoAntes, + required bool eqDisponible, + }) => + eqDisponible && + !reproductorActivoAntes && + estado != ProcessingState.idle; + + /// Forces [_eqDisponible] for a test. + /// + /// `_eqDisponible` is only ever set from `AndroidEqualizer.parameters` + /// (see [_activarEcualizador]), whose future only completes on a real + /// device, so off-device it is permanently `false` — and every EQ path + /// worth testing is gated on it. Without this seam + /// [manejarEstadoPlayer]'s re-assert can only ever be exercised on its + /// false branch. + @visibleForTesting + void simularEcualizadorDisponible(bool disponible) { + _eqDisponible = disponible; + } + + /// How many times [_reasertarEcualizadorNativo] has actually run. + /// + /// The native call it makes is unobservable off-device (see + /// [simularEcualizadorDisponible]), so this counter is the only evidence a + /// test can assert on that the re-assert HAPPENED, rather than that the + /// predicate would have said yes. + @visibleForTesting + int get reasercionesEcualizador => _reasercionesEcualizador; + int _reasercionesEcualizador = 0; + /// Aplica un preset al ecualizador nativo Android. Future aplicarPreset(PresetEcualizador preset) async { _presetActual = preset; @@ -1425,7 +1807,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler i++ ) { await params.bands[i].setGain( - _mapearGananciaNativa( + mapearGananciaNativa( preset.bandas[i], minDecibels: params.minDecibels, maxDecibels: params.maxDecibels, @@ -1453,7 +1835,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler final params = await _eq.parameters; if (index < params.bands.length) { await params.bands[index].setGain( - _mapearGananciaNativa( + mapearGananciaNativa( db, minDecibels: params.minDecibels, maxDecibels: params.maxDecibels, @@ -1463,16 +1845,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler } catch (_) {} } - double _mapearGananciaNativa( - double db, { - required double minDecibels, - required double maxDecibels, - }) { - final normalizado = ((db.clamp(-12.0, 12.0) + 12.0) / 24.0).clamp(0.0, 1.0); - return minDecibels + (normalizado * (maxDecibels - minDecibels)); - } + /// Sets the equalizer on/off state AND persists it — the single entry + /// point every surface goes through (phone settings via + /// `EstadoEcualizador`, the notification, and the car's [accionEqToggle]). + Future setEcualizadorActivo(bool activo) => + _aplicarEcualizadorActivo(activo, persistir: true); - Future setEcualizadorActivo(bool activo) async { + /// Adopts a value that came FROM disk (eq-estado-unico item A). Identical + /// to [setEcualizadorActivo] except that it does not write back — seeding + /// is a read, and echoing it to disk would only add a pointless write on + /// every engine start. + Future sembrarEcualizadorActivo(bool activo) => + _aplicarEcualizadorActivo(activo, persistir: false); + + Future _aplicarEcualizadorActivo( + bool activo, { + required bool persistir, + }) async { _ecualizadorActivo = activo; if (_eqDisponible) { try { @@ -1486,6 +1875,25 @@ class PluriWaveAudioHandler extends BaseAudioHandler // of WHO toggled it (a car customAction tap or the phone settings // screen via EstadoEcualizador). _actualizarControlesEq(); + if (!persistir) return; + _eqActivoPersistido = activo; + final guardar = _persistirEqActivo; + if (guardar == null) return; + // eq-estado-unico item B: the handler owns this write now. It used to + // be `EstadoEcualizador._resincronizarConHandler`'s job, which meant a + // toggle made in the car or from the notification was only saved if a + // phone UI object happened to exist — on a headless Android Auto engine + // it never did, so the car toggle was silently lost on every restart. + // + // Failures are swallowed on purpose: a full disk must not turn the + // equalizer button into a crash. + try { + await guardar(activo); + } catch (e) { + debugPrint( + '[PluriWave][ServicioAudio] no se pudo persistir el estado EQ: $e', + ); + } } Future setVolumen(double vol) async { @@ -1717,7 +2125,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler ]) async { switch (name) { case accionEqToggle: - await setEcualizadorActivo(!_ecualizadorActivo); + final decision = decidirToggleEq( + activoActual: _ecualizadorActivo, + eqDisponible: _eqDisponible, + ); + debugPrint( + '[PluriWave][ServicioAudio] customAction $name -> ' + 'activo=${decision.nuevoActivo} ' + 'nativo=${decision.requiereLlamadaNativa}', + ); + await setEcualizadorActivo(decision.nuevoActivo); } } @@ -1777,8 +2194,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler if (bloqueada != null) return bloqueada; final fuenteLocal = _fuenteMusicaLocalGlobal; if (parentMediaId == AudioService.browsableRootId) { + // fix/android-auto-musica-local: la RAÍZ ya no se decide con el + // round trip de permisos. Ese round trip viaja por + // `pluriwave/file_actions`, cuyo handler nativo solo se registra en + // `MainActivity.configureFlutterEngine` — en el motor headless que + // Android Auto levanta sin Activity no existe, la llamada lanzaba + // `MissingPluginException` y el nodo desaparecía del árbol. Y como + // el head unit CACHEA la raíz, seguía desaparecido toda la sesión. + // + // Ahora solo `noConfigurada` (sin URI persistida, o permiso + // revocado confirmado por el nativo) oculta el nodo; + // `canalNoDisponible` lo mantiene, y es el SUBÁRBOL quien explica + // el problema (`hijosMusicaLocal`) en vez de dejar una carpeta + // vacía. final incluirMusicaLocal = - fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada(); + fuenteLocal != null && + await fuenteLocal.estadoCarpeta() != + EstadoCarpetaLocal.noConfigurada; return constructor.raiz( incluirMusicaLocal: incluirMusicaLocal, premium: premium, diff --git a/lib/servicios/servicio_compras.dart b/lib/servicios/servicio_compras.dart index 5e33c73..c02405e 100644 --- a/lib/servicios/servicio_compras.dart +++ b/lib/servicios/servicio_compras.dart @@ -127,12 +127,19 @@ class ServicioComprasPlayBilling implements PuertoCompras { void _alRecibirCompras(List compras) { if (compras.isEmpty) { - // `restorePurchases()` with nothing to restore completes without ever - // pushing a PurchaseDetails (Spec "Restore finds nothing") — there is - // no per-call correlation in this stream, so this fires on ANY empty - // batch. In practice `queryPastPurchases`/`restorePurchases` on an - // account with nothing to restore is the only source of an empty - // batch this stream would ever emit. + // `restorePurchases()` with nothing to restore pushes an EMPTY batch + // (`in_app_purchase_android` does `_purchaseUpdatedController.add( + // pastPurchases)` unconditionally) — there is no per-call correlation + // in this stream, so this fires on ANY empty batch. In practice + // `restorePurchases` on an account with nothing to restore is the only + // source of an empty batch this stream would ever emit. + // + // Returning silently here (as this did before) left + // [TipoEventoCompra.noEncontrada] NEVER emitted, so + // `EstadoEntitlement._compraEnCurso` stayed `true` forever and + // `hoja_premium.dart` kept BOTH buttons disabled — restore AND buy. + // A paywall that cannot be paid. + _eventos.add(const EventoCompra(TipoEventoCompra.noEncontrada)); return; } for (final compra in compras) { diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index d371e0b..10bdb16 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -238,6 +238,24 @@ class ServicioEcualizador { await prefs.setBool(_keyActivo, activo); } + /// The persisted equalizer on/off flag, or `null` when the user has never + /// touched the toggle. + /// + /// Deliberately narrower than [cargar] (eq-estado-unico item A): it reads + /// ONE key and runs none of the migrations, because its caller is + /// `registrarHandler`, on the audio bootstrap path of EVERY engine — + /// including the headless one Android Auto starts, where there is no + /// widget tree and `EstadoEcualizador` never exists. It must stay cheap + /// and it must never mutate anything. + /// + /// `null` is preserved rather than collapsed to a default so that + /// `estadoEqInicial` — not this service — owns the "never persisted" + /// policy in exactly one place. + Future leerActivo() async { + final prefs = await _resolverPrefs(); + return prefs.getBool(_keyActivo); + } + Future eliminarPorEmisora(String uuid) async { final prefs = await _resolverPrefs(); final mapa = _leerPresetsPorEmisora(prefs); diff --git a/packages/pluriwave_file_actions/android/build.gradle.kts b/packages/pluriwave_file_actions/android/build.gradle.kts new file mode 100644 index 0000000..33ac55b --- /dev/null +++ b/packages/pluriwave_file_actions/android/build.gradle.kts @@ -0,0 +1,44 @@ +group = "es.freetimelab.pluriwave.fileactions" +version = "1.0-SNAPSHOT" + +// Sin bloque `buildscript` a proposito: este modulo solo se construye desde +// `android/settings.gradle.kts` de la app, cuyo `pluginManagement` ya pone +// AGP 8.11.1 y Kotlin 2.2.20 en el classpath compartido. Declarar aqui otro +// classpath de AGP arriesga un choque de versiones con el de la app. +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "es.freetimelab.pluriwave.fileactions" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + } + + defaultConfig { + // Igual que `flutter.minSdkVersion` en Flutter 3.44 (FlutterExtension.kt). + minSdk = 24 + } +} + +dependencies { + // `androidx.core.content.FileProvider`, para servir la caratula embebida + // cacheada desde la autoridad `${applicationId}.fileprovider` que declara + // el manifiesto del modulo de app. + implementation("androidx.core:core-ktx:1.16.0") +} diff --git a/packages/pluriwave_file_actions/android/settings.gradle b/packages/pluriwave_file_actions/android/settings.gradle new file mode 100644 index 0000000..7f6113d --- /dev/null +++ b/packages/pluriwave_file_actions/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'pluriwave_file_actions' diff --git a/packages/pluriwave_file_actions/android/src/main/AndroidManifest.xml b/packages/pluriwave_file_actions/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a2f47b6 --- /dev/null +++ b/packages/pluriwave_file_actions/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/packages/pluriwave_file_actions/android/src/main/kotlin/es/freetimelab/pluriwave/fileactions/FileActionsHandler.kt b/packages/pluriwave_file_actions/android/src/main/kotlin/es/freetimelab/pluriwave/fileactions/FileActionsHandler.kt new file mode 100644 index 0000000..205b335 --- /dev/null +++ b/packages/pluriwave_file_actions/android/src/main/kotlin/es/freetimelab/pluriwave/fileactions/FileActionsHandler.kt @@ -0,0 +1,363 @@ +package es.freetimelab.pluriwave.fileactions + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.util.Log +import androidx.core.content.FileProvider +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File + +/** + * Activity-FREE half of the `pluriwave/file_actions` channel + * (fix/android-auto-musica-local, item 3). + * + * These four methods -- `hasPersistedPermission`, `listAudioChildren`, + * `resolvePlayableUri`, `readAudioMetadataBatch` -- only ever needed a + * [ContentResolver][android.content.ContentResolver], which is an + * app-scoped API: they never touch an Activity, a window, or + * `startActivityForResult`. They were nevertheless trapped inside + * `MainActivity.configureFlutterEngine`, the ONE place in the whole repo + * that installed a handler on this channel. + * + * That is the reported bug: when Android Auto binds the MediaBrowserService + * before the phone app has been opened, `audio_service` builds a bare + * `FlutterEngine` with no Activity, `configureFlutterEngine` never runs, the + * channel has no handler at all, and every `invokeMethod` on it throws + * `MissingPluginException`. Dart could not tell that apart from "permission + * revoked" and silently dropped "Musica Local" from the car's browse tree. + * + * Living in a real plugin package is what makes them registerable on ANY + * engine: [PluriWaveFileActionsPlugin] is listed in + * `GeneratedPluginRegistrant`, which the `FlutterEngine(Context)` constructor + * runs by itself, headless engine included. An app-module class never could. + * + * `pickMusicFolder` and the recordings-folder intents are deliberately NOT + * here: they need `startActivityForResult` / `startActivity` plus an + * `onActivityResult` callback, so they stay on `MainActivity` in the app + * module, which delegates everything else to this same class -- so both + * engines answer the four SAF methods identically, from ONE implementation. + */ +class FileActionsHandler(private val context: Context) { + + private val tag = "PluriWave" + + /** + * Answers [call] if it is one of the Activity-free methods, replying + * through [result] and returning `true`. Returns `false` -- WITHOUT + * touching [result] -- for anything else, so `MainActivity` can fall + * through to its own Activity-bound methods on the same channel. + */ + fun manejar(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "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) + ) + } + "readAudioMetadataBatch" -> { + val treeUri = call.argument("treeUri") + val documentIds = call.argument>("documentIds") ?: emptyList() + Log.d( + tag, + "file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}" + ) + if (treeUri.isNullOrBlank()) { + result.success(emptyList>()) + } else { + result.success(readAudioMetadataBatch(treeUri, documentIds)) + } + } + else -> return false + } + return true + } + + /** + * Traza el rechazo de un metodo que exige Activity, para que el log + * distinga "no hay Activity aqui" de "el canal no existe". Lo usa + * [PluriWaveFileActionsPlugin] antes de responder `notImplemented()`. + */ + fun trazarNoDisponibleSinActividad(metodo: String) { + Log.d(tag, "file_actions.$metodo needs an Activity; not available here") + } + + /** + * 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 types 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>() + context.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. + * + * Persisted URI grants are taken by the app, not by the Activity, so + * this answers identically on an engine with no Activity -- which is + * exactly why it belongs in this class. + */ + private fun hasPersistedPermission(treeUri: String): Boolean { + return try { + val parsed = Uri.parse(treeUri) + context.contentResolver.persistedUriPermissions.any { + it.uri == parsed && it.isReadPermission + } + } catch (error: Throwable) { + Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error) + false + } + } + + /** + * Batched embedded-metadata extraction (android-auto-local-music-phase2, + * static review only -- Design "Interfaces / Contracts"): for each of + * [documentIds], extracts title/artist/bitrate/sample-rate and the + * embedded picture via [extraerMetadatosPista]. Never throws across the + * channel boundary -- a malformed [treeUri] (or any other unexpected + * failure) degrades the WHOLE call to `[]`; a per-entry failure is + * already isolated inside [extraerMetadatosPista]. + */ + private fun readAudioMetadataBatch(treeUri: String, documentIds: List): List> { + return try { + val parsedTree = Uri.parse(treeUri) + documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) } + } catch (error: Throwable) { + Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error) + emptyList() + } + } + + /** + * Extracts one [documentId]'s embedded metadata via + * [MediaMetadataRetriever] (android-auto-local-music-phase2, static + * review only -- mirrors [listAudioChildren]/[resolvePlayableUri]'s + * never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no + * public constant below API 31) is gated behind + * `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is + * available since API 10 and read unconditionally. A resolvable + * embedded picture is handed to [cachearArteEmbebido]; art-cache + * failures degrade that single field to `null` without failing the + * whole entry. On ANY failure for this [documentId] (unsupported + * format, permission edge case, corrupt file), the row degrades to an + * all-null-but-`documentId` entry instead of throwing -- + * `retriever.release()` always runs via `finally`. + */ + private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map { + val retriever = MediaMetadataRetriever() + return try { + val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId) + retriever.setDataSource(context, documentUri) + + val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) + val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) + val bitrate = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) + ?.toIntOrNull() + val sampleRate = if (Build.VERSION.SDK_INT >= 31) { + // METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no + // public constant exists on this minSdk, so the raw key is + // used directly, guarded by the version check above. + retriever.extractMetadata(38)?.toIntOrNull() + } else { + null + } + val artUri = try { + retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) } + } catch (error: Throwable) { + Log.e( + tag, + "file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId", + error + ) + null + } + + mapOf( + "documentId" to documentId, + "titulo" to titulo, + "artista" to artista, + "bitrate" to bitrate, + "sampleRate" to sampleRate, + "artUri" to artUri + ) + } catch (error: Throwable) { + Log.e( + tag, + "file_actions.readAudioMetadataBatch entry failed documentId=$documentId", + error + ) + mapOf( + "documentId" to documentId, + "titulo" to null, + "artista" to null, + "bitrate" to null, + "sampleRate" to null, + "artUri" to null + ) + } finally { + try { + retriever.release() + } catch (_: Throwable) { + // release() failing is not actionable -- the retriever is + // being discarded regardless. + } + } + } + + /** + * Embedded-art cache write + LRU trim (android-auto-local-music-phase2, + * static review only -- Design ADR-1). Writes [picture] bytes to + * `cacheDir/pluriwave_art/` (skips the write when the + * file already exists, so re-parsing the same track reuses it), returns + * the `content://` URI served via the EXISTING + * `${applicationId}.fileprovider` authority + * (`AndroidManifest.xml`, `pluriwave_file_paths.xml`'s + * `cache-path path="."`) and trims `pluriwave_art/` via [trimArtCache]. + * `hash` uses SHA-256 hex because a raw `documentId` may contain + * `:`/`/`, which are illegal in filenames on most filesystems. + */ + private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? { + return try { + val artDir = File(context.cacheDir, "pluriwave_art").apply { mkdirs() } + val artFile = File(artDir, hashDocumentId(documentId)) + if (!artFile.exists()) { + artFile.writeBytes(picture) + } + trimArtCache(artDir) + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + artFile + ).toString() + } catch (error: Throwable) { + Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error) + null + } + } + + private fun hashDocumentId(documentId: String): String { + val digest = java.security.MessageDigest.getInstance("SHA-256") + val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } + + /** + * LRU-by-`lastModified` trim of the embedded-art cache dir (Design + * ADR-1): after each write, keeps at most 256 files AND at most 32 MB + * total, deleting the OLDEST-by-mtime entries first. Kept as a + * trivially reviewable loop -- these files are native-owned, so + * round-tripping names to Dart to pick deletions would add channel + * chatter with no testability gain (the `delete()` is native + * regardless, per ADR-1's rationale). + */ + private fun trimArtCache(artDir: File) { + val maxArchivos = 256 + val maxBytes = 32L * 1024 * 1024 + val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList() + ?: return + var totalBytes = archivos.sumOf { it.length() } + while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) { + val masViejo = archivos.removeAt(archivos.size - 1) + totalBytes -= masViejo.length() + masViejo.delete() + } + } + + companion object { + const val CHANNEL = "pluriwave/file_actions" + } +} diff --git a/packages/pluriwave_file_actions/android/src/main/kotlin/es/freetimelab/pluriwave/fileactions/PluriWaveFileActionsPlugin.kt b/packages/pluriwave_file_actions/android/src/main/kotlin/es/freetimelab/pluriwave/fileactions/PluriWaveFileActionsPlugin.kt new file mode 100644 index 0000000..4caff05 --- /dev/null +++ b/packages/pluriwave_file_actions/android/src/main/kotlin/es/freetimelab/pluriwave/fileactions/PluriWaveFileActionsPlugin.kt @@ -0,0 +1,76 @@ +package es.freetimelab.pluriwave.fileactions + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodChannel + +/** + * Registra `pluriwave/file_actions` en TODOS los FlutterEngine de la app. + * + * ## Por que un paquete plugin y no una clase del modulo de app + * + * `AudioServicePlugin.getFlutterEngine` (audio_service 0.18.18, + * `AudioServicePlugin.java:70-75`) construye el engine compartido con + * `new FlutterEngine(context.getApplicationContext())`. Ese constructor + * encadena hasta el maestro con `automaticallyRegisterPlugins = true` + * (verificado en el bytecode de `FlutterEngine`: `FlutterEngine(Context)` -> + * `FlutterEngine(Context, String[])` con `iconst_1`) y ejecuta + * `GeneratedPluginRegister.registerGeneratedPlugins(this)`, que reflexiona + * sobre `io.flutter.plugins.GeneratedPluginRegistrant`. + * + * Es decir: el engine headless registra PLUGINS por si mismo. Por eso + * `shared_preferences` y `just_audio` ya funcionan cuando Android Auto arranca + * la app con el movil bloqueado, y por eso un handler instalado unicamente en + * `MainActivity.configureFlutterEngine` no podia funcionar nunca ahi: sin + * Activity, `configureFlutterEngine` jamas se ejecuta, el canal se queda sin + * handler y cada `invokeMethod` lanza `MissingPluginException`. El nodo + * "Musica Local" desaparecia del arbol del coche. + * + * ## Reparto con MainActivity (decision deliberada) + * + * Este plugin atiende SOLO los cuatro metodos que no necesitan Activity + * ([FileActionsHandler.manejar]) y responde `notImplemented()` al resto, que es + * la respuesta correcta en un engine sin Activity: `pickMusicFolder`, + * `openDirectory`, `viewDirectory` y `openFile` no pueden funcionar sin una. + * + * En el engine CON Activity ambos escriben en el mismo canal, y gana el + * ultimo: el orden es estructural, no casual. `GeneratedPluginRegistrant` corre + * dentro del constructor de `FlutterEngine`, o sea antes de que el engine + * exista como argumento; `FlutterActivityAndFragmentDelegate.onAttach` llama a + * `host.configureFlutterEngine(flutterEngine)` despues, necesariamente con un + * engine ya construido. Asi que en una Activity siempre gana el handler + * combinado de `MainActivity`, que es el superconjunto: delega los cuatro + * metodos SAF en esta MISMA clase [FileActionsHandler] y añade los suyos. + * + * Se descarto hacer el plugin `ActivityAware` y moverle tambien los metodos con + * Activity: obligaria a trasladar ~400 lineas de malabares de `Intent` + * (FileProvider, DocumentsUI, fallbacks de `ACTION_VIEW`) mas el round trip de + * `onActivityResult`, todo ello sin cobertura de `flutter test`, para arreglar + * un bug que no los toca. El reparto de arriba deja UNA sola implementacion de + * la logica compartida, que era el objetivo real. + */ +class PluriWaveFileActionsPlugin : FlutterPlugin { + + private var canal: MethodChannel? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // applicationContext a proposito: los cuatro metodos solo usan el + // ContentResolver y la cacheDir del proceso, asi que sobreviven a + // cualquier Activity y valen igual en el engine headless. + val handler = FileActionsHandler(binding.applicationContext) + canal = MethodChannel(binding.binaryMessenger, FileActionsHandler.CHANNEL).apply { + setMethodCallHandler { call, result -> + if (!handler.manejar(call, result)) { + handler.trazarNoDisponibleSinActividad(call.method) + result.notImplemented() + } + } + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + // Solo ocurre al destruir el engine, de modo que nunca puede pisar el + // handler combinado que instala MainActivity sobre este mismo canal. + canal?.setMethodCallHandler(null) + canal = null + } +} diff --git a/packages/pluriwave_file_actions/lib/pluriwave_file_actions.dart b/packages/pluriwave_file_actions/lib/pluriwave_file_actions.dart new file mode 100644 index 0000000..f3007b4 --- /dev/null +++ b/packages/pluriwave_file_actions/lib/pluriwave_file_actions.dart @@ -0,0 +1,20 @@ +/// Este paquete NO expone API Dart. +/// +/// Existe por una sola razon estructural: un `MethodChannel` registrado desde +/// el modulo de aplicacion (`MainActivity.configureFlutterEngine`) solo vive en +/// el engine que tiene Activity. `audio_service` construye ademas un +/// FlutterEngine *headless* (`AudioServicePlugin.getFlutterEngine`, que llama a +/// `new FlutterEngine(context.getApplicationContext())`) cuando Android Auto +/// enlaza el `MediaBrowserService` con la app cerrada. Ese constructor invoca +/// `GeneratedPluginRegister.registerGeneratedPlugins`, que reflexiona sobre +/// `io.flutter.plugins.GeneratedPluginRegistrant`; es decir, registra los +/// PLUGINS, nunca una clase suelta del modulo de app. +/// +/// Empaquetando el lado nativo aqui, `GeneratedPluginRegistrant` lo instala en +/// los dos engines sin tocar el manifiesto ni forkear `audio_service`. +/// +/// Los llamantes Dart siguen usando `MethodChannel('pluriwave/file_actions')` +/// directamente (`lib/servicios/musica_local_auto.dart`, +/// `lib/estado/estado_grabacion.dart`), asi que este fichero se queda vacio a +/// proposito: cualquier fachada aqui seria una segunda forma de decir lo mismo. +library; diff --git a/packages/pluriwave_file_actions/pubspec.yaml b/packages/pluriwave_file_actions/pubspec.yaml new file mode 100644 index 0000000..a2e2bcd --- /dev/null +++ b/packages/pluriwave_file_actions/pubspec.yaml @@ -0,0 +1,23 @@ +name: pluriwave_file_actions +description: >- + Canal nativo `pluriwave/file_actions` de PluriWave empaquetado como plugin + Flutter, para que quede registrado en TODOS los FlutterEngine de la app -- + incluido el engine headless que audio_service crea cuando Android Auto + arranca el MediaBrowserService sin Activity. +version: 0.0.1 +publish_to: 'none' + +environment: + sdk: ^3.7.0 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: es.freetimelab.pluriwave.fileactions + pluginClass: PluriWaveFileActionsPlugin diff --git a/pubspec.lock b/pubspec.lock index c86ee33..f7d7860 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -629,6 +629,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pluriwave_file_actions: + dependency: "direct main" + description: + path: "packages/pluriwave_file_actions" + relative: true + source: path + version: "0.0.1" provider: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0b8f327..772e88c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,15 @@ dependencies: # In-app purchase in_app_purchase: ^3.2.0 + # Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para + # que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine + # headless que audio_service crea al arrancar desde Android Auto. Sin esto + # el canal solo existia en el engine de MainActivity y "Musica Local" + # desaparecia del arbol del coche. No expone API Dart: los llamantes siguen + # usando MethodChannel('pluriwave/file_actions'). + pluriwave_file_actions: + path: packages/pluriwave_file_actions + # Song recognition (activar con AudD key) # permission_handler: ^11.3.1 diff --git a/test/arranque_invalidacion_arbol_auto_test.dart b/test/arranque_invalidacion_arbol_auto_test.dart new file mode 100644 index 0000000..6915ed6 --- /dev/null +++ b/test/arranque_invalidacion_arbol_auto_test.dart @@ -0,0 +1,203 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/main.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador. +/// +/// El disparador anterior era `View.maybeOf(context) != null` dentro de +/// `didChangeDependencies`, con un latch de un solo uso y este comentario: +/// «Que exista una View significa que hay Activity». La premisa es FALSA. +/// +/// `runApp` envuelve SIEMPRE el árbol en una `View` construida a partir de +/// `platformDispatcher.implicitView`, y lanza `StateError` si no la hay +/// (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). Así que +/// en el motor headless que `audio_service` levanta sin Activity —el mismo +/// que demostrablemente llega a `runApp`, ver la doc de +/// `aplicarPoliticaOrientacion`— `View.maybeOf(context)` ya es no-nulo en el +/// PRIMER `didChangeDependencies`. +/// +/// Consecuencia: el latch se gastaba durante el arranque headless, justo en +/// el instante en que no podía conseguir nada (`_childrenSubjects` sigue +/// vacío, y `notificarHijosCambiaron` es `_childrenSubjects[id]?.add(...)`, +/// un no-op silencioso). Y no podía volver a dispararse nunca, porque +/// `didChangeDependencies` no se re-ejecuta cuando más tarde se adjunta una +/// Activity al MISMO motor cacheado. La vía de recuperación estaba muerta en +/// los dos motores. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () { + test('resumed + coche ya suscrito + latch libre invalida', () { + expect( + debeInvalidarArbolAutoAlReanudar( + estado: AppLifecycleState.resumed, + hayCocheSuscrito: true, + yaInvalidado: false, + ), + isTrue, + ); + }); + + test('sin suscripción del coche NO invalida — y por tanto no gasta el ' + 'latch en el arranque headless', () { + expect( + debeInvalidarArbolAutoAlReanudar( + estado: AppLifecycleState.resumed, + hayCocheSuscrito: false, + yaInvalidado: false, + ), + isFalse, + reason: + 'notificarHijosCambiaron solo empuja a un sujeto que ya existe, ' + 'así que invalidar antes de que el coche se suscriba a NADA es ' + 'demostrablemente un no-op', + ); + }); + + test('ningún estado del ciclo de vida distinto de resumed invalida', () { + for (final estado in [ + AppLifecycleState.detached, + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]) { + expect( + debeInvalidarArbolAutoAlReanudar( + estado: estado, + hayCocheSuscrito: true, + yaInvalidado: false, + ), + isFalse, + reason: + '$estado no significa «hay una Activity adjunta en primer ' + 'plano»; solo resumed lo significa', + ); + } + }); + + test('con el latch ya gastado no vuelve a invalidar (nada de tormenta ' + 'de notificaciones)', () { + expect( + debeInvalidarArbolAutoAlReanudar( + estado: AppLifecycleState.resumed, + hayCocheSuscrito: true, + yaInvalidado: true, + ), + isFalse, + ); + }); + }); + + group('OrientacionResponsiveApp — cableado real del disparador', () { + testWidgets('bajo pumpWidget/runApp SIEMPRE existe una View, que es ' + 'exactamente por qué el disparador anterior no valía', (tester) async { + await tester.pumpWidget( + const OrientacionResponsiveApp(child: SizedBox.shrink()), + ); + + expect( + View.maybeOf(tester.element(find.byType(SizedBox))), + isNotNull, + reason: + 'wrapWithDefaultView envuelve el árbol en una View o lanza ' + 'StateError: no hay ningún motor bajo runApp sin View', + ); + }); + + testWidgets('arranque headless: hay View desde el primer frame, pero sin ' + 'Activity ni coche suscrito el latch NO se gasta y sigue disponible ' + 'para cuando el coche por fin navegue', (tester) async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + var invalidaciones = 0; + registrarInvalidacionArbolAuto(() => invalidaciones++); + + await tester.pumpWidget( + const OrientacionResponsiveApp(child: SizedBox.shrink()), + ); + await tester.pump(); + + expect( + invalidaciones, + 0, + reason: 'el primer frame no prueba que haya Activity', + ); + + // Incluso si un evento de ciclo de vida llegara en frío: el coche no + // ha navegado nada todavía, así que no hay ningún sujeto al que + // empujar y el latch debe sobrevivir. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(invalidaciones, 0); + + // Ahora el coche navega la raíz (esto es lo que crea el sujeto), y la + // siguiente vuelta a primer plano sí encuentra algo que invalidar. + handler.subscribeToChildren(AudioService.browsableRootId); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(invalidaciones, 1); + }); + + testWidgets('con el coche YA suscrito, adjuntar una Activity (resumed) ' + 'empuja de verdad por el stream de hijos de la raíz', (tester) async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + + // El coche navegó la raíz durante el arranque headless: el sujeto + // existe y el head unit tiene el listado cacheado. + final eventos = >[]; + final sub = handler + .subscribeToChildren(AudioService.browsableRootId) + .listen(eventos.add); + addTearDown(sub.cancel); + + await tester.pumpWidget( + const OrientacionResponsiveApp(child: SizedBox.shrink()), + ); + await tester.pump(); + expect( + eventos, + isEmpty, + reason: 'todavía no hay Activity, solo una View', + ); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect( + eventos, + hasLength(1), + reason: + 'esta es la ÚNICA vía de recuperación cuando el registro del ' + 'canal pluriwave/file_actions falló en el motor headless', + ); + + // Y no una por cada rebote de ciclo de vida. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(eventos, hasLength(1)); + }); + }); + + group('hayCocheSuscritoAlArbol', () { + test('es false sin handler suscrito y true en cuanto el coche navega un ' + 'id', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + + expect(hayCocheSuscritoAlArbol(), isFalse); + + handler.subscribeToChildren(AudioService.browsableRootId); + + expect(hayCocheSuscritoAlArbol(), isTrue); + }); + }); +} diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 069634d..69c0282 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1868,8 +1868,8 @@ void main() { ); test( - 'a handler-initiated toggle is persisted through ServicioEcualizador ' - '(survives a restart)', + 'a handler-initiated toggle is ADOPTED for display and NOT written ' + 'again from here (eq-estado-unico: the handler owns the write)', () async { final fakeAudio = FakeServicioAudio(); final fakeServicio = FakeServicioEcualizador(activo: true); @@ -1879,8 +1879,21 @@ void main() { fakeAudio.simularCambioEqDesdeHandler(activo: false); await Future.delayed(const Duration(milliseconds: 50)); - expect(fakeServicio.config.activo, isFalse); - expect(fakeServicio.guardarActivoLlamadas, equals(1)); + expect( + eq.activo, + isFalse, + reason: 'the phone toggle must show what the engine is really doing', + ); + expect( + fakeServicio.guardarActivoLlamadas, + equals(0), + reason: + 'persistence moved to PluriWaveAudioHandler itself, because ' + 'this resync only exists while an EstadoEcualizador does — and ' + 'on the headless Android Auto engine that produced the bug, ' + 'none ever does. A second write from here would be a second ' + 'owner of the same fact.', + ); eq.dispose(); }, ); diff --git a/test/estado/estado_entitlement_test.dart b/test/estado/estado_entitlement_test.dart index d072980..d343454 100644 --- a/test/estado/estado_entitlement_test.dart +++ b/test/estado/estado_entitlement_test.dart @@ -159,6 +159,34 @@ void main() { expect(estado.compraEnCurso, isFalse); }); + test('tras restaurar() sin compras el paywall NO queda bloqueado: se ' + 'puede volver a comprar', () async { + final compras = _PuertoComprasFalso(); + addTearDown(compras.dispose); + final estado = EstadoEntitlement( + prefs: await SharedPreferences.getInstance(), + compras: compras, + ); + addTearDown(estado.dispose); + await Future.delayed(Duration.zero); + + unawaited(estado.restaurar()); + await Future.delayed(Duration.zero); + expect(estado.compraEnCurso, isTrue); + + compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada)); + await Future.delayed(Duration.zero); + + // `compraEnCurso` deshabilita AMBOS botones de `hoja_premium.dart` + // (comprar y restaurar): si se queda pegado en `true`, el usuario ya no + // puede pagar nunca más. + expect(estado.compraEnCurso, isFalse); + + unawaited(estado.comprar()); + await Future.delayed(Duration.zero); + expect(compras.comprasIntentadas, 1); + }); + test( 'un error en el flujo de compra no bloquea al pagador (fail-open)', () async { diff --git a/test/pantallas/ajustes/pantalla_ajustes_musica_local_test.dart b/test/pantallas/ajustes/pantalla_ajustes_musica_local_test.dart index 70f9e15..da8468a 100644 --- a/test/pantallas/ajustes/pantalla_ajustes_musica_local_test.dart +++ b/test/pantallas/ajustes/pantalla_ajustes_musica_local_test.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_musica_local.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart' + show registrarInvalidacionArbolAuto; import 'package:pluriwave/widgets/pluri_push_scaffold.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -65,4 +68,81 @@ void main() { expect(find.text('No folder selected'), findsOneWidget); }); + + /// fix/android-auto-musica-local, item 4 — the browse-tree invalidation + /// after a successful folder pick had ZERO coverage and no testability + /// excuse: this file already mounts the screen, `registrarInvalidacionArbolAuto` + /// already takes a fake hook, and `pickMusicFolder` mocks exactly like + /// `hasPersistedPermission` does in `musica_local_auto_test.dart`. + /// + /// It matters because Android Auto CACHES the browse root and never asks + /// again on its own: without the call, a driver who picks a folder on the + /// phone keeps getting a car with no «Música Local» entry for the rest of + /// the session. + group('invalidación del árbol de Android Auto tras elegir carpeta', () { + const canal = MethodChannel('pluriwave/file_actions'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(canal, null); + }); + + Future pulsarElegirCarpeta( + WidgetTester tester, { + required String? uriDevuelta, + }) async { + SharedPreferences.setMockInitialValues({}); + var invalidaciones = 0; + registrarInvalidacionArbolAuto(() => invalidaciones++); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(canal, (call) async { + expect(call.method, 'pickMusicFolder'); + return uriDevuelta; + }); + + await tester.pumpWidget(buildScreen()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.text('Choose folder')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + return invalidaciones; + } + + testWidgets('elegir una carpeta invalida el árbol cacheado del coche', ( + tester, + ) async { + final invalidaciones = await pulsarElegirCarpeta( + tester, + uriDevuelta: 'content://com.android.externalstorage.documents/tree/' + 'primary%3AMusic%2FMyFolder', + ); + + expect( + invalidaciones, + 1, + reason: + 'acaba de aparecer música local donde antes no había, y el head ' + 'unit no vuelve a preguntar por su cuenta', + ); + }); + + testWidgets('cancelar el selector NO invalida nada (el `if (uri == null) ' + 'return` es deliberado)', (tester) async { + final invalidaciones = await pulsarElegirCarpeta( + tester, + uriDevuelta: null, + ); + + expect( + invalidaciones, + 0, + reason: + 'nada cambió, así que forzar un re-browse del árbol entero sería ' + 'trabajo gratis para el coche', + ); + }); + }); } diff --git a/test/servicios/musica_local_auto_prefs_test.dart b/test/servicios/musica_local_auto_prefs_test.dart new file mode 100644 index 0000000..f2b56c6 --- /dev/null +++ b/test/servicios/musica_local_auto_prefs_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/musica_local_auto.dart'; + +/// `FuenteMusicaLocalAuto.estadoCarpeta` promises, in its own interface doc, +/// «Never throws: cualquier fallo degrada a un valor de +/// [EstadoCarpetaLocal], nunca a una excepción». The refactor to the +/// three-valued enum moved `await _uriPersistida()` OUTSIDE the try/catch, +/// so a SharedPreferences failure escaped again — and the only caller, +/// `PluriWaveAudioHandler.getChildren`'s root branch, awaits it inline, so +/// the throw takes the whole browse root down and empties the car. +/// +/// DELIBERATE FILE SEPARATION: nothing here may call +/// `SharedPreferences.setMockInitialValues`. That call swaps +/// `SharedPreferencesStorePlatform.instance` for an in-memory store for the +/// rest of the isolate, and an in-memory store cannot fail. Left alone, the +/// default store answers a real `getAll` platform call that nothing handles +/// under `flutter test`, which is exactly the failure being exercised — +/// hence its own file, not a group inside `musica_local_auto_test.dart`. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FuenteMusicaLocalAutoImpl.estadoCarpeta — fallo de prefs', () { + test('un fallo leyendo SharedPreferences degrada a noConfigurada en vez ' + 'de propagar y vaciar la raíz del coche', () async { + // Sin prefs inyectadas: `_resolverPrefs` cae en + // `SharedPreferences.getInstance()`, que aquí lanza. + final fuente = FuenteMusicaLocalAutoImpl(); + + await expectLater( + fuente.estadoCarpeta(), + completion(EstadoCarpetaLocal.noConfigurada), + ); + }); + + test('ese fallo NO se reporta como canalNoDisponible, que es la única ' + 'respuesta que significa «hay carpeta pero no puedo comprobar el ' + 'permiso»', () async { + final fuente = FuenteMusicaLocalAutoImpl(); + + final estado = await fuente.estadoCarpeta(); + + expect( + estado, + isNot(EstadoCarpetaLocal.canalNoDisponible), + reason: + 'el fallo de prefs es una MissingPluginException igual que la ' + 'del canal `pluriwave/file_actions`, así que un único try que ' + 'las capturase juntas borraría la distinción de 3 valores: el ' + 'árbol mostraría «Música Local» con un subárbol que explica un ' + 'problema de canal inexistente', + ); + }); + }); +} diff --git a/test/servicios/musica_local_auto_test.dart b/test/servicios/musica_local_auto_test.dart index 4708564..142a940 100644 --- a/test/servicios/musica_local_auto_test.dart +++ b/test/servicios/musica_local_auto_test.dart @@ -163,6 +163,132 @@ void main() { }, ); }); + /// fix/android-auto-musica-local — «Muchísimas veces (la mayoría) no + /// aparece la opción de reproducir música local, no aparece ni el menú». + /// + /// El usuario TIENE la compra PRO, así que no es un problema de + /// entitlement. La causa real: `hasPersistedPermission` viaja por + /// `MethodChannel('pluriwave/file_actions')`, cuyo ÚNICO registro de + /// handler vive en `MainActivity.configureFlutterEngine`. Cuando Android + /// Auto levanta el MediaBrowserService sin que la app se haya abierto, + /// `audio_service` construye un FlutterEngine SIN Activity, ese método + /// nunca corre, el canal se queda sin handler y `invokeMethod` lanza + /// `MissingPluginException` — indistinguible hasta ahora de «permiso + /// revocado». + /// + /// Estos tests fijan la distinción: «el canal no está disponible» NO es + /// «no hay carpeta». + group('FuenteMusicaLocalAutoImpl.estadoCarpeta', () { + const canal = MethodChannel('pluriwave/file_actions'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(canal, null); + }); + + test( + 'con URI persistida y SIN handler nativo (motor headless de Android ' + 'Auto) reporta canalNoDisponible, no noConfigurada', + () async { + SharedPreferences.setMockInitialValues({ + 'musica_local_uri': 'content://tree/x', + }); + // Sin handler: `invokeMethod` lanza MissingPluginException, que es + // exactamente lo que pasa en el motor sin Activity. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(canal, null); + + final fuente = FuenteMusicaLocalAutoImpl( + prefs: await SharedPreferences.getInstance(), + ); + + expect( + await fuente.estadoCarpeta(), + EstadoCarpetaLocal.canalNoDisponible, + ); + }, + ); + + test( + 'con URI persistida y handler nativo que responde false (permiso ' + 'revocado de verdad) reporta noConfigurada', + () async { + SharedPreferences.setMockInitialValues({ + 'musica_local_uri': 'content://tree/x', + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(canal, (call) async { + expect(call.method, 'hasPersistedPermission'); + return false; + }); + + final fuente = FuenteMusicaLocalAutoImpl( + prefs: await SharedPreferences.getInstance(), + ); + + expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada); + }, + ); + + test( + 'con URI persistida y handler nativo que responde true reporta ' + 'configurada', + () async { + SharedPreferences.setMockInitialValues({ + 'musica_local_uri': 'content://tree/x', + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(canal, (call) async => true); + + final fuente = FuenteMusicaLocalAutoImpl( + prefs: await SharedPreferences.getInstance(), + ); + + expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.configurada); + }, + ); + + test( + 'sin URI persistida reporta noConfigurada sin invocar el canal', + () async { + SharedPreferences.setMockInitialValues({}); + var llamadas = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(canal, (call) async { + llamadas++; + return true; + }); + + final fuente = FuenteMusicaLocalAutoImpl( + prefs: await SharedPreferences.getInstance(), + ); + + expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada); + expect(llamadas, 0); + }, + ); + + test( + 'un PlatformException del canal (el handler SÍ existe, la llamada ' + 'falla) reporta noConfigurada, no canalNoDisponible', + () async { + SharedPreferences.setMockInitialValues({ + 'musica_local_uri': 'content://tree/x', + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(canal, (call) async { + throw PlatformException(code: 'ERROR'); + }); + + final fuente = FuenteMusicaLocalAutoImpl( + prefs: await SharedPreferences.getInstance(), + ); + + expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada); + }, + ); + }); + group('esArchivoAudio', () { test('acepta cualquier MIME audio/*, en cualquier capitalización', () { expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue); diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index d5c213b..cd589fb 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2130,6 +2130,72 @@ void main() { expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull); }); + /// fix/android-auto-musica-local: la raíz ya no oculta el nodo cuando + /// el canal nativo no está disponible, así que el subárbol tiene que + /// EXPLICAR el problema en vez de abrirse vacío (una carpeta vacía se + /// lee como «no tengo música», que es justo la conclusión equivocada). + /// + /// La etiqueta va en castellano hardcodeado, como TODAS las etiquetas + /// del árbol del coche en `navegacion_auto.dart` (ver + /// `itemPremiumBloqueado`): convención establecida, nunca `AppLocalizations`. + test('canalNoDisponible y carpeta vacía: la raíz local devuelve un item ' + 'explicativo NO reproducible, no una carpeta vacía', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + estado: EstadoCarpetaLocal.canalNoDisponible, + ); + + final items = await hijosMusicaLocal( + ConstructorArbolAuto.idMusicaLocal, + fuente: fuente, + ); + + expect(items, isNotNull); + expect(items!.map((i) => i.id), [ConstructorArbolAuto.idLocalNoLista]); + expect(items.single.playable, isFalse); + expect(items.single.title, isNotEmpty); + }); + + test('canalNoDisponible pero CON pistas resueltas: no se entromete, se ' + 'listan las pistas normalmente', () async { + final fuente = _FakeFuenteMusicaLocalAuto( + estado: EstadoCarpetaLocal.canalNoDisponible, + hijosPorDocId: { + '': const [ + NodoLocal( + documentId: 'd1', + nombre: 'Cancion.mp3', + esDirectorio: false, + ), + ], + }, + ); + + final items = await hijosMusicaLocal( + ConstructorArbolAuto.idMusicaLocal, + fuente: fuente, + ); + + expect(items, isNotNull); + expect( + items!.map((i) => i.id), + isNot(contains(ConstructorArbolAuto.idLocalNoLista)), + ); + expect(items.any((i) => i.id == 'pista:d1'), isTrue); + }); + + test('carpeta genuinamente vacía con el canal SÍ disponible: sigue ' + 'devolviendo lista vacía, sin item explicativo', () async { + final fuente = _FakeFuenteMusicaLocalAuto(); + + final items = await hijosMusicaLocal( + ConstructorArbolAuto.idMusicaLocal, + fuente: fuente, + ); + + expect(items, isNotNull); + expect(items, isEmpty); + }); + 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( @@ -3630,7 +3696,7 @@ class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto { class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { _FakeFuenteMusicaLocalAuto({ - bool configurada = true, + EstadoCarpetaLocal estado = EstadoCarpetaLocal.configurada, Map>? hijosPorDocId, Map? uriPorDocId, Map? metadatosPorDocId, @@ -3638,7 +3704,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { Object? errorEnUriContenido, Object? errorEnMetadatosDe, Set? idsConErrorEnHijos, - }) : _configurada = configurada, + }) : _estado = estado, _hijosPorDocId = hijosPorDocId ?? const {}, _uriPorDocId = uriPorDocId ?? const {}, _metadatosPorDocId = metadatosPorDocId ?? const {}, @@ -3647,7 +3713,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { _errorEnMetadatosDe = errorEnMetadatosDe, _idsConErrorEnHijos = idsConErrorEnHijos ?? const {}; - final bool _configurada; + final EstadoCarpetaLocal _estado; final Map> _hijosPorDocId; final Map _uriPorDocId; final Map _metadatosPorDocId; @@ -3666,7 +3732,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto { final List> llamadasMetadatosDe = []; @override - Future hayCarpetaConfigurada() async => _configurada; + Future estadoCarpeta() async => _estado; @override Future> hijos(String documentId) async { diff --git a/test/servicios/servicio_audio_eq_estado_unico_test.dart b/test/servicios/servicio_audio_eq_estado_unico_test.dart new file mode 100644 index 0000000..14be0e9 --- /dev/null +++ b/test/servicios/servicio_audio_eq_estado_unico_test.dart @@ -0,0 +1,474 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// eq-estado-unico — the equalizer's on/off flag gets a SINGLE owner. +/// +/// Reported bug: «alguna emisora parece que esta con la ecualizacion activada +/// (suena muy alto) pero con el boton desactivado», and «pulsando sobre el +/// boton de ecualizar en Android Auto tampoco activaba ni desactivaba». +/// +/// The flag used to live in three independent copies — the handler's +/// hardcoded `_ecualizadorActivo = true`, `EstadoEcualizador._activo`, and +/// SharedPreferences — and the persisted value only ever reached the handler +/// through `EstadoEcualizador.cargarPersistido()`, which a headless Android +/// Auto engine (no Activity, no Provider tree, no `EstadoRadio._init`) never +/// runs. So in the car the handler played with the equalizer forced ON while +/// disk and the phone UI both said OFF. +/// +/// NOTE on testability: the long-standing comment in `servicio_audio.dart` +/// claiming `PluriWaveAudioHandler` "cannot be instantiated in a unit test +/// (a real just_audio.AudioPlayer needs platform MethodChannels)" is WRONG +/// as of just_audio 0.9.46 — `AudioPlayer`'s constructor resolves its +/// platform lazily and never becomes active without a `setUrl`, so the +/// handler constructs fine here and every EQ path that does not touch the +/// native effect is directly exercisable. That is what the dispatch tests +/// below rely on; only the native `setEnabled`/`setGain` calls stay out of +/// reach (they sit behind `_eqDisponible`, which is `false` off-device). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('estadoEqInicial (A — seed the handler from disk on every engine)', () { + test('adopts the persisted value when there is one', () { + expect(estadoEqInicial(persistido: false), isFalse); + expect(estadoEqInicial(persistido: true), isTrue); + }); + + test('defaults to ON only when nothing was ever persisted', () { + expect( + estadoEqInicial(persistido: null), + isTrue, + reason: 'a first install keeps the historical default (EQ on)', + ); + }); + }); + + group('registrarHandler (A — seeding)', () { + test('consults the injected read port exactly once and seeds the handler ' + 'with the persisted value', () async { + final handler = PluriWaveAudioHandler(); + var lecturas = 0; + + registrarHandler( + handler, + leerEqActivoPersistido: () async { + lecturas++; + return false; + }, + ); + await Future.delayed(Duration.zero); + + expect(lecturas, 1, reason: 'exactly one disk read per engine start'); + expect( + handler.ecualizadorActivo, + isFalse, + reason: 'the handler must adopt what the phone UI persisted', + ); + }); + + test('a read failure leaves the handler on the safe default instead of ' + 'propagating', () async { + final handler = PluriWaveAudioHandler(); + + registrarHandler( + handler, + leerEqActivoPersistido: () async => throw StateError('sin disco'), + ); + await Future.delayed(Duration.zero); + + expect(handler.ecualizadorActivo, isTrue); + }); + + test('without a read port the handler is left untouched (widget tests, ' + 'fakes)', () async { + final handler = PluriWaveAudioHandler(); + await handler.setEcualizadorActivo(false); + + registrarHandler(handler); + await Future.delayed(Duration.zero); + + expect(handler.ecualizadorActivo, isFalse); + }); + }); + + /// A, CONSTRUCTION-WINDOW half. `_eqActivoPersistido` (the module-level + /// cache behind `_ecualizadorActivo = estadoEqInicial(persistido: ...)`) + /// had zero coverage on BOTH sides: replacing that initialiser with the old + /// hardcoded `= true` left the suite green, and so did deleting the + /// `_eqActivoPersistido = activo` write in `_aplicarEcualizadorActivo`. + /// + /// The window it closes is real: `AudioService.init` builds the handler + /// through its `builder` callback and only AFTER that future resolves does + /// `main.dart` reach `registrarHandler`. A car tap landing inside that + /// window would otherwise hit a handler whose flag had never seen disk. + group('A (construction window) — a handler built after a disk read', () { + test('a handler constructed AFTER a read port has already answered ' + 'starts from the persisted value, not from a hardcoded default', + () async { + // One engine does the read `registrarHandler` performs in main.dart. + final primero = PluriWaveAudioHandler(); + + // Pin the module cache to the OPPOSITE value first. Without this the + // test passes for the wrong reason: whatever ran before may already + // have left the cache on `false`, so deleting the disk→cache write in + // `_sembrarEcualizadorDesdeDisco` would still leave this green. Seeding + // does NOT write the cache (`_aplicarEcualizadorActivo` returns before + // it when `persistir: false`), so after this pin that write is the only + // path that can bring the cache back down to `false`. + await primero.setEcualizadorActivo(true); + + registrarHandler(primero, leerEqActivoPersistido: () async => false); + await Future.delayed(Duration.zero); + expect(primero.ecualizadorActivo, isFalse); + + // Now the construction window: a handler built by `AudioService.init`'s + // builder, with no port of its own yet. + final segundo = PluriWaveAudioHandler(); + + expect( + segundo.ecualizadorActivo, + isFalse, + reason: + 'a car tap landing before registrarHandler must not find the ' + 'equalizer forced on while disk says off', + ); + }); + + test('the cache follows what the handler itself writes, in both ' + 'directions', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + + await handler.setEcualizadorActivo(false); + expect( + PluriWaveAudioHandler().ecualizadorActivo, + isFalse, + reason: + 'the write side of the cache: a toggle must be visible to the ' + 'next handler built on this engine', + ); + + await handler.setEcualizadorActivo(true); + expect(PluriWaveAudioHandler().ecualizadorActivo, isTrue); + }); + }); + + group('B — the handler persists its OWN toggle', () { + test('an eq toggle writes through the injected port even with no ' + 'EstadoEcualizador in play', () async { + final handler = PluriWaveAudioHandler(); + final escrituras = []; + + registrarHandler( + handler, + guardarEqActivoPersistido: (activo) async => escrituras.add(activo), + ); + + await handler.setEcualizadorActivo(false); + await handler.setEcualizadorActivo(true); + + expect( + escrituras, + [false, true], + reason: + 'a car/notification toggle must survive a process restart ' + 'without any UI object existing', + ); + }); + + test('seeding from disk does NOT write back to disk', () async { + final handler = PluriWaveAudioHandler(); + final escrituras = []; + + registrarHandler( + handler, + leerEqActivoPersistido: () async => false, + guardarEqActivoPersistido: (activo) async => escrituras.add(activo), + ); + await Future.delayed(Duration.zero); + + expect(handler.ecualizadorActivo, isFalse); + expect(escrituras, isEmpty); + }); + + test('a failing write port never breaks the toggle', () async { + final handler = PluriWaveAudioHandler(); + + registrarHandler( + handler, + guardarEqActivoPersistido: (_) async => throw StateError('disco lleno'), + ); + + await handler.setEcualizadorActivo(false); + + expect(handler.ecualizadorActivo, isFalse); + }); + }); + + group('decidirToggleEq (C — the customAction decision)', () { + test('flips the current value', () { + expect( + decidirToggleEq(activoActual: true, eqDisponible: true).nuevoActivo, + isFalse, + ); + expect( + decidirToggleEq(activoActual: false, eqDisponible: true).nuevoActivo, + isTrue, + ); + }); + + test('a native call is required only when the effect is attached', () { + expect( + decidirToggleEq( + activoActual: true, + eqDisponible: true, + ).requiereLlamadaNativa, + isTrue, + ); + expect( + decidirToggleEq( + activoActual: true, + eqDisponible: false, + ).requiereLlamadaNativa, + isFalse, + reason: + 'with no native Equalizer effect the flag still flips, but ' + 'nothing is pushed to the platform', + ); + }); + + test('the flag still flips with no native effect — the car button must ' + 'never look inert', () { + expect( + decidirToggleEq(activoActual: false, eqDisponible: false).nuevoActivo, + isTrue, + ); + }); + }); + + group('customAction dispatch (C — zero coverage before this)', () { + test('the accionEqToggle literal routes through decidirToggleEq', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + await handler.setEcualizadorActivo(true); + + await handler.customAction(accionEqToggle); + expect(handler.ecualizadorActivo, isFalse); + + await handler.customAction(accionEqToggle); + expect(handler.ecualizadorActivo, isTrue); + }); + + test('a car toggle persists through the same write port as a phone ' + 'toggle', () async { + final handler = PluriWaveAudioHandler(); + final escrituras = []; + registrarHandler( + handler, + guardarEqActivoPersistido: (activo) async => escrituras.add(activo), + ); + // Explicit starting state. A fresh handler seeds `_ecualizadorActivo` + // from the module-level `_eqActivoPersistido` cache, which any earlier + // test in this file leaves at whatever it last wrote. This assertion + // is about what the TOGGLE does, not about what the previous test + // happened to leave behind — without these two lines, simply + // reordering the tests silently flips the expectation to `[true]`. + await handler.setEcualizadorActivo(true); + escrituras.clear(); + + await handler.customAction(accionEqToggle); + + expect(escrituras, [false]); + }); + + test('an unknown custom action is a silent no-op', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + final antes = handler.ecualizadorActivo; + + await handler.customAction('accion.inexistente'); + + expect(handler.ecualizadorActivo, antes); + }); + }); + + group('debeReasertarEcualizadorNativo (D — re-assert on activation)', () { + test('an idle -> active transition with the effect attached re-asserts', () { + expect( + PluriWaveAudioHandler.debeReasertarEcualizadorNativo( + estado: ProcessingState.ready, + reproductorActivoAntes: false, + eqDisponible: true, + ), + isTrue, + reason: + "just_audio's AudioEffect.setEnabled only reaches the platform " + 'while the player is active, so a toggle made while stopped ' + 'never landed natively', + ); + }); + + test('staying active does not re-assert on every event', () { + expect( + PluriWaveAudioHandler.debeReasertarEcualizadorNativo( + estado: ProcessingState.ready, + reproductorActivoAntes: true, + eqDisponible: true, + ), + isFalse, + ); + }); + + test('going idle does not re-assert', () { + expect( + PluriWaveAudioHandler.debeReasertarEcualizadorNativo( + estado: ProcessingState.idle, + reproductorActivoAntes: true, + eqDisponible: true, + ), + isFalse, + ); + }); + + test('no attached effect never re-asserts', () { + expect( + PluriWaveAudioHandler.debeReasertarEcualizadorNativo( + estado: ProcessingState.ready, + reproductorActivoAntes: false, + eqDisponible: false, + ), + isFalse, + ); + }); + + test('buffering/loading/completed already count as active', () { + for (final estado in [ + ProcessingState.loading, + ProcessingState.buffering, + ProcessingState.completed, + ]) { + expect( + PluriWaveAudioHandler.debeReasertarEcualizadorNativo( + estado: estado, + reproductorActivoAntes: false, + eqDisponible: true, + ), + isTrue, + reason: + '$estado is a non-idle state, i.e. the platform player is ' + 'attached and accepts effect calls', + ); + } + }); + }); + + /// D, WIRING half. Everything above this group tests the pure + /// [PluriWaveAudioHandler.debeReasertarEcualizadorNativo] predicate and + /// nothing else: deleting the `playerStateStream` listener's whole re-assert + /// block — the `if (debeReasertar...) unawaited(_reasertarEcualizadorNativo())` + /// call, the `_reproductorActivo = proc != ProcessingState.idle` edge + /// tracking — left the suite green. That is the SAME producer-only hole that + /// let a dead Android Auto EQ button ship, so it gets closed here rather than + /// re-tested at the predicate. + /// + /// [PluriWaveAudioHandler.manejarEstadoPlayer] IS the listener body — the + /// same method `playerStateStream.listen` is subscribed to — so these + /// drive the real handler through real player-state transitions. + group('D (wiring) — the playerState idle -> active edge', () { + PlayerState estado(ProcessingState proc, {bool playing = false}) => + PlayerState(playing, proc); + + test('the first non-idle event re-asserts the native effect exactly ' + 'once, and staying active never re-asserts again', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + handler.simularEcualizadorDisponible(true); + + expect(handler.reasercionesEcualizador, 0); + + handler.manejarEstadoPlayer(estado(ProcessingState.loading)); + expect( + handler.reasercionesEcualizador, + 1, + reason: + "just_audio's AudioEffect.setEnabled is a no-op while the " + 'platform player is detached, so a toggle made while stopped ' + 'only lands on this edge', + ); + + handler.manejarEstadoPlayer(estado(ProcessingState.buffering)); + handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); + handler.manejarEstadoPlayer(estado(ProcessingState.completed)); + expect( + handler.reasercionesEcualizador, + 1, + reason: + 'the player emits many events while active; re-asserting on ' + 'each one would be a native call storm', + ); + }); + + test('going idle re-arms the edge, so stop + play re-asserts again — ' + 'this is the `_reproductorActivo = proc != idle` line', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + handler.simularEcualizadorDisponible(true); + + handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); + expect(handler.reasercionesEcualizador, 1); + + handler.manejarEstadoPlayer(estado(ProcessingState.idle)); + expect( + handler.reasercionesEcualizador, + 1, + reason: 'going idle itself never re-asserts', + ); + + handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); + expect( + handler.reasercionesEcualizador, + 2, + reason: + 'without the edge-tracking assignment the flag would stay true ' + 'and the toggle made while stopped would never land natively', + ); + }); + + test('with no native effect attached nothing is ever re-asserted', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + // `_eqDisponible` is false off-device, which is also the real + // "device has no Equalizer effect" case. + + handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); + handler.manejarEstadoPlayer(estado(ProcessingState.idle)); + handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true)); + + expect(handler.reasercionesEcualizador, 0); + }); + }); + + group('F — the EQ re-push must not rewind the car progress bar', () { + test('the EQ controls re-push refreshes updatePosition from the ' + 'player', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + handler.playbackState.add( + handler.playbackState.value.copyWith( + updatePosition: const Duration(minutes: 3), + ), + ); + + await handler.setEcualizadorActivo(false); + + expect( + handler.playbackState.value.updatePosition, + handler.posicionActual, + reason: + 'copyWith stamps a fresh updateTime but keeps the OLD ' + 'updatePosition, so an EQ tap told the car "you are at 3:00, as ' + 'of right now" and the bar snapped backwards', + ); + }); + }); +} diff --git a/test/servicios/servicio_audio_eq_ganancia_test.dart b/test/servicios/servicio_audio_eq_ganancia_test.dart new file mode 100644 index 0000000..aa20a96 --- /dev/null +++ b/test/servicios/servicio_audio_eq_ganancia_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the +/// app's fixed ±12 dB slider scale to whatever range the device's native +/// `Equalizer.getBandLevelRange()` reports. +/// +/// This is the only source-plausible explanation for the reported «suena muy +/// alto» half of the bug. The original implementation normalised the input +/// across the WHOLE range and mapped it linearly: +/// +/// normalizado = (db.clamp(-12, 12) + 12) / 24 +/// return minDecibels + normalizado * (maxDecibels - minDecibels) +/// +/// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when +/// the range happens to be symmetric. Android does not guarantee that: the +/// AudioEffect Equalizer contract only requires a min/max pair, and real +/// devices ship asymmetric ranges. On such a device a FLAT preset — every +/// band 0 dB — was silently pushing a positive boost into every band, which +/// is audibly louder while the on/off button still reads "off". +/// +/// The contract asserted here: 0 dB always maps to exactly 0, and the two +/// sides of the scale are stretched INDEPENDENTLY against their own end of +/// the native range, so the sign of the user's intent is never inverted and +/// the extremes still reach the device's real limits. +void main() { + group('mapearGananciaNativa — 0 dB is always exactly 0', () { + test('symmetric range (the common case) is unchanged', () { + expect( + mapearGananciaNativa(0, minDecibels: -15, maxDecibels: 15), + 0, + ); + }); + + test('asymmetric range no longer boosts a FLAT preset', () { + // The reported symptom: on a device reporting [-12, +19] the old + // midpoint mapping turned every 0 dB band into +3.5 dB of real boost. + expect( + mapearGananciaNativa(0, minDecibels: -12, maxDecibels: 19), + 0, + reason: 'a FLAT preset must be inaudible, on every device', + ); + }); + + test('a wholly positive range still cannot boost a FLAT preset', () { + expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0); + }); + + test('a wholly negative range still cannot cut a FLAT preset', () { + expect(mapearGananciaNativa(0, minDecibels: -19, maxDecibels: -3), 0); + }); + }); + + group('mapearGananciaNativa — the extremes reach the native limits', () { + test('+12 dB maps to the native maximum', () { + expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19); + }); + + test('-12 dB maps to the native minimum', () { + expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12); + }); + + test('values beyond the slider scale are clamped, not extrapolated', () { + expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15); + expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -15); + }); + }); + + group('mapearGananciaNativa — each side scales against its own end', () { + test('half boost is half of the positive headroom', () { + expect( + mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20), + closeTo(10, 1e-9), + ); + }); + + test('half cut is half of the negative headroom', () { + expect( + mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20), + closeTo(-6, 1e-9), + ); + }); + + test('the sign of the user intent is never inverted', () { + for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) { + final nativo = mapearGananciaNativa( + db, + minDecibels: -12, + maxDecibels: 19, + ); + expect( + nativo.sign, + db.sign, + reason: 'a cut must never become a boost ($db dB -> $nativo)', + ); + } + }); + }); + + group('mapearGananciaNativa — degenerate ranges reported by the device', () { + test('a range with no headroom on one side clamps that side to 0', () { + // A device that reports max == 0 can only cut. Asking for a boost must + // resolve to "no change", never to a negative value. + expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0); + expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15); + }); + + test('a zero-width range collapses everything to 0', () { + expect(mapearGananciaNativa(12, minDecibels: 0, maxDecibels: 0), 0); + expect(mapearGananciaNativa(-12, minDecibels: 0, maxDecibels: 0), 0); + }); + + test('the result never escapes the native range', () { + for (final db in [-12.0, -3.0, 0.0, 3.0, 12.0]) { + final nativo = mapearGananciaNativa( + db, + minDecibels: -3, + maxDecibels: 19, + ); + expect(nativo, greaterThanOrEqualTo(-3)); + expect(nativo, lessThanOrEqualTo(19)); + } + }); + }); +} diff --git a/test/servicios/servicio_audio_gating_test.dart b/test/servicios/servicio_audio_gating_test.dart index 31ab8d7..fa6debd 100644 --- a/test/servicios/servicio_audio_gating_test.dart +++ b/test/servicios/servicio_audio_gating_test.dart @@ -1,5 +1,10 @@ +import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/pista_local.dart'; +import 'package:pluriwave/servicios/musica_local_auto.dart'; +import 'package:pluriwave/servicios/navegacion_auto.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// Android Auto play-path backstop (design.md ADR-4, android-auto-media /// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station @@ -13,6 +18,8 @@ import 'package:pluriwave/servicios/servicio_audio.dart'; /// handler's dispatch methods delegate to (mirrors `mapearEstadoProceso` /// and every other pure helper in this file). void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('free tier: bloquea cualquier cambio de emisora/salto', () { expect(debeBloquearCambioDeEmisora(premium: false), isTrue); }); @@ -21,18 +28,176 @@ void main() { expect(debeBloquearCambioDeEmisora(premium: true), isFalse); }); - group('notificarDesbloqueoAuto / registrarNotificacionDesbloqueoAuto', () { + /// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la + /// transición free -> premium». Android Auto cachea la raíz, así que + /// CUALQUIER momento en el que el árbol pasa a poder mostrar algo que + /// antes no podía tiene que invalidarla — muy en particular, que aparezca + /// por fin una Activity (y con ella el handler nativo del canal + /// `pluriwave/file_actions`) o que el usuario acabe de elegir carpeta. + /// De ahí el nombre neutro. + group('invalidarArbolAuto / registrarInvalidacionArbolAuto', () { test('sin hook registrado, es un no-op seguro', () { - expect(() => notificarDesbloqueoAuto(), returnsNormally); + expect(() => invalidarArbolAuto(), returnsNormally); }); test('invoca el hook registrado exactamente una vez por llamada', () { var llamadas = 0; - registrarNotificacionDesbloqueoAuto(() => llamadas++); + registrarInvalidacionArbolAuto(() => llamadas++); - notificarDesbloqueoAuto(); + invalidarArbolAuto(); expect(llamadas, 1); }); + + test('registrarHandler conecta la invalidación al handler: una llamada ' + 'notifica la raíz Y Música Local', () async { + final handler = PluriWaveAudioHandler(); + registrarHandler(handler); + + final raiz = >[]; + final local = >[]; + final subRaiz = handler + .subscribeToChildren(AudioService.browsableRootId) + .listen(raiz.add); + final subLocal = handler + .subscribeToChildren(ConstructorArbolAuto.idMusicaLocal) + .listen(local.add); + + invalidarArbolAuto(); + await Future.delayed(Duration.zero); + await subRaiz.cancel(); + await subLocal.cancel(); + + expect(raiz, hasLength(1)); + expect(local, hasLength(1)); + }); + }); + + /// fix/android-auto-musica-local, item 5. + /// + /// `subscribeToChildren` sembraba el `BehaviorSubject` con un mapa vacío. + /// El listener interno de `audio_service` se suscribe en cuanto el head + /// unit navega un id, recibe ESE valor semilla de inmediato y lo reenvía + /// como `notifyChildrenChanged` — o sea, el primer browse de cada id + /// provocaba un segundo `getChildren` espurio. En la raíz eso era un + /// SEGUNDO round trip de permisos justo en la ruta que ya estaba + /// fallando. Sin semilla no hay valor que reenviar, y la invalidación + /// explícita (`notificarHijosCambiaron`) sigue funcionando igual. + group('subscribeToChildren', () { + test('el sujeto arranca SIN valor: nada que reenviar en la primera ' + 'suscripción, así que no hay notifyChildrenChanged espurio', () { + final handler = PluriWaveAudioHandler(); + + expect(handler.subscribeToChildren('musica_local').hasValue, isFalse); + }); + + test('memoiza por id: dos llamadas devuelven el MISMO stream', () { + final handler = PluriWaveAudioHandler(); + + expect( + identical( + handler.subscribeToChildren('musica_local'), + handler.subscribeToChildren('musica_local'), + ), + isTrue, + ); + expect( + identical( + handler.subscribeToChildren('musica_local'), + handler.subscribeToChildren('favoritos'), + ), + isFalse, + ); + }); + + test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito', + () async { + final handler = PluriWaveAudioHandler(); + final stream = handler.subscribeToChildren('musica_local'); + final recibidos = >[]; + final sub = stream.listen(recibidos.add); + + handler.notificarHijosCambiaron('musica_local'); + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(recibidos, hasLength(1)); + }); + }); + + /// fix/android-auto-musica-local — «no aparece la opción de reproducir + /// música local, no aparece ni el menú», con la compra PRO hecha. + /// + /// La raíz decidía la existencia del nodo con un round trip de permisos + /// por `MethodChannel`. En el motor headless que Android Auto levanta sin + /// Activity ese canal no tiene handler, la llamada lanzaba + /// `MissingPluginException` y el nodo se omitía — y Android Auto CACHEA + /// la raíz, así que se quedaba fuera toda la sesión. + /// + /// La pertenencia a la raíz ya no depende de poder contestar esa + /// pregunta: basta con que el estado NO sea [EstadoCarpetaLocal.noConfigurada]. + group('getChildren(root): pertenencia de Música Local', () { + setUp(() { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + }); + + Future> idsRaizCon(EstadoCarpetaLocal estado) async { + registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado)); + final handler = PluriWaveAudioHandler(); + final items = await handler.getChildren(AudioService.browsableRootId); + return items.map((i) => i.id).toList(); + } + + test( + 'canalNoDisponible (motor sin Activity, pero el usuario SÍ eligió ' + 'carpeta): la raíz sigue ofreciendo Música Local', + () async { + expect( + await idsRaizCon(EstadoCarpetaLocal.canalNoDisponible), + contains(ConstructorArbolAuto.idMusicaLocal), + ); + }, + ); + + test('configurada: la raíz ofrece Música Local', () async { + expect( + await idsRaizCon(EstadoCarpetaLocal.configurada), + contains(ConstructorArbolAuto.idMusicaLocal), + ); + }); + + test( + 'noConfigurada (nunca se eligió carpeta, o el permiso está revocado ' + 'de verdad): la raíz sigue omitiendo Música Local', + () async { + expect( + await idsRaizCon(EstadoCarpetaLocal.noConfigurada), + isNot(contains(ConstructorArbolAuto.idMusicaLocal)), + ); + }, + ); }); } + +/// Misma forma que `_FakeFuenteMusicaLocalAuto` en +/// `navegacion_auto_test.dart`, reducida a lo que esta suite necesita: solo +/// el estado de la carpeta decide la raíz. +class _FakeFuenteMusicaLocalGating implements FuenteMusicaLocalAuto { + _FakeFuenteMusicaLocalGating(this._estado); + + final EstadoCarpetaLocal _estado; + + @override + Future estadoCarpeta() async => _estado; + + @override + Future> hijos(String documentId) async => const []; + + @override + Future uriContenidoDePista(String documentId) async => null; + + @override + Future> metadatosDe( + List documentIds, + ) async => const {}; +} diff --git a/test/servicios/servicio_compras_test.dart b/test/servicios/servicio_compras_test.dart index 8bcdfa5..1950b0b 100644 --- a/test/servicios/servicio_compras_test.dart +++ b/test/servicios/servicio_compras_test.dart @@ -1,14 +1,57 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:pluriwave/servicios/servicio_compras.dart'; -/// Pure port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero -/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] is the ONLY -/// piece of `ServicioComprasPlayBilling` that is unit-testable without a -/// real `in_app_purchase` platform channel — [ServicioComprasPlayBilling] -/// itself is the sole call site (Design ADR-2), exercised instead through -/// `EstadoEntitlement` + a fake `PuertoCompras` +/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero +/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] es la parte +/// pura, y [ServicioComprasPlayBilling] se ejercita inyectando +/// [_InAppPurchaseFalso] — sin ningún platform channel real. +/// `EstadoEntitlement` se prueba aparte con un `PuertoCompras` falso /// (`estado_entitlement_test.dart`). + +/// Fake [InAppPurchase]: deja que cada test empuje lotes por +/// [purchaseStream] a mano. `noSuchMethod` cubre el resto de la API del +/// plugin, que estos tests no ejercitan. +class _InAppPurchaseFalso implements InAppPurchase { + final _compras = StreamController>.broadcast(); + int restauracionesPedidas = 0; + final completadas = []; + + @override + Stream> get purchaseStream => _compras.stream; + + @override + Future restorePurchases({String? applicationUserName}) async { + restauracionesPedidas++; + } + + @override + Future completePurchase(PurchaseDetails purchase) async { + completadas.add(purchase); + } + + void emitir(List compras) => _compras.add(compras); + + Future dispose() => _compras.close(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails( + purchaseID: 'compra-1', + productID: ServicioComprasPlayBilling.idProducto, + verificationData: PurchaseVerificationData( + localVerificationData: 'local', + serverVerificationData: 'server', + source: 'google_play', + ), + transactionDate: null, + status: status, +); + void main() { group('eventoDesdeEstadoCompra', () { test('purchased -> comprada', () { @@ -50,6 +93,59 @@ void main() { }); }); + group('ServicioComprasPlayBilling.purchaseStream', () { + test('un lote vacio emite noEncontrada (restaurar sin compras)', () async { + final iap = _InAppPurchaseFalso(); + addTearDown(iap.dispose); + final servicio = ServicioComprasPlayBilling(inAppPurchase: iap); + addTearDown(servicio.dispose); + + final tipos = []; + final sub = servicio.eventos.listen((e) => tipos.add(e.tipo)); + addTearDown(sub.cancel); + + await servicio.restaurar(); + iap.emitir(const []); + await Future.delayed(Duration.zero); + + // Sin este evento `EstadoEntitlement._compraEnCurso` se queda en `true` + // para siempre y `hoja_premium.dart` deshabilita AMBOS botones + // (comprar y restaurar): el usuario no puede pagar. + expect(tipos, [TipoEventoCompra.noEncontrada]); + expect(iap.restauracionesPedidas, 1); + }); + + test('un lote con compras NO emite noEncontrada', () async { + final iap = _InAppPurchaseFalso(); + addTearDown(iap.dispose); + final servicio = ServicioComprasPlayBilling(inAppPurchase: iap); + addTearDown(servicio.dispose); + + final tipos = []; + final sub = servicio.eventos.listen((e) => tipos.add(e.tipo)); + addTearDown(sub.cancel); + + iap.emitir([_compraFalsa(PurchaseStatus.restored)]); + await Future.delayed(Duration.zero); + + expect(tipos, [TipoEventoCompra.restaurada]); + }); + + test('completa las compras pendientes de confirmar', () async { + final iap = _InAppPurchaseFalso(); + addTearDown(iap.dispose); + final servicio = ServicioComprasPlayBilling(inAppPurchase: iap); + addTearDown(servicio.dispose); + + final compra = + _compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true; + iap.emitir([compra]); + await Future.delayed(Duration.zero); + + expect(iap.completadas, [compra]); + }); + }); + test('idProducto es el identificador unico no-consumible', () { expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium'); }); diff --git a/test/widgets/hoja_premium_test.dart b/test/widgets/hoja_premium_test.dart index dc7d92b..6b0af55 100644 --- a/test/widgets/hoja_premium_test.dart +++ b/test/widgets/hoja_premium_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/servicios/servicio_compras.dart'; @@ -35,6 +36,28 @@ class _PuertoComprasFalso implements PuertoCompras { Future dispose() => _eventos.close(); } +/// Fake [InAppPurchase]: permite montar el paywall sobre el +/// `ServicioComprasPlayBilling` REAL (no un `PuertoCompras` falso) para cubrir +/// el bloqueo del paywall de extremo a extremo. `noSuchMethod` cubre el resto +/// de la API del plugin, que este test no ejercita. +class _InAppPurchaseFalso implements InAppPurchase { + final _compras = StreamController>.broadcast(); + + @override + Stream> get purchaseStream => _compras.stream; + + @override + Future restorePurchases({String? applicationUserName}) async { + // Play Billing publica un lote VACÍO cuando no hay nada que restaurar. + _compras.add(const []); + } + + Future dispose() => _compras.close(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + /// FIX 3 / FIX 9 (code review): the paywall must show localized feedback for /// a failed purchase/restore, a distinct non-error confirmation when a /// restore finds nothing, and its own dedicated "premium active" string @@ -298,4 +321,54 @@ void main() { }, ); }); + + group('bloqueo del paywall — "Restaurar compras" sin compras previas', () { + testWidgets('extremo a extremo (ServicioComprasPlayBilling real): un lote ' + 'vacío reactiva AMBOS botones, comprar y restaurar', (tester) async { + final iap = _InAppPurchaseFalso(); + addTearDown(iap.dispose); + final servicio = ServicioComprasPlayBilling(inAppPurchase: iap); + addTearDown(servicio.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: + (_) => EstadoEntitlement(prefs: null, compras: servicio), + ), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: HojaPremium()), + ), + ), + ); + await tester.pump(); + l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium))); + + final restaurar = find.byKey(const ValueKey('hoja-premium-restaurar')); + final comprar = find.byKey(const ValueKey('hoja-premium-comprar')); + + await tester.tap(restaurar); + await tester.pump(); + await tester.pump(); + + // El botón sigue vivo: si `noEncontrada` nunca llega, `compraEnCurso` + // se queda en `true` y el usuario no puede pagar nunca más. + expect( + tester.widget(restaurar).onPressed, + isNotNull, + reason: 'restaurar debe volver a estar habilitado', + ); + expect( + tester.widget(comprar).onPressed, + isNotNull, + reason: 'comprar debe volver a estar habilitado', + ); + expect(find.text(l10n.restauracionSinCompras), findsOneWidget); + }); + }); }