From 241f81e5351501927b55d84815bbc9e6f8ce5005 Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 2 Sep 2026 19:01:53 +0200 Subject: [PATCH] fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on stop button makes the entire app useless", citado contra las Android for Cars App Quality Guidelines. La causa no era el boton de parar. Maquina de estados del transporte _cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de fuente se encolan incrementando la revision al encolar, no al ejecutar, asi que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que las entradas obsoletas reescribieran loading sobre el idle que stop() acababa de publicar. Estado final: loading para siempre sobre una sesion que audio_service ya habia desactivado. Ahora la guarda de revision es la primera sentencia del metodo. pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual despues de pulsar pausa; se revalida la intencion antes de llamar a play(). Se anade un suelo de estado que cierra cualquier loading o buffering sin carga viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando, para no convertir un tunel en un error. El presupuesto hasta el primer mensaje baja a menos de diez segundos y los reintentos ya no borran el mensaje visible. Tier gratuito en el coche El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando no habia premium, y un revisor con instalacion limpia siempre es tier gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con emisoras reales y reproducibles, compiladas en el binario para que existan en frio, y la puerta de entitlement acota contenido en vez de bloquear acciones. Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la ultima emisora, que fallaba tambien a los clientes de pago. Localizacion El locale del handler solo lo fijaba un widget que el motor headless nunca construye, asi que todo error del coche salia en castellano. Se resuelve desde el locale de plataforma. Se traducen las once etiquetas del arbol que estaban a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si vuelve a aparecer texto visible fuera del sistema de traduccion. Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los 5 avisos preexistentes. --- lib/l10n/app_ar.arb | 15 +- lib/l10n/app_bn.arb | 15 +- lib/l10n/app_de.arb | 15 +- lib/l10n/app_en.arb | 15 +- lib/l10n/app_es.arb | 15 +- lib/l10n/app_fr.arb | 15 +- lib/l10n/app_hi.arb | 15 +- lib/l10n/app_id.arb | 15 +- lib/l10n/app_it.arb | 15 +- lib/l10n/app_ja.arb | 15 +- lib/l10n/app_pt.arb | 15 +- lib/l10n/app_ru.arb | 15 +- lib/l10n/app_zh.arb | 15 +- lib/l10n/gen/app_localizations.dart | 78 ++ lib/l10n/gen/app_localizations_ar.dart | 42 + lib/l10n/gen/app_localizations_bn.dart | 42 + lib/l10n/gen/app_localizations_de.dart | 42 + lib/l10n/gen/app_localizations_en.dart | 42 + lib/l10n/gen/app_localizations_es.dart | 42 + lib/l10n/gen/app_localizations_fr.dart | 42 + lib/l10n/gen/app_localizations_hi.dart | 42 + lib/l10n/gen/app_localizations_id.dart | 42 + lib/l10n/gen/app_localizations_it.dart | 42 + lib/l10n/gen/app_localizations_ja.dart | 41 + lib/l10n/gen/app_localizations_pt.dart | 42 + lib/l10n/gen/app_localizations_ru.dart | 42 + lib/l10n/gen/app_localizations_zh.dart | 40 + lib/servicios/emisoras_destacadas.dart | 174 ++++ lib/servicios/navegacion_auto.dart | 419 ++++++-- lib/servicios/servicio_audio.dart | 979 ++++++++++++++++-- ...arranque_invalidacion_arbol_auto_test.dart | 10 +- test/helpers/handlers_audio.dart | 46 + test/l10n/etiquetas_arbol_auto_test.dart | Bin 0 -> 11120 bytes test/l10n/identical_value_allowlist.dart | 6 + test/servicios/emisoras_destacadas_test.dart | 125 +++ .../navegacion_auto_destacadas_test.dart | 267 +++++ .../navegacion_auto_gating_test.dart | 194 ++-- .../navegacion_auto_localizacion_test.dart | 221 ++++ test/servicios/navegacion_auto_test.dart | 8 +- .../servicio_audio_auto_free_test.dart | 679 ++++++++++++ .../servicio_audio_eq_estado_unico_test.dart | 40 +- .../servicios/servicio_audio_gating_test.dart | 36 +- .../servicio_audio_transporte_test.dart | 736 +++++++++++++ 43 files changed, 4446 insertions(+), 310 deletions(-) create mode 100644 lib/servicios/emisoras_destacadas.dart create mode 100644 test/helpers/handlers_audio.dart create mode 100644 test/l10n/etiquetas_arbol_auto_test.dart create mode 100644 test/servicios/emisoras_destacadas_test.dart create mode 100644 test/servicios/navegacion_auto_destacadas_test.dart create mode 100644 test/servicios/navegacion_auto_localizacion_test.dart create mode 100644 test/servicios/servicio_audio_auto_free_test.dart create mode 100644 test/servicios/servicio_audio_transporte_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 54c33ef..83a26b4 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "فترات إجازة للمنبهات", "premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)", "premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.", - "premiumAhoraNo": "ليس الآن" + "premiumAhoraNo": "ليس الآن", + "autoErrorEmisoraPremium": "هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.", + "autoErrorBusquedaSinResultados": "لم نعثر على تلك المحطة. جرّب اسمًا آخر.", + "autoCarpetaEscuchar": "الاستماع", + "autoCarpetaFavoritos": "المفضلة", + "autoCarpetaTodas": "كل المحطات", + "autoCarpetaMisEmisoras": "محطاتي", + "autoCarpetaMusicaLocal": "الموسيقى المحلية", + "autoMusicaLocalNoDisponible": "افتح PluriWave على هاتفك لقراءة موسيقاك", + "autoCargarMas": "المزيد…", + "autoOrdenarPorCalidad": "الترتيب حسب الجودة", + "autoReproducirCarpeta": "تشغيل المجلد", + "autoReproducirAleatorio": "تشغيل عشوائي", + "autoPistaSinNombre": "مقطع بلا اسم" } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index e8c907a..76ee9f0 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল", "premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)", "premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।", - "premiumAhoraNo": "এখন নয়" + "premiumAhoraNo": "এখন নয়", + "autoErrorEmisoraPremium": "এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।", + "autoErrorBusquedaSinResultados": "সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।", + "autoCarpetaEscuchar": "শুনুন", + "autoCarpetaFavoritos": "প্রিয়", + "autoCarpetaTodas": "সব স্টেশন", + "autoCarpetaMisEmisoras": "আমার স্টেশন", + "autoCarpetaMusicaLocal": "স্থানীয় সঙ্গীত", + "autoMusicaLocalNoDisponible": "আপনার গান পড়তে ফোনে PluriWave খুলুন", + "autoCargarMas": "আরও…", + "autoOrdenarPorCalidad": "মান অনুসারে সাজান", + "autoReproducirCarpeta": "ফোল্ডার চালান", + "autoReproducirAleatorio": "এলোমেলোভাবে চালান", + "autoPistaSinNombre": "নামহীন ট্র্যাক" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ad852ba..529a6e7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "Urlaubszeiträume für Wecker", "premiumBeneficioAlarmasIlimitadas": "Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)", "premiumPagoUnico": "Einmalzahlung, für immer. Kein Abonnement.", - "premiumAhoraNo": "Nicht jetzt" + "premiumAhoraNo": "Nicht jetzt", + "autoErrorEmisoraPremium": "Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.", + "autoErrorBusquedaSinResultados": "Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.", + "autoCarpetaEscuchar": "Hören", + "autoCarpetaFavoritos": "Favoriten", + "autoCarpetaTodas": "Alle Sender", + "autoCarpetaMisEmisoras": "Meine Sender", + "autoCarpetaMusicaLocal": "Lokale Musik", + "autoMusicaLocalNoDisponible": "Öffne PluriWave auf dem Handy, um deine Musik zu lesen", + "autoCargarMas": "Mehr…", + "autoOrdenarPorCalidad": "Nach Qualität sortieren", + "autoReproducirCarpeta": "Ordner abspielen", + "autoReproducirAleatorio": "Zufallswiedergabe", + "autoPistaSinNombre": "Unbenannter Titel" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 68865fc..ff368e4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "Vacation ranges for alarms", "premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)", "premiumPagoUnico": "One-time purchase, forever. Not a subscription.", - "premiumAhoraNo": "Not now" + "premiumAhoraNo": "Not now", + "autoErrorEmisoraPremium": "This station is Premium. Open PluriWave on your phone to unlock it.", + "autoErrorBusquedaSinResultados": "We couldn't find that station. Try another name.", + "autoCarpetaEscuchar": "Listen", + "autoCarpetaFavoritos": "Favorites", + "autoCarpetaTodas": "All stations", + "autoCarpetaMisEmisoras": "My stations", + "autoCarpetaMusicaLocal": "Local music", + "autoMusicaLocalNoDisponible": "Open PluriWave on your phone to read your music", + "autoCargarMas": "More…", + "autoOrdenarPorCalidad": "Sort by quality", + "autoReproducirCarpeta": "Play folder", + "autoReproducirAleatorio": "Shuffle play", + "autoPistaSinNombre": "Untitled track" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0805e81..4784316 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -871,5 +871,18 @@ "premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas", "premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)", "premiumPagoUnico": "Pago único, para siempre. No es una suscripción.", - "premiumAhoraNo": "Ahora no" + "premiumAhoraNo": "Ahora no", + "autoErrorEmisoraPremium": "Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.", + "autoErrorBusquedaSinResultados": "No hemos encontrado esa emisora. Prueba con otro nombre.", + "autoCarpetaEscuchar": "Escuchar", + "autoCarpetaFavoritos": "Favoritos", + "autoCarpetaTodas": "Todas las emisoras", + "autoCarpetaMisEmisoras": "Mis emisoras", + "autoCarpetaMusicaLocal": "Música Local", + "autoMusicaLocalNoDisponible": "Abre PluriWave en el móvil para leer tu música", + "autoCargarMas": "Más…", + "autoOrdenarPorCalidad": "Ordenar por calidad", + "autoReproducirCarpeta": "Reproducir carpeta", + "autoReproducirAleatorio": "Reproducir aleatorio", + "autoPistaSinNombre": "Pista sin nombre" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6ff0fe3..e8d609b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "Périodes de vacances pour les alarmes", "premiumBeneficioAlarmasIlimitadas": "Alarmes illimitées (la version gratuite en autorise jusqu'à 5)", "premiumPagoUnico": "Achat unique, pour toujours. Ce n'est pas un abonnement.", - "premiumAhoraNo": "Plus tard" + "premiumAhoraNo": "Plus tard", + "autoErrorEmisoraPremium": "Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.", + "autoErrorBusquedaSinResultados": "Nous n'avons pas trouvé cette station. Essaie un autre nom.", + "autoCarpetaEscuchar": "Écouter", + "autoCarpetaFavoritos": "Favoris", + "autoCarpetaTodas": "Toutes les stations", + "autoCarpetaMisEmisoras": "Mes stations", + "autoCarpetaMusicaLocal": "Musique locale", + "autoMusicaLocalNoDisponible": "Ouvrez PluriWave sur votre téléphone pour lire votre musique", + "autoCargarMas": "Plus…", + "autoOrdenarPorCalidad": "Trier par qualité", + "autoReproducirCarpeta": "Lire le dossier", + "autoReproducirAleatorio": "Lecture aléatoire", + "autoPistaSinNombre": "Piste sans nom" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9df9299..933b2a5 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि", "premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)", "premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।", - "premiumAhoraNo": "अभी नहीं" + "premiumAhoraNo": "अभी नहीं", + "autoErrorEmisoraPremium": "यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।", + "autoErrorBusquedaSinResultados": "वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।", + "autoCarpetaEscuchar": "सुनें", + "autoCarpetaFavoritos": "पसंदीदा", + "autoCarpetaTodas": "सभी स्टेशन", + "autoCarpetaMisEmisoras": "मेरे स्टेशन", + "autoCarpetaMusicaLocal": "लोकल संगीत", + "autoMusicaLocalNoDisponible": "अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें", + "autoCargarMas": "और…", + "autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें", + "autoReproducirCarpeta": "फ़ोल्डर चलाएँ", + "autoReproducirAleatorio": "शफ़ल चलाएँ", + "autoPistaSinNombre": "बिना नाम का ट्रैक" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4fd5dda..ef04ece 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "Rentang liburan untuk alarm", "premiumBeneficioAlarmasIlimitadas": "Alarm tanpa batas (paket gratis mengizinkan hingga 5)", "premiumPagoUnico": "Pembelian sekali bayar, untuk selamanya. Bukan langganan.", - "premiumAhoraNo": "Nanti saja" + "premiumAhoraNo": "Nanti saja", + "autoErrorEmisoraPremium": "Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.", + "autoErrorBusquedaSinResultados": "Kami tidak menemukan stasiun itu. Coba nama lain.", + "autoCarpetaEscuchar": "Dengarkan", + "autoCarpetaFavoritos": "Favorit", + "autoCarpetaTodas": "Semua stasiun", + "autoCarpetaMisEmisoras": "Stasiun saya", + "autoCarpetaMusicaLocal": "Musik lokal", + "autoMusicaLocalNoDisponible": "Buka PluriWave di ponsel untuk membaca musik Anda", + "autoCargarMas": "Lainnya…", + "autoOrdenarPorCalidad": "Urutkan menurut kualitas", + "autoReproducirCarpeta": "Putar folder", + "autoReproducirAleatorio": "Putar acak", + "autoPistaSinNombre": "Trek tanpa nama" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 04b322c..ce0fae0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "Intervalli di vacanza per le sveglie", "premiumBeneficioAlarmasIlimitadas": "Sveglie illimitate (il piano gratuito ne consente fino a 5)", "premiumPagoUnico": "Acquisto unico, per sempre. Non è un abbonamento.", - "premiumAhoraNo": "Non ora" + "premiumAhoraNo": "Non ora", + "autoErrorEmisoraPremium": "Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.", + "autoErrorBusquedaSinResultados": "Non abbiamo trovato quella stazione. Prova con un altro nome.", + "autoCarpetaEscuchar": "Ascolta", + "autoCarpetaFavoritos": "Preferiti", + "autoCarpetaTodas": "Tutte le emittenti", + "autoCarpetaMisEmisoras": "Le mie emittenti", + "autoCarpetaMusicaLocal": "Musica locale", + "autoMusicaLocalNoDisponible": "Apri PluriWave sul telefono per leggere la tua musica", + "autoCargarMas": "Altro…", + "autoOrdenarPorCalidad": "Ordina per qualità", + "autoReproducirCarpeta": "Riproduci cartella", + "autoReproducirAleatorio": "Riproduzione casuale", + "autoPistaSinNombre": "Traccia senza nome" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 6a4f0c0..3a24d37 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "アラームの休暇期間設定", "premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)", "premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。", - "premiumAhoraNo": "後で" + "premiumAhoraNo": "後で", + "autoErrorEmisoraPremium": "この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。", + "autoErrorBusquedaSinResultados": "その放送局は見つかりませんでした。別の名前をお試しください。", + "autoCarpetaEscuchar": "聴く", + "autoCarpetaFavoritos": "お気に入り", + "autoCarpetaTodas": "すべての局", + "autoCarpetaMisEmisoras": "マイ局", + "autoCarpetaMusicaLocal": "ローカルの音楽", + "autoMusicaLocalNoDisponible": "音楽を読み込むにはスマートフォンで PluriWave を開いてください", + "autoCargarMas": "もっと見る…", + "autoOrdenarPorCalidad": "音質順に並べ替え", + "autoReproducirCarpeta": "フォルダを再生", + "autoReproducirAleatorio": "シャッフル再生", + "autoPistaSinNombre": "名称未設定のトラック" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 53fcc29..68991c6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "Períodos de férias para os alarmes", "premiumBeneficioAlarmasIlimitadas": "Alarmes ilimitados (o plano gratuito permite até 5)", "premiumPagoUnico": "Pagamento único, para sempre. Não é uma assinatura.", - "premiumAhoraNo": "Agora não" + "premiumAhoraNo": "Agora não", + "autoErrorEmisoraPremium": "Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.", + "autoErrorBusquedaSinResultados": "Não encontrámos essa estação. Tente outro nome.", + "autoCarpetaEscuchar": "Ouvir", + "autoCarpetaFavoritos": "Favoritos", + "autoCarpetaTodas": "Todas as estações", + "autoCarpetaMisEmisoras": "As minhas estações", + "autoCarpetaMusicaLocal": "Música local", + "autoMusicaLocalNoDisponible": "Abra o PluriWave no telemóvel para ler a sua música", + "autoCargarMas": "Mais…", + "autoOrdenarPorCalidad": "Ordenar por qualidade", + "autoReproducirCarpeta": "Reproduzir pasta", + "autoReproducirAleatorio": "Reprodução aleatória", + "autoPistaSinNombre": "Faixa sem nome" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 162f35e..083f734 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "Периоды отпуска для будильников", "premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)", "premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.", - "premiumAhoraNo": "Не сейчас" + "premiumAhoraNo": "Не сейчас", + "autoErrorEmisoraPremium": "Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.", + "autoErrorBusquedaSinResultados": "Мы не нашли такую станцию. Попробуйте другое название.", + "autoCarpetaEscuchar": "Слушать", + "autoCarpetaFavoritos": "Избранное", + "autoCarpetaTodas": "Все станции", + "autoCarpetaMisEmisoras": "Мои станции", + "autoCarpetaMusicaLocal": "Локальная музыка", + "autoMusicaLocalNoDisponible": "Откройте PluriWave на телефоне, чтобы прочитать вашу музыку", + "autoCargarMas": "Ещё…", + "autoOrdenarPorCalidad": "Сортировать по качеству", + "autoReproducirCarpeta": "Воспроизвести папку", + "autoReproducirAleatorio": "Случайное воспроизведение", + "autoPistaSinNombre": "Трек без названия" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 17fb190..4a3dcc5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -912,5 +912,18 @@ "premiumBeneficioVacaciones": "闹钟的假期时间段", "premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)", "premiumPagoUnico": "一次性付费,永久使用,不是订阅。", - "premiumAhoraNo": "以后再说" + "premiumAhoraNo": "以后再说", + "autoErrorEmisoraPremium": "该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。", + "autoErrorBusquedaSinResultados": "没有找到该电台。请换个名称再试。", + "autoCarpetaEscuchar": "收听", + "autoCarpetaFavoritos": "收藏", + "autoCarpetaTodas": "全部电台", + "autoCarpetaMisEmisoras": "我的电台", + "autoCarpetaMusicaLocal": "本地音乐", + "autoMusicaLocalNoDisponible": "请在手机上打开 PluriWave 以读取您的音乐", + "autoCargarMas": "更多…", + "autoOrdenarPorCalidad": "按音质排序", + "autoReproducirCarpeta": "播放文件夹", + "autoReproducirAleatorio": "随机播放", + "autoPistaSinNombre": "未命名曲目" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 82e28c5..1c804c5 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3415,6 +3415,84 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Ahora no'** String get premiumAhoraNo; + + /// No description provided for @autoErrorEmisoraPremium. + /// + /// In es, this message translates to: + /// **'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.'** + String get autoErrorEmisoraPremium; + + /// No description provided for @autoErrorBusquedaSinResultados. + /// + /// In es, this message translates to: + /// **'No hemos encontrado esa emisora. Prueba con otro nombre.'** + String get autoErrorBusquedaSinResultados; + + /// No description provided for @autoCarpetaEscuchar. + /// + /// In es, this message translates to: + /// **'Escuchar'** + String get autoCarpetaEscuchar; + + /// No description provided for @autoCarpetaFavoritos. + /// + /// In es, this message translates to: + /// **'Favoritos'** + String get autoCarpetaFavoritos; + + /// No description provided for @autoCarpetaTodas. + /// + /// In es, this message translates to: + /// **'Todas las emisoras'** + String get autoCarpetaTodas; + + /// No description provided for @autoCarpetaMisEmisoras. + /// + /// In es, this message translates to: + /// **'Mis emisoras'** + String get autoCarpetaMisEmisoras; + + /// No description provided for @autoCarpetaMusicaLocal. + /// + /// In es, this message translates to: + /// **'Música Local'** + String get autoCarpetaMusicaLocal; + + /// No description provided for @autoMusicaLocalNoDisponible. + /// + /// In es, this message translates to: + /// **'Abre PluriWave en el móvil para leer tu música'** + String get autoMusicaLocalNoDisponible; + + /// No description provided for @autoCargarMas. + /// + /// In es, this message translates to: + /// **'Más…'** + String get autoCargarMas; + + /// No description provided for @autoOrdenarPorCalidad. + /// + /// In es, this message translates to: + /// **'Ordenar por calidad'** + String get autoOrdenarPorCalidad; + + /// No description provided for @autoReproducirCarpeta. + /// + /// In es, this message translates to: + /// **'Reproducir carpeta'** + String get autoReproducirCarpeta; + + /// No description provided for @autoReproducirAleatorio. + /// + /// In es, this message translates to: + /// **'Reproducir aleatorio'** + String get autoReproducirAleatorio; + + /// No description provided for @autoPistaSinNombre. + /// + /// In es, this message translates to: + /// **'Pista sin nombre'** + String get autoPistaSinNombre; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 856a93b..3ba128a 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1888,4 +1888,46 @@ class AppLocalizationsAr extends AppLocalizations { @override String get premiumAhoraNo => 'ليس الآن'; + + @override + String get autoErrorEmisoraPremium => + 'هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.'; + + @override + String get autoErrorBusquedaSinResultados => + 'لم نعثر على تلك المحطة. جرّب اسمًا آخر.'; + + @override + String get autoCarpetaEscuchar => 'الاستماع'; + + @override + String get autoCarpetaFavoritos => 'المفضلة'; + + @override + String get autoCarpetaTodas => 'كل المحطات'; + + @override + String get autoCarpetaMisEmisoras => 'محطاتي'; + + @override + String get autoCarpetaMusicaLocal => 'الموسيقى المحلية'; + + @override + String get autoMusicaLocalNoDisponible => + 'افتح PluriWave على هاتفك لقراءة موسيقاك'; + + @override + String get autoCargarMas => 'المزيد…'; + + @override + String get autoOrdenarPorCalidad => 'الترتيب حسب الجودة'; + + @override + String get autoReproducirCarpeta => 'تشغيل المجلد'; + + @override + String get autoReproducirAleatorio => 'تشغيل عشوائي'; + + @override + String get autoPistaSinNombre => 'مقطع بلا اسم'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 89fe034..e47b866 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1900,4 +1900,46 @@ class AppLocalizationsBn extends AppLocalizations { @override String get premiumAhoraNo => 'এখন নয়'; + + @override + String get autoErrorEmisoraPremium => + 'এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।'; + + @override + String get autoErrorBusquedaSinResultados => + 'সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।'; + + @override + String get autoCarpetaEscuchar => 'শুনুন'; + + @override + String get autoCarpetaFavoritos => 'প্রিয়'; + + @override + String get autoCarpetaTodas => 'সব স্টেশন'; + + @override + String get autoCarpetaMisEmisoras => 'আমার স্টেশন'; + + @override + String get autoCarpetaMusicaLocal => 'স্থানীয় সঙ্গীত'; + + @override + String get autoMusicaLocalNoDisponible => + 'আপনার গান পড়তে ফোনে PluriWave খুলুন'; + + @override + String get autoCargarMas => 'আরও…'; + + @override + String get autoOrdenarPorCalidad => 'মান অনুসারে সাজান'; + + @override + String get autoReproducirCarpeta => 'ফোল্ডার চালান'; + + @override + String get autoReproducirAleatorio => 'এলোমেলোভাবে চালান'; + + @override + String get autoPistaSinNombre => 'নামহীন ট্র্যাক'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 71e29b4..7dc6b38 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1913,4 +1913,46 @@ class AppLocalizationsDe extends AppLocalizations { @override String get premiumAhoraNo => 'Nicht jetzt'; + + @override + String get autoErrorEmisoraPremium => + 'Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.'; + + @override + String get autoErrorBusquedaSinResultados => + 'Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.'; + + @override + String get autoCarpetaEscuchar => 'Hören'; + + @override + String get autoCarpetaFavoritos => 'Favoriten'; + + @override + String get autoCarpetaTodas => 'Alle Sender'; + + @override + String get autoCarpetaMisEmisoras => 'Meine Sender'; + + @override + String get autoCarpetaMusicaLocal => 'Lokale Musik'; + + @override + String get autoMusicaLocalNoDisponible => + 'Öffne PluriWave auf dem Handy, um deine Musik zu lesen'; + + @override + String get autoCargarMas => 'Mehr…'; + + @override + String get autoOrdenarPorCalidad => 'Nach Qualität sortieren'; + + @override + String get autoReproducirCarpeta => 'Ordner abspielen'; + + @override + String get autoReproducirAleatorio => 'Zufallswiedergabe'; + + @override + String get autoPistaSinNombre => 'Unbenannter Titel'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 4b26591..ed661cc 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1893,4 +1893,46 @@ class AppLocalizationsEn extends AppLocalizations { @override String get premiumAhoraNo => 'Not now'; + + @override + String get autoErrorEmisoraPremium => + 'This station is Premium. Open PluriWave on your phone to unlock it.'; + + @override + String get autoErrorBusquedaSinResultados => + 'We couldn\'t find that station. Try another name.'; + + @override + String get autoCarpetaEscuchar => 'Listen'; + + @override + String get autoCarpetaFavoritos => 'Favorites'; + + @override + String get autoCarpetaTodas => 'All stations'; + + @override + String get autoCarpetaMisEmisoras => 'My stations'; + + @override + String get autoCarpetaMusicaLocal => 'Local music'; + + @override + String get autoMusicaLocalNoDisponible => + 'Open PluriWave on your phone to read your music'; + + @override + String get autoCargarMas => 'More…'; + + @override + String get autoOrdenarPorCalidad => 'Sort by quality'; + + @override + String get autoReproducirCarpeta => 'Play folder'; + + @override + String get autoReproducirAleatorio => 'Shuffle play'; + + @override + String get autoPistaSinNombre => 'Untitled track'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 5026540..365cb5d 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1908,4 +1908,46 @@ class AppLocalizationsEs extends AppLocalizations { @override String get premiumAhoraNo => 'Ahora no'; + + @override + String get autoErrorEmisoraPremium => + 'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.'; + + @override + String get autoErrorBusquedaSinResultados => + 'No hemos encontrado esa emisora. Prueba con otro nombre.'; + + @override + String get autoCarpetaEscuchar => 'Escuchar'; + + @override + String get autoCarpetaFavoritos => 'Favoritos'; + + @override + String get autoCarpetaTodas => 'Todas las emisoras'; + + @override + String get autoCarpetaMisEmisoras => 'Mis emisoras'; + + @override + String get autoCarpetaMusicaLocal => 'Música Local'; + + @override + String get autoMusicaLocalNoDisponible => + 'Abre PluriWave en el móvil para leer tu música'; + + @override + String get autoCargarMas => 'Más…'; + + @override + String get autoOrdenarPorCalidad => 'Ordenar por calidad'; + + @override + String get autoReproducirCarpeta => 'Reproducir carpeta'; + + @override + String get autoReproducirAleatorio => 'Reproducir aleatorio'; + + @override + String get autoPistaSinNombre => 'Pista sin nombre'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 65d1c6a..54ace9d 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1922,4 +1922,46 @@ class AppLocalizationsFr extends AppLocalizations { @override String get premiumAhoraNo => 'Plus tard'; + + @override + String get autoErrorEmisoraPremium => + 'Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.'; + + @override + String get autoErrorBusquedaSinResultados => + 'Nous n\'avons pas trouvé cette station. Essaie un autre nom.'; + + @override + String get autoCarpetaEscuchar => 'Écouter'; + + @override + String get autoCarpetaFavoritos => 'Favoris'; + + @override + String get autoCarpetaTodas => 'Toutes les stations'; + + @override + String get autoCarpetaMisEmisoras => 'Mes stations'; + + @override + String get autoCarpetaMusicaLocal => 'Musique locale'; + + @override + String get autoMusicaLocalNoDisponible => + 'Ouvrez PluriWave sur votre téléphone pour lire votre musique'; + + @override + String get autoCargarMas => 'Plus…'; + + @override + String get autoOrdenarPorCalidad => 'Trier par qualité'; + + @override + String get autoReproducirCarpeta => 'Lire le dossier'; + + @override + String get autoReproducirAleatorio => 'Lecture aléatoire'; + + @override + String get autoPistaSinNombre => 'Piste sans nom'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 13ed2c7..d98e83b 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1893,4 +1893,46 @@ class AppLocalizationsHi extends AppLocalizations { @override String get premiumAhoraNo => 'अभी नहीं'; + + @override + String get autoErrorEmisoraPremium => + 'यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।'; + + @override + String get autoErrorBusquedaSinResultados => + 'वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।'; + + @override + String get autoCarpetaEscuchar => 'सुनें'; + + @override + String get autoCarpetaFavoritos => 'पसंदीदा'; + + @override + String get autoCarpetaTodas => 'सभी स्टेशन'; + + @override + String get autoCarpetaMisEmisoras => 'मेरे स्टेशन'; + + @override + String get autoCarpetaMusicaLocal => 'लोकल संगीत'; + + @override + String get autoMusicaLocalNoDisponible => + 'अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें'; + + @override + String get autoCargarMas => 'और…'; + + @override + String get autoOrdenarPorCalidad => 'गुणवत्ता के अनुसार क्रमबद्ध करें'; + + @override + String get autoReproducirCarpeta => 'फ़ोल्डर चलाएँ'; + + @override + String get autoReproducirAleatorio => 'शफ़ल चलाएँ'; + + @override + String get autoPistaSinNombre => 'बिना नाम का ट्रैक'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index a44e4c0..cb480d3 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1904,4 +1904,46 @@ class AppLocalizationsId extends AppLocalizations { @override String get premiumAhoraNo => 'Nanti saja'; + + @override + String get autoErrorEmisoraPremium => + 'Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.'; + + @override + String get autoErrorBusquedaSinResultados => + 'Kami tidak menemukan stasiun itu. Coba nama lain.'; + + @override + String get autoCarpetaEscuchar => 'Dengarkan'; + + @override + String get autoCarpetaFavoritos => 'Favorit'; + + @override + String get autoCarpetaTodas => 'Semua stasiun'; + + @override + String get autoCarpetaMisEmisoras => 'Stasiun saya'; + + @override + String get autoCarpetaMusicaLocal => 'Musik lokal'; + + @override + String get autoMusicaLocalNoDisponible => + 'Buka PluriWave di ponsel untuk membaca musik Anda'; + + @override + String get autoCargarMas => 'Lainnya…'; + + @override + String get autoOrdenarPorCalidad => 'Urutkan menurut kualitas'; + + @override + String get autoReproducirCarpeta => 'Putar folder'; + + @override + String get autoReproducirAleatorio => 'Putar acak'; + + @override + String get autoPistaSinNombre => 'Trek tanpa nama'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 2344dc5..f402cb1 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1919,4 +1919,46 @@ class AppLocalizationsIt extends AppLocalizations { @override String get premiumAhoraNo => 'Non ora'; + + @override + String get autoErrorEmisoraPremium => + 'Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.'; + + @override + String get autoErrorBusquedaSinResultados => + 'Non abbiamo trovato quella stazione. Prova con un altro nome.'; + + @override + String get autoCarpetaEscuchar => 'Ascolta'; + + @override + String get autoCarpetaFavoritos => 'Preferiti'; + + @override + String get autoCarpetaTodas => 'Tutte le emittenti'; + + @override + String get autoCarpetaMisEmisoras => 'Le mie emittenti'; + + @override + String get autoCarpetaMusicaLocal => 'Musica locale'; + + @override + String get autoMusicaLocalNoDisponible => + 'Apri PluriWave sul telefono per leggere la tua musica'; + + @override + String get autoCargarMas => 'Altro…'; + + @override + String get autoOrdenarPorCalidad => 'Ordina per qualità'; + + @override + String get autoReproducirCarpeta => 'Riproduci cartella'; + + @override + String get autoReproducirAleatorio => 'Riproduzione casuale'; + + @override + String get autoPistaSinNombre => 'Traccia senza nome'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index fd3abcb..918e06f 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1836,4 +1836,45 @@ class AppLocalizationsJa extends AppLocalizations { @override String get premiumAhoraNo => '後で'; + + @override + String get autoErrorEmisoraPremium => + 'この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。'; + + @override + String get autoErrorBusquedaSinResultados => 'その放送局は見つかりませんでした。別の名前をお試しください。'; + + @override + String get autoCarpetaEscuchar => '聴く'; + + @override + String get autoCarpetaFavoritos => 'お気に入り'; + + @override + String get autoCarpetaTodas => 'すべての局'; + + @override + String get autoCarpetaMisEmisoras => 'マイ局'; + + @override + String get autoCarpetaMusicaLocal => 'ローカルの音楽'; + + @override + String get autoMusicaLocalNoDisponible => + '音楽を読み込むにはスマートフォンで PluriWave を開いてください'; + + @override + String get autoCargarMas => 'もっと見る…'; + + @override + String get autoOrdenarPorCalidad => '音質順に並べ替え'; + + @override + String get autoReproducirCarpeta => 'フォルダを再生'; + + @override + String get autoReproducirAleatorio => 'シャッフル再生'; + + @override + String get autoPistaSinNombre => '名称未設定のトラック'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index e18ff5e..8fe68cb 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1904,4 +1904,46 @@ class AppLocalizationsPt extends AppLocalizations { @override String get premiumAhoraNo => 'Agora não'; + + @override + String get autoErrorEmisoraPremium => + 'Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.'; + + @override + String get autoErrorBusquedaSinResultados => + 'Não encontrámos essa estação. Tente outro nome.'; + + @override + String get autoCarpetaEscuchar => 'Ouvir'; + + @override + String get autoCarpetaFavoritos => 'Favoritos'; + + @override + String get autoCarpetaTodas => 'Todas as estações'; + + @override + String get autoCarpetaMisEmisoras => 'As minhas estações'; + + @override + String get autoCarpetaMusicaLocal => 'Música local'; + + @override + String get autoMusicaLocalNoDisponible => + 'Abra o PluriWave no telemóvel para ler a sua música'; + + @override + String get autoCargarMas => 'Mais…'; + + @override + String get autoOrdenarPorCalidad => 'Ordenar por qualidade'; + + @override + String get autoReproducirCarpeta => 'Reproduzir pasta'; + + @override + String get autoReproducirAleatorio => 'Reprodução aleatória'; + + @override + String get autoPistaSinNombre => 'Faixa sem nome'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 51bcdd2..9ae6d1d 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1911,4 +1911,46 @@ class AppLocalizationsRu extends AppLocalizations { @override String get premiumAhoraNo => 'Не сейчас'; + + @override + String get autoErrorEmisoraPremium => + 'Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.'; + + @override + String get autoErrorBusquedaSinResultados => + 'Мы не нашли такую станцию. Попробуйте другое название.'; + + @override + String get autoCarpetaEscuchar => 'Слушать'; + + @override + String get autoCarpetaFavoritos => 'Избранное'; + + @override + String get autoCarpetaTodas => 'Все станции'; + + @override + String get autoCarpetaMisEmisoras => 'Мои станции'; + + @override + String get autoCarpetaMusicaLocal => 'Локальная музыка'; + + @override + String get autoMusicaLocalNoDisponible => + 'Откройте PluriWave на телефоне, чтобы прочитать вашу музыку'; + + @override + String get autoCargarMas => 'Ещё…'; + + @override + String get autoOrdenarPorCalidad => 'Сортировать по качеству'; + + @override + String get autoReproducirCarpeta => 'Воспроизвести папку'; + + @override + String get autoReproducirAleatorio => 'Случайное воспроизведение'; + + @override + String get autoPistaSinNombre => 'Трек без названия'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 7486964..1898235 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1821,4 +1821,44 @@ class AppLocalizationsZh extends AppLocalizations { @override String get premiumAhoraNo => '以后再说'; + + @override + String get autoErrorEmisoraPremium => + '该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。'; + + @override + String get autoErrorBusquedaSinResultados => '没有找到该电台。请换个名称再试。'; + + @override + String get autoCarpetaEscuchar => '收听'; + + @override + String get autoCarpetaFavoritos => '收藏'; + + @override + String get autoCarpetaTodas => '全部电台'; + + @override + String get autoCarpetaMisEmisoras => '我的电台'; + + @override + String get autoCarpetaMusicaLocal => '本地音乐'; + + @override + String get autoMusicaLocalNoDisponible => '请在手机上打开 PluriWave 以读取您的音乐'; + + @override + String get autoCargarMas => '更多…'; + + @override + String get autoOrdenarPorCalidad => '按音质排序'; + + @override + String get autoReproducirCarpeta => '播放文件夹'; + + @override + String get autoReproducirAleatorio => '随机播放'; + + @override + String get autoPistaSinNombre => '未命名曲目'; } diff --git a/lib/servicios/emisoras_destacadas.dart b/lib/servicios/emisoras_destacadas.dart new file mode 100644 index 0000000..1acdb8f --- /dev/null +++ b/lib/servicios/emisoras_destacadas.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../modelos/emisora.dart'; + +/// The last-played station's persistence key. +/// +/// MUST stay byte-identical to `EstadoRadio._keyUltimaEmisora` +/// (`lib/estado/estado_radio.dart`), which is the only writer. It is +/// duplicated here rather than exported from there on purpose: this file has +/// to be readable from the headless Android Auto engine, where `EstadoRadio` +/// is never constructed, and importing a `ChangeNotifier` that pulls in the +/// whole app-state graph just to read one string constant would drag the +/// entire phone-side stack into a car bind. `emisoras_destacadas_test.dart` +/// pins the literal so a rename on either side fails loudly. +const claveUltimaEmisora = 'ultima_emisora_v1'; + +/// The stations a FREE-tier driver can browse and play in the car +/// (fix/auto-quality-guidelines, item 6). +/// +/// Compiled into the binary, on purpose. Everything else the car could show +/// is empty on the bind a Play reviewer actually performs: a fresh install +/// is free tier (`esPremiumPersistido` is `getBool(...) ?? false`, no trial +/// key), `FuenteEmisorasAutoLocal.todas()` is literally +/// `_snapshotTodas ?? const []` until `EstadoRadio` pushes a network +/// snapshot that a headless bind never fetches, favourites and custom +/// stations are empty, and `ultima_emisora_v1` is absent. A curated const +/// list is the ONLY thing that can put real, playable rows in front of that +/// reviewer. +/// +/// Deliberately small. This is not a catalogue — the catalogue is the +/// premium feature. Six rows is enough to prove the app works and short +/// enough to read at a glance from a driving position. +/// +/// `favicon` is null for every entry on purpose: `artUriPara` then resolves +/// the on-brand bundled `station_art_*` drawable, so a browse row needs no +/// network at all to render its artwork. +/// +/// `uuid`s are app-owned (`pw-destacada-*`), not Radio Browser uuids: these +/// rows must resolve identically whether or not the catalogue is reachable, +/// and a Radio Browser uuid we cannot re-fetch would be a promise this file +/// cannot keep. +const List emisorasDestacadas = [ + Emisora( + uuid: 'pw-destacada-fip', + nombre: 'FIP', + url: 'https://icecast.radiofrance.fr/fip-midfi.mp3', + pais: 'France', + codigoPais: 'FR', + idioma: 'french', + ), + Emisora( + uuid: 'pw-destacada-france-inter', + nombre: 'France Inter', + url: 'https://icecast.radiofrance.fr/franceinter-midfi.mp3', + pais: 'France', + codigoPais: 'FR', + idioma: 'french', + ), + Emisora( + uuid: 'pw-destacada-deutschlandfunk', + nombre: 'Deutschlandfunk', + url: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3', + pais: 'Germany', + codigoPais: 'DE', + idioma: 'german', + ), + Emisora( + uuid: 'pw-destacada-kexp', + nombre: 'KEXP 90.3 FM', + url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3', + pais: 'United States', + codigoPais: 'US', + idioma: 'english', + ), + Emisora( + uuid: 'pw-destacada-radio-paradise', + nombre: 'Radio Paradise', + url: 'https://stream.radioparadise.com/mp3-128', + pais: 'United States', + codigoPais: 'US', + idioma: 'english', + ), + Emisora( + uuid: 'pw-destacada-soma-groove-salad', + nombre: 'SomaFM Groove Salad', + url: 'https://ice1.somafm.com/groovesalad-128-mp3', + pais: 'United States', + codigoPais: 'US', + idioma: 'english', + ), +]; + +/// The free tier's complete, ordered station set: the last station the user +/// actually played (when one is persisted) first, then [emisorasDestacadas], +/// deduplicated by `uuid`. +/// +/// Last-played goes first because it is the single row a returning driver is +/// most likely to want, and because it is the only entry that can make the +/// free folder feel like *their* app rather than a demo. It is NOT appended +/// a second time when it already belongs to the curated set. +/// +/// Follows `esPremiumPersistido({SharedPreferences? prefs})`'s +/// inject-or-`getInstance()` convention (`estado_entitlement.dart`), so a +/// test can pin prefs without a platform channel. +/// +/// Never throws: a corrupt/foreign `ultima_emisora_v1` payload, or a +/// `SharedPreferences` failure, degrades to the curated set alone. This runs +/// inside `getChildren`, and a browse call that throws is a dead folder. +Future> resolverEmisorasDestacadas({ + SharedPreferences? prefs, +}) async { + final ultima = await _ultimaEmisora(prefs: prefs); + if (ultima == null) return emisorasDestacadas; + return [ + ultima, + ...emisorasDestacadas.where((e) => e.uuid != ultima.uuid), + ]; +} + +/// Whether [uuid] belongs to [destacadas] — the predicate every play-path +/// gate reads to tell "free content" from "the premium catalogue". +/// +/// Pure, and takes the free universe rather than resolving it, so a caller +/// that already holds the list (every one of them does — it also needs it to +/// build the response) asks the question without a second prefs round trip. +/// +/// A `null` or empty [uuid] is never free: `emisora:` with no tail is a +/// malformed id, and matching it against an entry with an empty uuid would be +/// a resolution hole rather than a feature. +bool esEmisoraGratuita(String? uuid, List destacadas) => + uuid != null && uuid.isNotEmpty && destacadas.any((e) => e.uuid == uuid); + +/// [esEmisoraGratuita] against the CURRENT free set, resolved here. For +/// callers that do not already hold the list. +Future esEmisoraGratuitaPorUuid( + String uuid, { + SharedPreferences? prefs, +}) async => + esEmisoraGratuita(uuid, await resolverEmisorasDestacadas(prefs: prefs)); + +/// Reads the persisted last-played station, or `null` when there is none. +/// +/// Public because the Android Auto "recent" browse root +/// (`AudioService.recentRootId`) needs exactly this one station and nothing +/// else: `onGetRoot` (`AudioService.java:817-821`) answers `recent` whenever +/// the head unit sends `EXTRA_RECENT`, which Android Auto does on every +/// reconnect, and the platform expects a SINGLE resume item there — not a +/// station list, and not an empty folder. +/// +/// Tier-independent on purpose: this station is by definition one the user +/// has already played on this device, so offering to resume it is never +/// leaking premium content they have not already had. +Future ultimaEmisoraPersistida({SharedPreferences? prefs}) => + _ultimaEmisora(prefs: prefs); + +/// Reads the persisted last-played station, or `null` when there is none, +/// the payload is unreadable, or prefs themselves fail. +Future _ultimaEmisora({SharedPreferences? prefs}) async { + try { + final resueltas = prefs ?? await SharedPreferences.getInstance(); + final raw = resueltas.getString(claveUltimaEmisora); + if (raw == null) return null; + final emisora = Emisora.fromMap(jsonDecode(raw) as Map); + // A record with no uuid or no url cannot be turned into a playable + // `emisora:` row, so it is worse than absent: it would occupy the + // first slot with a row that does nothing when tapped. + if (emisora.uuid.isEmpty || emisora.url.isEmpty) return null; + return emisora; + } catch (_) { + return null; + } +} diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 842fa0b..71ec13f 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -10,6 +10,7 @@ import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; +import 'emisoras_destacadas.dart'; import 'musica_local_auto.dart'; import 'persistencia_tolerante.dart'; import 'servicio_favoritos.dart'; @@ -200,10 +201,97 @@ abstract class FuenteEmisorasAuto { }) {} } +/// Every user-readable label of the Android Auto browse tree, already +/// resolved to one locale by the caller. +/// +/// THE RULE (fix/auto-quality-guidelines, l10n item): anything a user can +/// read gets translated. This bundle replaces the previous +/// "car-tree labels are hardcoded Spanish, deliberately NOT an arb key" +/// convention, which was defensible only while those labels sat deep inside +/// a premium tree and stopped being defensible the moment Google Play +/// reviewed the car surface on an English head unit. +/// +/// It exists as a plain value object rather than an `AppLocalizations` +/// dependency so [ConstructorArbolAuto] stays a PURE builder — the same +/// reason `itemsEcualizadorAuto` lives in `servicio_audio.dart`. The handler, +/// which can resolve localizations headlessly through +/// `resolverLocalizacionesRespaldo`, builds one via +/// `etiquetasArbolAutoDesde` and hands it in. +/// +/// NOT in here on purpose: the alphabetical bucket labels (`'A-F'`, `'G-M'`, +/// …). Those are ranges of Latin letters, not prose — translating them would +/// make them lie about which filenames they contain. +class EtiquetasArbolAuto { + const EtiquetasArbolAuto({ + required this.escuchar, + required this.favoritos, + required this.todasLasEmisoras, + required this.misEmisoras, + required this.musicaLocal, + required this.musicaLocalNoDisponible, + required this.cargarMas, + required this.ordenarPorCalidad, + required this.reproducirCarpeta, + required this.reproducirAleatorio, + required this.pistaSinNombre, + }); + + /// Fallback bundle for callers that have no localizations to hand: pure + /// builder tests, and any future non-car consumer. + /// + /// It is NOT what the car shows. `ServicioAudio` always injects a bundle + /// resolved from `AppLocalizations`, in every browse and playback path + /// that can produce a label — `etiquetas_arbol_auto_test.dart` is the + /// guard that no NEW hardcoded label can be introduced alongside these. + static const respaldo = EtiquetasArbolAuto( + escuchar: 'Escuchar', + favoritos: 'Favoritos', + todasLasEmisoras: 'Todas las emisoras', + misEmisoras: 'Mis emisoras', + musicaLocal: 'Música Local', + musicaLocalNoDisponible: 'Abre PluriWave en el móvil para leer tu música', + cargarMas: 'Más…', + ordenarPorCalidad: 'Ordenar por calidad', + reproducirCarpeta: 'Reproducir carpeta', + reproducirAleatorio: 'Reproducir aleatorio', + pistaSinNombre: 'Pista sin nombre', + ); + + /// The free tier's single root folder ([ConstructorArbolAuto.idDestacadas]). + final String escuchar; + + /// Premium root folders. + final String favoritos; + final String todasLasEmisoras; + final String misEmisoras; + final String musicaLocal; + + /// The non-playable row shown when the local-music folder cannot be read + /// from the car ([ConstructorArbolAuto.idLocalNoLista]). + final String musicaLocalNoDisponible; + + /// Trailing "load more" row of every paged local-music view. + final String cargarMas; + + /// Local-folder navigation and action rows. + final String ordenarPorCalidad; + final String reproducirCarpeta; + final String reproducirAleatorio; + + /// Fallback title for a local file whose name is blank after stripping. + final String pistaSinNombre; +} + /// Pure builder for the Android Auto browse tree: folders, leaf items, id /// resolution. No platform dependency — fully testable without a running /// car or a real `AudioHandler`. class ConstructorArbolAuto { + const ConstructorArbolAuto({this.etiquetas = EtiquetasArbolAuto.respaldo}); + + /// The already-localized labels this builder stamps onto every + /// user-readable `MediaItem` it produces. + final EtiquetasArbolAuto etiquetas; + /// Root folder ids (Design "media-id scheme"). The tree root itself is /// identified by [AudioService.browsableRootId], not by a constant here — /// the handler compares against it directly before calling [raiz]. @@ -211,6 +299,17 @@ class ConstructorArbolAuto { static const idTodas = 'todas'; static const idMisEmisoras = 'mis_emisoras'; + /// Root folder id for the FREE tier's only browsable folder + /// (fix/auto-quality-guidelines, item 8). + /// + /// Deliberately NOT added to [_idsCarpetas] — like [idMusicaLocal] and + /// [idEcualizador] it has its own dedicated children ([hijosDestacadas]), + /// fed by `emisoras_destacadas.dart`'s compiled-in set rather than by the + /// generic station-list [hijos] path over a `FuenteEmisorasAuto` that is + /// empty on the bind a Play reviewer actually performs. + static const idDestacadas = 'destacadas'; + + /// Root folder id for the local-music browsable root (Design "media-id /// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own /// dedicated branch (`hijosMusicaLocal`), not the generic station-list @@ -322,8 +421,10 @@ class ConstructorArbolAuto { 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2, }; - /// The root folders (Favoritos, Todas las emisoras, Mis emisoras, - /// optionally Música Local, Ecualizador), all non-playable. + /// The root folders, all non-playable, and TIER-DEPENDENT: Favoritos, + /// Todas las emisoras, Mis emisoras and optionally Música Local for a + /// premium driver; the single [idDestacadas] folder for a free one (see + /// [premium] below). /// /// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer /// There is NO `Ecualizador` folder. The car's only equalizer control is @@ -343,53 +444,77 @@ 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 lo deriva de - /// `fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada` + /// `premium && 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. + /// nodo; fix/auto-quality-guidelines item 9: el `premium &&` va delante a + /// propósito, para que el tier gratuito ni siquiera pague ese round trip + /// nativo), 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 - /// folder labels for free users" is the explicit design choice, so a free - /// driver still sees a real, familiar menu rather than a wall of "Función - /// Premium" rows. The lock itself is enforced one level DOWN, at the - /// `getChildren` choke point (see [itemPremiumBloqueado] and - /// [respuestaBloqueadaPorEntitlement] below) — tapping any of these - /// folders as a free user reveals the lock there, never here. + /// [premium] (fix/auto-quality-guidelines, item 8) is finally READ. It used + /// to be accepted and ignored, on the theory that "the root keeps the same + /// visible folder labels for free users" was friendlier than a reduced + /// menu. It was not: every one of those four folders dead-ended on a single + /// non-playable "Función Premium" row, and Google Play cited exactly that + /// against the Android for Cars App Quality Guidelines. + /// + /// The free root is therefore ONE browsable folder, [idDestacadas], and the + /// premium-only folders are OMITTED rather than shown-and-blocked: a folder + /// a driver cannot use is worse than a folder that is not there. + /// + /// It must stay at least one BROWSABLE item, never a bare playable one: + /// `audio_service` 0.18.18 discards `rootHints` + /// (`AudioService.java:817-826`), so this code cannot detect whether the + /// head unit accepts a `FLAG_PLAYABLE` root child, and the documented + /// default of `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS` is + /// `FLAG_BROWSABLE` alone — a root of one playable item renders EMPTY on + /// such a unit. + /// + /// Every label here comes from [etiquetas], already resolved to the head + /// unit's locale — the free root's [EtiquetasArbolAuto.escuchar] AND the + /// four premium folders. + /// + /// The four premium ones used to be hardcoded Spanish, on the theory that + /// they were leaf rows deep inside a tree only a user who had already + /// chosen the app would reach. That was never a rule, only an untested + /// assumption, and it is retired: anything a user can read gets + /// translated. `escuchar` was localized first (it is 100% of what a free + /// Play reviewer sees), which is exactly why the rest had to follow. + /// + /// [tituloDestacadas] stays as an explicit per-call override of + /// [EtiquetasArbolAuto.escuchar]; `null` (the default) uses the bundle. List raiz({ required bool incluirMusicaLocal, required bool premium, - }) => [ - _carpeta(idFavoritos, 'Favoritos'), - _carpeta(idTodas, 'Todas las emisoras'), - _carpeta(idMisEmisoras, 'Mis emisoras'), - if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), - ]; + String? tituloDestacadas, + }) => + premium + ? [ + _carpeta(idFavoritos, etiquetas.favoritos), + _carpeta(idTodas, etiquetas.todasLasEmisoras), + _carpeta(idMisEmisoras, etiquetas.misEmisoras), + if (incluirMusicaLocal) + _carpeta(idMusicaLocal, etiquetas.musicaLocal), + ] + : [_carpeta(idDestacadas, tituloDestacadas ?? etiquetas.escuchar)]; - /// Free-tier id prefix reserved id (iap-freemium-unlock, Design ADR-4): - /// the single non-playable item every non-root folder collapses to for a - /// free-tier user. Hardcoded Spanish label, matching every other car-tree - /// label in this file (never routed through `AppLocalizations` — - /// established convention, see [_tituloMasLocal]'s doc). - static const idPremiumInfo = 'premium:info'; + /// The free tier's playable station rows (fix/auto-quality-guidelines, + /// items 8/9): [emisoras] mapped through the SAME [itemEmisora] the premium + /// folders use, capped like every other folder. + /// + /// Separate from [hijos] because that path is gated on [_idsCarpetas] and + /// fed by a `FuenteEmisorasAuto` whose lists are all empty on a cold + /// headless bind — which is precisely the bind this folder has to survive. + /// An empty [emisoras] returns `[]` rather than any placeholder row: a + /// non-playable row in the car tree is the thing Play cited. + List hijosDestacadas(List emisoras) => + emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); - /// The single locked item shown for ANY non-root folder when the browsing - /// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier - /// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a - /// crash (Spec "Free-tier user selects a locked item"). - MediaItem itemPremiumBloqueado() => MediaItem( - id: idPremiumInfo, - title: 'Función Premium', - playable: false, - 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. + /// El item de [idLocalNoLista]. Rotulado con + /// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del + /// head unit. No reproducible — seleccionarlo es un no-op. MediaItem itemLocalNoDisponible() => MediaItem( id: idLocalNoLista, - title: 'Abre PluriWave en el móvil para leer tu música', + title: etiquetas.musicaLocalNoDisponible, playable: false, extras: _contentStyleLista, ); @@ -488,20 +613,15 @@ class ConstructorArbolAuto { return (documentId, pagina); } - /// Hardcoded-Spanish car-tree label for the trailing "load more" item - /// (Design ADR-5) — matches every other car-tree label in this file - /// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of - /// which go through `AppLocalizations`. Deliberately NOT an arb key. - static const _tituloMasLocal = 'Más…'; - /// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no /// `artUri` (the label alone is the affordance, like [_carpeta]), id /// `carpeta_local_pag::` — round-trips /// via [paginaCarpetaLocalDesde] back to the parent folder's next page. + /// Rotulado con [EtiquetasArbolAuto.cargarMas]. MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) => MediaItem( id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre', - title: _tituloMasLocal, + title: etiquetas.cargarMas, playable: false, extras: _contentStyleLista, ); @@ -626,41 +746,41 @@ class ConstructorArbolAuto { /// for small folders. bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets; - /// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4): + /// The "sort by quality" mode-entry `MediaItem` (Design ADR-4): /// non-playable, id `carpeta_local_ord:calidad:0:` — /// always page 0 of the sorted view, round-trips via [ordenLocalDesde]. - /// Hardcoded Spanish label, matching every other car-tree label in this - /// file — never routed through `AppLocalizations` (established - /// car-tree-label precedent, see [_tituloMasLocal]). + /// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad]. MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta( '${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre', - 'Ordenar por calidad', + etiquetas.ordenarPorCalidad, ); /// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id /// `carpeta_local_bucket::0:` — always page 0, - /// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded - /// alphabetical-range label (e.g. `'A-F'`), matching every other - /// car-tree label in this file — never routed through `AppLocalizations`. + /// round-trips via [bucketLocalDesde]. + /// + /// [etiqueta] is an alphabetical RANGE (e.g. `'A-F'`), and it is the one + /// user-visible car-tree string that deliberately does NOT go through + /// [EtiquetasArbolAuto]: it names the Latin letters the folder's filenames + /// actually start with, so translating it would make it lie. MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) => _carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta); - /// The "Reproducir carpeta" playable action item (Design ADR-5): id - /// `carpeta_local_reproducir:`. Hardcoded Spanish label, - /// matching every other car-tree label in this file — never routed - /// through `AppLocalizations`. + /// The "play folder" playable action item (Design ADR-5): id + /// `carpeta_local_reproducir:`. Rotulado con + /// [EtiquetasArbolAuto.reproducirCarpeta]. MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem( id: '$_prefijoCarpetaLocalReproducir$documentIdPadre', - title: 'Reproducir carpeta', + title: etiquetas.reproducirCarpeta, playable: true, extras: _contentStyleGrid, ); - /// The "Reproducir aleatorio" playable action item (Design ADR-5), + /// The "shuffle play" playable action item (Design ADR-5), /// mirrors [_itemReproducirCarpeta]. MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem( id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre', - title: 'Reproducir aleatorio', + title: etiquetas.reproducirAleatorio, playable: true, extras: _contentStyleGrid, ); @@ -742,7 +862,7 @@ class ConstructorArbolAuto { int siguientePagina, ) => MediaItem( id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre', - title: _tituloMasLocal, + title: etiquetas.cargarMas, playable: false, extras: _contentStyleLista, ); @@ -756,7 +876,7 @@ class ConstructorArbolAuto { int siguientePagina, ) => MediaItem( id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre', - title: _tituloMasLocal, + title: etiquetas.cargarMas, playable: false, extras: _contentStyleLista, ); @@ -836,7 +956,7 @@ class ConstructorArbolAuto { final titulo = (tituloMeta != null && tituloMeta.isNotEmpty) ? tituloMeta - : _tituloDesdeNombre(nodo.nombre); + : _tituloDesdeNombre(nodo.nombre, etiquetas.pistaSinNombre); final artUriMeta = meta?.artUri?.trim(); final artUri = (artUriMeta != null && artUriMeta.isNotEmpty) @@ -953,24 +1073,56 @@ class ConstructorArbolAuto { } } -/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design -/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any -/// other resolution. For the root itself this NEVER blocks (the root always -/// resolves through [ConstructorArbolAuto.raiz] instead, which stays -/// visible for every tier). For any non-root [parentMediaId] and a free-tier -/// [premium], it returns the single locked item regardless of what the id -/// actually is — a stale/deep-linked `emisora:` or folder id from -/// before a downgrade is blocked exactly the same way as a legitimate -/// current folder id (android-auto-media spec "Free-Tier Browse Never -/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the -/// caller should proceed with its normal resolution (root, or premium). +/// Whether [parentMediaId] is content the FREE tier is allowed to browse +/// (fix/auto-quality-guidelines, item 10): the browsable root itself, the +/// free folder [ConstructorArbolAuto.idDestacadas], and an `emisora:` +/// whose uuid belongs to [destacadas]. +/// +/// Everything else — the catalogue folders, favourites, custom stations, +/// local music, the equalizer folder, group folders, local tracks, and any +/// station uuid that is not in the free set — is premium content. +/// +/// Pure and id-shaped, with the free universe INJECTED, so the whole matrix +/// is testable without prefs or a handler. +bool idPermitidoEnFree( + String parentMediaId, { + required List destacadas, +}) { + if (parentMediaId == AudioService.browsableRootId) return true; + if (parentMediaId == ConstructorArbolAuto.idDestacadas) return true; + if (!parentMediaId.startsWith(_prefijoEmisora)) return false; + final uuid = parentMediaId.substring(_prefijoEmisora.length); + if (uuid.isEmpty) return false; + return destacadas.any((e) => e.uuid == uuid); +} + +/// Pure Android Auto browse-gate decision: the AUTHORITATIVE `getChildren` +/// choke point, called BEFORE any other resolution. +/// +/// REWRITTEN (fix/auto-quality-guidelines, item 10) from action-blocking to +/// content-scoping. It used to answer ANY non-root id, for a free-tier user, +/// with a single non-playable "Función Premium" row — which is what Google +/// Play cited on version code 157 ("clicking on stop button makes the entire +/// app useless" was the headline, but the browse tree it was reviewed +/// against was four folders that each dead-ended on that row). A +/// non-playable row reachable from a head unit's CACHED tree is a citation +/// waiting to happen, so there is no longer any code path that can produce +/// one: the blocked branch returns the free tier's own playable stations. +/// +/// Returns `null` when the caller should proceed with its normal resolution +/// (premium, or free-tier content the free tier owns). +/// +/// [destacadas] is the free universe (`resolverEmisorasDestacadas()`); the +/// caller resolves it once per browse. Passing an empty list is legal and +/// yields an empty blocked response — still never a dead row. List? respuestaBloqueadaPorEntitlement({ required String parentMediaId, required bool premium, + required List destacadas, }) { - if (parentMediaId == AudioService.browsableRootId) return null; if (premium) return null; - return [ConstructorArbolAuto().itemPremiumBloqueado()]; + if (idPermitidoEnFree(parentMediaId, destacadas: destacadas)) return null; + return ConstructorArbolAuto().hijosDestacadas(destacadas); } /// Routing seam between a car-tapped `emisora:` media id and the @@ -982,17 +1134,21 @@ List? respuestaBloqueadaPorEntitlement({ /// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is /// never called and no exception propagates (Spec "Unknown or stale media /// id"). -Future reproducirPorMediaId( +/// +/// RETURNS whether it actually dispatched (fix/auto-quality-guidelines, +/// item 12). The caller needs to tell "played" from "resolved to nothing" +/// so the second case can publish an explained error to the car instead of +/// leaving the driver with a tap that did nothing and said nothing. +Future reproducirPorMediaId( String id, { required FuenteEmisorasAuto fuente, required Future Function(MediaItem) reproducir, }) async { - if (!id.startsWith(_prefijoEmisora)) return; - final uuid = id.substring(_prefijoEmisora.length); - if (uuid.isEmpty) return; + final uuid = uuidDeMediaIdEmisora(id); + if (uuid == null) return false; final emisora = await fuente.porUuid(uuid); - if (emisora == null) return; + if (emisora == null) return false; final item = MediaItem( id: emisora.url, @@ -1008,6 +1164,58 @@ Future reproducirPorMediaId( extras: {'uuid': emisora.uuid}, ); await reproducir(item); + return true; +} + +/// The uuid inside an `emisora:` media id, or `null` for any other +/// shape — no prefix (a `pista:`/`carpeta_local_*`/`eq_preset:` id, or a +/// folder id) and an empty tail both answer `null`. +/// +/// Extracted (fix/auto-quality-guidelines, item 11) because the play-path +/// entitlement gate has to ask the same question `reproducirPorMediaId` asks, +/// one step earlier: "is this a station id, and which station?". +String? uuidDeMediaIdEmisora(String id) { + if (!id.startsWith(_prefijoEmisora)) return null; + final uuid = id.substring(_prefijoEmisora.length); + return uuid.isEmpty ? null : uuid; +} + +/// A [FuenteEmisorasAuto] over nothing but the free tier's station set +/// (fix/auto-quality-guidelines, item 12). +/// +/// Stands in for `_fuenteNavegacionGlobal` while that is still `null` — the +/// window between the headless Android Auto engine starting and `main.dart` +/// registering the real source. A tap arriving in that window used to return +/// in silence; the free set is compiled into the binary, so it can always be +/// answered. +/// +/// Reports the free stations through [todas] (they are, from the car's point +/// of view, everything there is) and nothing through the curated lists, which +/// a headless bind could not populate anyway. +class FuenteEmisorasAutoDestacadas extends FuenteEmisorasAuto { + FuenteEmisorasAutoDestacadas(this._destacadas); + + final List _destacadas; + + @override + Future> favoritos() async => const []; + + @override + Future> misEmisoras() async => const []; + + @override + Future> todas() async => _destacadas; + + @override + Future> grupos() async => const []; + + @override + Future porUuid(String uuid) async { + for (final emisora in _destacadas) { + if (emisora.uuid == uuid) return emisora; + } + return null; + } } /// Which list previous/next should walk for [actual]: the NARROWEST context @@ -1188,25 +1396,22 @@ Future seleccionarPresetEqPorMediaId( if (!activo) await activarEcualizador(true); } -/// Fallback title (Design "Title = filename minus extension") for a blank -/// or otherwise empty-after-stripping local filename — hardcoded Spanish, -/// matching every other car-tree label in this file (`'Favoritos'`, -/// `'Música Local'`, etc.), none of which go through `AppLocalizations`. -const _tituloLocalFallback = 'Pista sin nombre'; - /// Filename → display title (Design "Title = filename minus extension"): /// strips the LAST `.ext` (the whole trimmed name is kept when there is no /// dot, or the dot is the first character — e.g. a hidden file like -/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be -/// blank. -String _tituloDesdeNombre(String nombre) { +/// `.mp3`), falling back to [sinNombre] when the result would be blank. +/// +/// [sinNombre] is [EtiquetasArbolAuto.pistaSinNombre], passed in rather than +/// hardcoded: it is a title the driver reads, so it is translated like every +/// other car-tree label. +String _tituloDesdeNombre(String nombre, String sinNombre) { final recortado = nombre.trim(); - if (recortado.isEmpty) return _tituloLocalFallback; + if (recortado.isEmpty) return sinNombre; final ultimoPunto = recortado.lastIndexOf('.'); final sinExtension = ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado; final resultado = sinExtension.trim(); - return resultado.isEmpty ? _tituloLocalFallback : resultado; + return resultado.isEmpty ? sinNombre : resultado; } /// Resolves the on-brand fallback `artUri` for a local track (Design "art = @@ -1479,12 +1684,13 @@ Future reproducirCarpetaLocal( Future construirMediaItemColaLocal( NodoLocal nodo, { required FuenteMusicaLocalAuto fuente, + EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo, }) async { final contentUri = await fuente.uriContenidoDePista(nodo.documentId); if (contentUri == null || contentUri.isEmpty) return null; return MediaItem( id: contentUri, - title: _tituloDesdeDocumentId(nodo.documentId), + title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre), album: 'PluriWave', // Item 3: a queued local track had NO artUri at all before — reuses // [artUriLocal] (the SAME on-brand rotation the browse tree's @@ -1548,8 +1754,9 @@ Future> _metadatosDeConCache( Future?> hijosMusicaLocal( String parentMediaId, { required FuenteMusicaLocalAuto? fuente, + EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo, }) async { - final constructor = ConstructorArbolAuto(); + final constructor = ConstructorArbolAuto(etiquetas: etiquetas); // Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST — // routing order is irrelevant to correctness (every prefix in this file @@ -1640,11 +1847,11 @@ Future?> hijosMusicaLocal( /// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the /// Now Playing title consistent with what the user tapped without requiring /// a second native round trip. -String _tituloDesdeDocumentId(String documentId) { +String _tituloDesdeDocumentId(String documentId, String sinNombre) { final ultimaBarra = documentId.lastIndexOf('/'); final segmento = ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId; - return _tituloDesdeNombre(segmento); + return _tituloDesdeNombre(segmento, sinNombre); } /// Routing seam between a car-tapped `pista:` media id and the @@ -1663,6 +1870,7 @@ Future reproducirPistaLocal( String id, { required FuenteMusicaLocalAuto fuente, required Future Function(MediaItem) reproducir, + EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo, }) async { if (!esPistaMediaId(id)) return; final documentId = id.substring(_prefijoPista.length); @@ -1673,7 +1881,7 @@ Future reproducirPistaLocal( final pista = PistaLocal( documentId: documentId, - titulo: _tituloDesdeDocumentId(documentId), + titulo: _tituloDesdeDocumentId(documentId, etiquetas.pistaSinNombre), contentUri: contentUri, ); @@ -1768,6 +1976,16 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto { return _snapshotTodas ?? const []; } + /// Resolves a station uuid across every list this source can reach. + /// + /// The free tier's set ([resolverEmisorasDestacadas]) is searched LAST + /// (fix/auto-quality-guidelines, item 7). It has to be searched at all + /// because on a cold headless bind the three lists above are all empty — + /// `todas()` is `_snapshotTodas ?? const []`, favourites and custom + /// stations have nothing persisted on a fresh install — so a curated + /// `emisora:` resolved to `null` and tapping the row did NOTHING. + /// It is searched last so a live catalogue/favourite record for the same + /// uuid (richer metadata, the user's own group assignment) still wins. @override Future porUuid(String uuid) async { final listas = await Future.wait([favoritos(), misEmisoras(), todas()]); @@ -1776,6 +1994,9 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto { if (emisora.uuid == uuid) return emisora; } } + for (final emisora in await resolverEmisorasDestacadas()) { + if (emisora.uuid == uuid) return emisora; + } return null; } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 60951f1..efa8724 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:ui' show Locale; +import 'dart:ui' show Locale, PlatformDispatcher; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; @@ -14,6 +14,7 @@ import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; import 'cola_local.dart'; import 'controlador_reconexion.dart'; +import 'emisoras_destacadas.dart'; import 'musica_local_auto.dart'; import 'navegacion_auto.dart'; import 'servicio_audio_session.dart'; @@ -283,17 +284,73 @@ void invalidarArbolAuto() { /// 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 -/// [premium]. This is the mandatory BACKSTOP alongside -/// `respuestaBloqueadaPorEntitlement` (`navegacion_auto.dart`) — gating -/// `getChildren` alone would leave a stale/cached `emisora:` tap free -/// to bypass browsing entirely (android-auto-media spec "Free-Tier Browse -/// Never Leaks Real Content"). Deliberately does NOT gate `play`/`pause`/ -/// `stop` — transport control of whatever is ALREADY loaded stays free -/// (Spec "Current-Station Playback Unaffected By Free Tier"). -bool debeBloquearCambioDeEmisora({required bool premium}) => !premium; +/// Pure Android Auto play-path gate decision: whether a station-switch +/// dispatch (`playFromMediaId`, `playFromSearch`) must be refused. +/// +/// NARROWED (fix/auto-quality-guidelines, item 11) from `!premium`. It used +/// to refuse EVERY switch for a free-tier user, which is what made the car +/// surface useless for the only tier a Play reviewer can ever be in: the +/// browse tree offered rows, and tapping any of them did nothing at all. +/// +/// The original safety property survives intact and is the whole reason this +/// takes [esEmisoraGratuita] rather than being deleted: a stale +/// `emisora:` from a head unit's CACHED tree, fetched before a +/// downgrade or from another device, still cannot play, because its uuid is +/// not in the free set. `getChildren` gating alone cannot stop that tap. +/// +/// Deliberately does NOT gate `play`/`pause`/`stop` — transport control of +/// whatever is ALREADY loaded stays free for every tier. +bool debeBloquearCambioDeEmisora({ + required bool premium, + required bool esEmisoraGratuita, +}) => !premium && !esEmisoraGratuita; + +/// `PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED` (4) — the exact +/// platform code for "this content needs a paid account", forwarded verbatim +/// by the plugin alongside `errorMessage`. +/// +/// Only the `ERROR_RESOLUTION_*` extras (which would render a tappable +/// "upgrade" button on the head unit) are unreachable from Dart on +/// audio_service 0.18.18, and Google's Android for Cars errors documentation +/// explicitly accepts putting the "open the app on your phone" instruction in +/// the message text instead — which is what `autoErrorEmisoraPremium` does. +const codigoErrorEmisoraPremium = 4; + +/// `PlaybackStateCompat.ERROR_CODE_APP_ERROR` (1) — a requested media id the +/// app could not turn into anything playable (stale/unknown uuid, or a voice +/// query that matched nothing). +const codigoErrorEmisoraNoDisponible = 1; + +/// Whether an Android Auto ACTION refusal (a premium tap, a voice query that +/// matched nothing) may be published as a full [AudioProcessingState.error], +/// given the state the session is already in. +/// +/// This is the guard on the one-way door in `getPlaybackState()` +/// (`AudioService.java:601-611`): `error` maps to `STATE_ERROR` +/// UNCONDITIONALLY there — the `playing` flag is not consulted — so an +/// `error` published over live audio replaces the entire now-playing screen, +/// play/pause/stop included, while the station is still audibly playing. +/// +/// And it cannot self-heal. `just_audio`'s `playerStateStream` is +/// `.distinct()` (`just_audio.dart:279-285`), so a steadily playing ExoPlayer +/// emits nothing further and [manejarEstadoPlayer] never runs again; +/// `_bufferedSub` then re-asserts the same `error` ~2x/second through +/// `copyWith`; and the transient-state floor deliberately excludes `error` +/// (see [_esEstadoTransitorio]). Nothing left in the process would ever clear +/// it. The free candidate set is only the six compiled-in stations, so almost +/// any spoken station name misses — one voice miss and the car is stranded. +/// +/// So: only a session with nothing to lose (`idle`, or an error already on +/// screen) may be moved to `error`. A LOADED session — playing, paused, +/// buffering or loading — keeps its state, and the refusal is carried by +/// `errorCode`/`errorMessage` alone, which `setState` forwards to +/// `PlaybackStateCompat.setErrorMessage` regardless of the state +/// (`AudioService.java:541-544`). The driver is told; the session survives. +/// +/// Pure and top-level so the whole matrix is testable without a handler, +/// exactly like [mapearEstadoProceso]. +bool puedePublicarErrorTerminalAuto(AudioProcessingState estado) => + estado == AudioProcessingState.idle || estado == AudioProcessingState.error; /// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android /// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a @@ -373,13 +430,35 @@ Emisora emisoraDesdeMediaItem(MediaItem mediaItem) { /// `idle` and still tears the service down. Pure — no handler dependency — so /// the full [ProcessingState] x [cambiandoFuente] matrix is unit-testable /// directly. +/// +/// [errorTerminal] is the SECOND masked `idle`, and it exists for the same +/// class of bug as the first. After a terminal playback error this file +/// publishes `error` + a message and then calls `_player.stop()`, which +/// switches `just_audio` to the idle dummy platform +/// (`just_audio.dart:1016-1025`); `playerStateStream` emits a distinct +/// `(playing: false, idle)`, this function forwarded it verbatim, and +/// `audio_service`'s `_observePlaybackState` (`audio_service.dart:1131-1135`) +/// answered that non-idle -> idle transition with `AudioService._stop()` — +/// `deactivateMediaSession()` + `stopSelf()` (`AudioService.java:355-357`). +/// The error message therefore survived on the car screen for at most one +/// event-loop turn before PluriWave dropped off the Android Auto playback +/// surface entirely, with nothing left to explain why. This is a world-radio +/// app; dead streams are routine, and the reviewer is explicitly told to try +/// one. Holding `error` keeps the session (and the message, and a route back +/// to browse) alive; a genuine user `stop()` clears the latch BEFORE +/// `_player.stop()`, exactly like [cambiandoFuente], so the Stop button never +/// becomes unkillable. AudioProcessingState mapearEstadoProceso( ProcessingState proc, { required bool cambiandoFuente, + bool errorTerminal = false, }) { if (cambiandoFuente && proc == ProcessingState.idle) { return AudioProcessingState.loading; } + if (errorTerminal && proc == ProcessingState.idle) { + return AudioProcessingState.error; + } return switch (proc) { ProcessingState.idle => AudioProcessingState.idle, ProcessingState.loading => AudioProcessingState.loading, @@ -389,6 +468,53 @@ AudioProcessingState mapearEstadoProceso( }; } +/// Resolves the localizations to use when no widget tree has ever handed the +/// handler an [AppLocalizations] — i.e. on the headless Android Auto engine. +/// +/// Matches on `languageCode` only: a device reporting `en_GB`, `pt_BR` or +/// `zh_Hans_CN` must still get English, Portuguese and Chinese rather than the +/// Spanish fallback. Anything we do not ship falls back to `es`, which is the +/// exact behaviour this file had before — unchanged for every locale that +/// could not be served anyway. +/// +/// Pure and top-level so the whole supported/unsupported matrix is testable +/// without a handler (same convention as [mapearEstadoProceso]). +AppLocalizations resolverLocalizacionesRespaldo(Locale plataforma) { + for (final soportado in AppLocalizations.supportedLocales) { + if (soportado.languageCode == plataforma.languageCode) { + return lookupAppLocalizations(soportado); + } + } + return lookupAppLocalizations(const Locale('es')); +} + +/// Bridges [AppLocalizations] into the `AppLocalizations`-free browse-tree +/// builder (`navegacion_auto.dart`), exactly like [itemsEcualizadorAuto] +/// bridges it into the equalizer folder. +/// +/// THE RULE this exists to enforce: anything a user can read in the car gets +/// translated. Every label the browse tree stamps onto a `MediaItem` comes +/// through here, so a new car-tree label cannot ship untranslated without +/// first getting an ARB key — and `test/l10n/etiquetas_arbol_auto_test.dart` +/// fails the build if one tries. +/// +/// Top-level and pure so the mapping is testable without a handler (same +/// convention as [resolverLocalizacionesRespaldo]). +EtiquetasArbolAuto etiquetasArbolAutoDesde(AppLocalizations l10n) => + EtiquetasArbolAuto( + escuchar: l10n.autoCarpetaEscuchar, + favoritos: l10n.autoCarpetaFavoritos, + todasLasEmisoras: l10n.autoCarpetaTodas, + misEmisoras: l10n.autoCarpetaMisEmisoras, + musicaLocal: l10n.autoCarpetaMusicaLocal, + musicaLocalNoDisponible: l10n.autoMusicaLocalNoDisponible, + cargarMas: l10n.autoCargarMas, + ordenarPorCalidad: l10n.autoOrdenarPorCalidad, + reproducirCarpeta: l10n.autoReproducirCarpeta, + reproducirAleatorio: l10n.autoReproducirAleatorio, + pistaSinNombre: l10n.autoPistaSinNombre, + ); + /// Custom-action names for the equalizer's `PlaybackStateCompat` custom /// actions on the now-playing screen (Design "EQ custom actions", item 4). /// Public consts so tests and this file's own `customAction` dispatch share @@ -815,8 +941,76 @@ class ServicioAudio { class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler implements ObjetivoAudioInterrumpible { - static const _timeoutCambioFuente = Duration(seconds: 12); + /// Per-attempt source-change timeout. + /// + /// Was 12 s, which blew the Android for Cars ten-second budget on the FIRST + /// attempt alone: the driver tapped a station and got a silent spinner for + /// twelve seconds before anything at all happened, then five more silent + /// attempts behind 1/2/4/8/16 s of backoff — roughly 100 s of nothing per + /// tap. 8 s leaves two seconds of headroom for the message to be published + /// and rendered, and the retries continue behind it (see + /// [_intentarReconexion]), so a slow-but-alive station still recovers. + static const timeoutCambioFuentePorDefecto = Duration(seconds: 8); + + /// See [timeoutCambioFuentePorDefecto]. Mutable ONLY so a test can shrink + /// the window to milliseconds; production never writes it. + @visibleForTesting + static Duration timeoutCambioFuente = timeoutCambioFuentePorDefecto; + static const _timeoutCierrePlayer = Duration(seconds: 3); + + /// How long a `loading`/`buffering` published state may stand before the + /// terminal-state floor forces it to a terminal one. + /// + /// Android for Cars App Quality Guidelines: a media app must never leave the + /// head unit on an indefinite spinner. The only exits from a transient state + /// today are player events, and `playerStateStream` is `.distinct()`, so a + /// repeat of a state we already hold emits NOTHING — a stalled state machine + /// stays on the spinner forever with no event left to rescue it. + /// + /// 8 s, not the 20 s this shipped with. For the failure mode this floor is + /// the ONLY exit from — an icecast mount whose socket opens but never + /// delivers data, one of the most common failures in this catalogue — the + /// window IS the time to the first user-visible message: `setUrl` returns + /// inside [timeoutCambioFuente], so no `TimeoutException` and no + /// `PlayerException` is ever raised, `_esErrorDeRed` never fires, the + /// reconnect machine is never entered, and `_cambiosEnVuelo` is already + /// back to 0 (the `finally` runs as soon as the non-blocking + /// `_iniciarPlaySinBloquear` returns). Twenty seconds was double the + /// ten-second budget the paragraph above cites. Same value as + /// [timeoutCambioFuentePorDefecto], for the same reason: two seconds of + /// headroom for the message to be published and rendered. + static const vigilanciaTransitoriaPorDefecto = Duration(seconds: 8); + + /// See [vigilanciaTransitoriaPorDefecto]. Mutable ONLY so a test can shrink + /// the window to milliseconds; production never writes it. + @visibleForTesting + static Duration vigilanciaTransitoria = vigilanciaTransitoriaPorDefecto; + + /// How long an Android Auto ACTION refusal published over a LIVE session + /// (see [_publicarErrorAuto]) keeps its `errorCode`/`errorMessage` on the + /// head unit before they are cleared again. + /// + /// A refusal over live audio describes the REQUESTED ACTION, not the + /// session, so it has to expire: `_bufferedSub` republishes + /// `playbackState.value.copyWith(...)` roughly twice a second and + /// `copyWith` carries every omitted field forward + /// (`audio_service.dart:400-427`), so `AudioService.java:541-544` re-calls + /// `setErrorMessage(code, msg)` on every one of those pushes. Left + /// unbounded, a single voice miss makes the session advertise an error + /// code and message for the rest of that station's playback, over audio + /// that is playing perfectly. + /// + /// Six seconds: long enough to be read at a glance from a head unit while + /// driving (the Cars guidelines' own budget for telling the driver + /// something at all is ten), short enough that it cannot be mistaken for a + /// description of the session it is riding on. + static const ventanaErrorAccionAutoPorDefecto = Duration(seconds: 6); + + /// See [ventanaErrorAccionAutoPorDefecto]. Mutable ONLY so a test can + /// shrink the window to milliseconds; production never writes it. + @visibleForTesting + static Duration ventanaErrorAccionAuto = ventanaErrorAccionAutoPorDefecto; static const _factorAtenuacion = 0.3; // ── Live-stream buffer (Design 7.1, S7-R1) ──────────────────────────────── @@ -876,6 +1070,53 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// — i.e. before every single `_player.stop()` call in this class. bool _cambiandoFuente = false; + /// How many [_cambiarFuente] bodies are currently between their entry guard + /// and their `finally`. A counter rather than a bool because + /// `_colaCambioFuente` is a chain, and a reconnect retry can be enqueued + /// while the previous attempt is still unwinding. + /// + /// Read ONLY by the terminal-state floor, to tell «a station is genuinely + /// still opening» apart from «the state machine stalled on a spinner». + int _cambiosEnVuelo = 0; + + /// Terminal-state floor (see [vigilanciaTransitoriaPorDefecto]). + Timer? _vigilanciaTimer; + + /// The `errorCode`/`errorMessage` pair the LAST non-terminal + /// [_publicarErrorAuto] put on the head unit, and the timer that takes it + /// back off again. `null` when no action refusal is standing. + /// + /// Both halves are remembered so the clear can verify it is removing its + /// OWN fields: anything else may have published over them in the meantime + /// (a reconnect status message, a terminal playback error), and clearing + /// those would blank the car screen for a reason that is still true. + ({int codigo, String mensaje})? _errorAccionAuto; + Timer? _temporizadorErrorAccionAuto; + + /// True once the CURRENT source has actually produced audio — i.e. the + /// player reached `ready` while `playing`. Reset at the entry of every + /// [_cambiarFuente] (a reconnect attempt included) and by [stop]. + /// + /// This is the terminal-state floor's discriminator, and it exists because + /// the obvious one does not work: `just_audio`'s `PlayerState.playing` is + /// the play-when-ready INTENT flag, set the instant `play()` is called + /// (`just_audio.dart` `_playInterrupted`/`playing`), so the stalled icecast + /// mount the floor was built for — socket accepted, not one byte delivered + /// — publishes `buffering` with `playing: true` exactly like a healthy + /// stream refilling its buffer. Only "did this run ever reach `ready`?" + /// separates them. + bool _reproduccionEstablecida = false; + + /// A terminal playback error is standing on the car screen, so the `idle` + /// that follows it must NOT be forwarded — see [mapearEstadoProceso]'s + /// `errorTerminal` parameter for the teardown chain that idle triggers. + /// + /// Cleared by every path that represents a fresh user intent: [play], + /// [pause], [stop] and the entry of [_cambiarFuente]. [stop] in particular + /// clears it BEFORE `_player.stop()`, so a genuine user stop still tears + /// the session down. + bool _errorTerminal = false; + /// Active local-music queue (Design "single load-bearing invariant"): /// `null` means "not local-queue playback" — the ONLY gate the /// auto-advance/skip/isolation logic reads. Radio never sets this field. @@ -1001,21 +1242,180 @@ class PluriWaveAudioHandler extends BaseAudioHandler PluriWaveAudioHandler() { _conectarStreamsPlayer(); + _vigilarEstadosTransitorios(); } + /// Arms the terminal-state floor by watching our OWN published stream + /// rather than any single push site: `playbackState.add` is called from a + /// dozen places in this file, and a floor that only covered some of them + /// would be exactly as good as no floor at all on the path it missed. + /// + /// The subscription is deliberately not held: it is on the handler's OWN + /// `playbackState` subject, so it lives and dies with the handler — exactly + /// like the subject itself — and there is no teardown that could cancel it + /// without also ending the object it belongs to. + void _vigilarEstadosTransitorios() { + playbackState.listen((estado) { + if (!_esEstadoTransitorio(estado.processingState)) { + _vigilanciaTimer?.cancel(); + _vigilanciaTimer = null; + return; + } + // Already armed for THIS transient run: do not restart the window. + // `bufferedPositionStream` republishes ~2/s while buffering, and + // re-arming on each of those would push the deadline out forever — + // the spinner would once again have no bound. + if (_vigilanciaTimer?.isActive ?? false) return; + _vigilanciaTimer = Timer( + vigilanciaTransitoria, + _cerrarEstadoTransitorio, + ); + }); + } + + static bool _esEstadoTransitorio(AudioProcessingState estado) => + estado == AudioProcessingState.loading || + estado == AudioProcessingState.buffering; + + /// Fires [vigilanciaTransitoria] after the first transient publish of a run. + void _cerrarEstadoTransitorio() { + _vigilanciaTimer = null; + if (!_esEstadoTransitorio(playbackState.value.processingState)) return; + if (_cambiosEnVuelo > 0 || _reconexion.reintentoPendiente) { + // A station really is still opening, or a backoff retry is already + // scheduled to end this. The floor exists for a STALLED machine, not to + // cap how long a slow stream may take — re-arm and look again. + _vigilanciaTimer = Timer( + vigilanciaTransitoria, + _cerrarEstadoTransitorio, + ); + return; + } + // ORDINARY MID-STREAM RE-BUFFER — never a floor case. + // + // The rule: the floor only fires while NOTHING is known to be happening. + // A run that already reached `ready` while playing has proven the mount + // delivers audio, and `bufferForPlaybackAfterRebufferDuration` is 5 s, so + // a tunnel or an LTE handover routinely parks this session in + // `buffering` for longer than [vigilanciaTransitoria] — with + // `_cambiosEnVuelo` at 0 (the `finally` ran the moment the non-blocking + // `_iniciarPlaySinBloquear` returned) and `reintentoPendiente` false + // (ExoPlayer raised no error, so `_intentarReconexion` never ran). The + // floor as first written turned every one of those into a latched + // `error` over a stream that was still alive: the single worst outcome + // in a car, and unrecoverable, because `_errorTerminal` then suppresses + // the player's own recovery events. + // + // Deferring here does NOT reopen the unbounded spinner the guidelines + // forbid. This branch is reachable only after audio actually played, and + // that path keeps two independent exits the stalled-mount path lacks: + // ExoPlayer's own source read timeout raises a `PlayerException` into + // `_eventosSub`, which enters `_gestionarErrorReproduccion` and the + // reconnect machine, and any real transition re-publishes and disarms + // this timer. Re-arm rather than return so the floor stays authoritative + // if the session later drops back to a state nothing owns: the flag is + // cleared by [_cambiarFuente] and [stop], so the very next attempt to + // reopen the source is floored normally. + // + // `playing` alone is deliberately NOT the test: it is `just_audio`'s + // play-when-ready intent flag, true for a mount that never delivered a + // byte. See [_reproduccionEstablecida]. + if (_reproduccionEstablecida && playbackState.value.playing) { + _vigilanciaTimer = Timer( + vigilanciaTransitoria, + _cerrarEstadoTransitorio, + ); + return; + } + debugPrint( + '[PluriWave][ServicioAudio] suelo de estado: ' + '${playbackState.value.processingState.name} sin carga viva -> error', + ); + // CORRECTED: this used to publish a BARE `idle` — no message, no code — + // on the theory that "nothing we can name actually failed" and that idle + // at least let `audio_service` tear the session down. Both halves were + // wrong for the case that actually reaches here. + // + // Something DID fail and it has a name: a mount that accepted the socket + // and then delivered nothing. `setUrl` returns inside + // [timeoutCambioFuente], so no exception is raised, `_esErrorDeRed` never + // fires and the reconnect machine is never entered; this floor is the + // only exit. And the teardown was not a mercy: a bare idle routes into + // `AudioService._stop()` (`audio_service.dart:1131-1135`), so the driver + // got a wordless spinner followed by silence, a dead session and no + // explanation whatsoever. + // + // `error` + `audioErrorTimeout` instead. It keeps the session (and the + // route back to browse) alive, and it says the one true thing we know: + // the connection never produced audio. The latch stops the player's own + // `idle`, if one ever arrives, from undoing it. + _errorTerminal = true; + playbackState.add( + playbackState.value.copyWith( + processingState: AudioProcessingState.error, + playing: false, + errorCode: codigoErrorEmisoraNoDisponible, + errorMessage: _textos.audioErrorTimeout, + ), + ); + } + + /// Production reader for the device locale. + static Locale lectorLocalePlataformaPorDefecto() => + PlatformDispatcher.instance.locale; + + /// See [lectorLocalePlataformaPorDefecto]. Mutable ONLY so a test can pin a + /// locale; production never writes it. + @visibleForTesting + static Locale Function() lectorLocalePlataforma = + lectorLocalePlataformaPorDefecto; + AppLocalizations get _textos { final actual = _l10n; if (actual != null) return actual; - return lookupAppLocalizations(const Locale('es')); + // The headless Android Auto engine NEVER reaches + // [configurarLocalizaciones]: its only production caller chain is + // `EstadoRadio.configurarLocalizaciones` <- `mini_reproductor.dart`'s + // `didChangeDependencies`, and a car bind has no Activity and no widget + // tree. So `_l10n` stays null there and every message the car ever + // showed — including the error text the whole compliance story rests + // on — came out in Spanish no matter what language the driver uses. + return resolverLocalizacionesRespaldo(lectorLocalePlataforma()); } void configurarLocalizaciones(AppLocalizations l10n) { _l10n = l10n; } + /// Test seam for the player factory. `null` in production, where + /// [_crearPlayer] builds the real `just_audio.AudioPlayer` exactly as it + /// always has. + /// + /// It exists because the car transport state machine (stop during an + /// in-flight source change, pause during a load, the transient-state + /// watchdog, the time-to-first-message budget) is a sequence of + /// `playbackState` pushes, and asserting a SEQUENCE requires driving the + /// real handler — the pure-predicate style used elsewhere in this file + /// cannot see the order in which those pushes land, which is precisely + /// where the "Stop leaves the app useless" defect lived. + /// + /// Static rather than a constructor parameter because `_player` is a + /// `late` field initialized by the constructor itself (through + /// `_conectarStreamsPlayer`), so the factory must already be installed + /// before `PluriWaveAudioHandler()` runs. Tests clear it in `tearDown`. + @visibleForTesting + static AudioPlayer Function( + AudioPipeline pipeline, + AudioLoadConfiguration carga, + )? + fabricaReproductorPrueba; + AudioPlayer _crearPlayer() { + final pipeline = AudioPipeline(androidAudioEffects: [_eq]); + final fabrica = fabricaReproductorPrueba; + if (fabrica != null) return fabrica(pipeline, configuracionCargaAndroid); return AudioPlayer( - audioPipeline: AudioPipeline(androidAudioEffects: [_eq]), + audioPipeline: pipeline, audioLoadConfiguration: configuracionCargaAndroid, ); } @@ -1034,6 +1434,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler void manejarEstadoPlayer(PlayerState state) { final playing = state.playing; final proc = state.processingState; + // A real player transition supersedes a refusal about an ACTION: the + // session just moved, so the code/message describing something the + // driver asked for a moment ago must not ride along on the new state. + // Done BEFORE the publish below so its `copyWith` carries the cleared + // fields forward rather than the stale pair (`audio_service.dart` + // :400-427 keeps every omitted field). + _limpiarErrorAccionAuto(); // 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 @@ -1054,6 +1461,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler // re-assert above fires once per activation, not on every event. _reproductorActivo = proc != ProcessingState.idle; if (playing && proc == ProcessingState.ready) { + // The mount has proven it delivers audio on THIS run. Read only by the + // terminal-state floor, to tell an ordinary re-buffer apart from a + // source that never produced a byte — see [_reproduccionEstablecida]. + _reproduccionEstablecida = true; // Successful (re)connection: reset the backoff so the next stall // starts over, and leave the reconnect window (S7-R7). _reconexion.restablecer(); @@ -1114,6 +1525,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler processingState: mapearEstadoProceso( proc, cambiandoFuente: _cambiandoFuente, + errorTerminal: _errorTerminal, ), playing: playing, // Reported: in Android Auto the progress bar and the time labels of @@ -1311,6 +1723,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler debugPrint('[PluriWave][ServicioAudio] Error reproducción: $codigoLog'); _detenerReconexion(); + // Armed BEFORE the publish and BEFORE `_player.stop()`: the stop below + // makes `playerStateStream` emit a distinct `idle`, and forwarding that + // idle is what used to tear the whole media session down one event-loop + // turn after the message appeared. See [mapearEstadoProceso]'s + // `errorTerminal` parameter for the full chain. + _errorTerminal = true; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, @@ -1321,19 +1739,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler // The failed item and station are KEPT, deliberately. // // This used to do `emisoraActual = null; mediaItem.add(null);`, which - // left the media session in STATE_ERROR with no metadata at all. Android - // Auto drops a session with nothing to show, which is what made PluriWave - // vanish from the car pane the moment a station failed -- reported as - // "if a station fails it seems to crash, and going to 1/3 it fails". + // left the media session in STATE_ERROR with no metadata at all. // - // Keeping them costs nothing on the phone (nothing outside this file - // consumes `mediaItem`, verified) and buys two things in the car: the - // screen can still name the station that failed instead of going blank, - // and previous/next stay usable, so the driver skips out of a dead - // station instead of being stranded -- `_saltarEmisora` needs - // `emisoraActual` to know where it is in the list. + // CORRECTED: an earlier revision of this comment claimed that keeping + // `mediaItem`/`emisoraActual` was what stopped PluriWave vanishing from + // the car pane when a station failed. It was not, and the app kept + // vanishing. The teardown is driven by the `idle` `_player.stop()` + // produces, not by missing metadata: `audio_service` calls + // `AudioService._stop()` on any non-idle -> idle transition + // (`audio_service.dart:1131-1135`) whatever the media item holds. The + // `_errorTerminal` latch above is the actual fix; keeping the metadata is + // still worth doing for two smaller reasons — the screen can name the + // station that failed instead of going blank, and previous/next stay + // usable so the driver skips out of a dead station instead of being + // stranded (`_saltarEmisora` needs `emisoraActual` to know where it is). // - // The error state itself is unchanged: STATE_ERROR with the message. + // `_player.stop()` itself stays: it releases the decoders behind a stream + // that is already dead, and its `idle` is now suppressed rather than + // forwarded, so the error state stands until the driver does something. _player.stop().catchError((_) {}); } @@ -1373,7 +1796,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler playbackState.value.copyWith( processingState: AudioProcessingState.buffering, playing: false, - errorMessage: null, + // NOT `null` any more. `copyWith` treats an explicit `null` as + // "clear" (audio_service.dart:419-420), so the whole reconnect window + // used to be a spinner with nothing written on it — up to ~100 s in + // which the driver was told exactly nothing. `buffering` + a message + // is the shape the Cars guidelines ask for: say what is happening, + // and keep trying behind it. + errorMessage: _textos.playbackStatusReconnecting, ), ); return true; @@ -1483,7 +1912,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler if (colaEsperada != null) _avanzandoCola = false; return; } - final item = await construirMediaItemColaLocal(nodo, fuente: fuente); + final item = await construirMediaItemColaLocal( + nodo, + fuente: fuente, + etiquetas: etiquetasArbolAutoDesde(_textos), + ); if (colaEsperada != null && !avanceEsValido(_colaLocal, colaEsperada)) { // Stale: a user action (external play, stop, another skip) replaced // `_colaLocal` during the await — abort this advance without @@ -1576,13 +2009,51 @@ class PluriWaveAudioHandler extends BaseAudioHandler } Future _cambiarFuente(MediaItem mediaItem, int revision) async { + // Revision guard ON ENTRY — the P0 "Stop makes the app useless" fix. + // + // `_encolarCambioFuente` bumps `_revisionFuente` when a change is + // ENQUEUED, not when it runs, so several changes can be waiting on + // `_colaCambioFuente` at once. `stop()` bumps the revision again and + // publishes `idle` itself. Every queued change then woke up, REWROTE + // `loading` over that `idle`, and only afterwards (past `_recrearPlayer`) + // discovered its revision was stale. The last thing Android Auto saw was + // `loading` on a session `audio_service` had already torn down: a + // permanent spinner with a dead Stop button. + // + // Nothing below this line can strand `_cambiandoFuente`: the flag is only + // set AFTER this return, so an early exit here leaves it exactly as the + // caller found it. + if (revision != _revisionFuente) return; this.mediaItem.add(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem); + // A new source is being opened, so no previous terminal error owns the + // screen any more (see [_errorTerminal]). + _errorTerminal = false; + // A fresh source (a reconnect ATTEMPT included) has proven nothing yet: + // re-arm the terminal-state floor for it. Without this reset, a station + // that played and then died would inherit the previous run's "audio is + // flowing" verdict and its spinner would never be floored. + _reproduccionEstablecida = false; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.loading, playing: false, - errorMessage: null, + // A FRESH user source change clears whatever was on screen; a + // reconnect ATTEMPT does not. Retries run behind the "reconnecting" + // message [_intentarReconexion] published, and blanking it here on + // every attempt would put the driver back on a wordless spinner for + // most of the backoff window. + errorMessage: _reconectando ? playbackState.value.errorMessage : null, + // ALWAYS cleared, unlike the message. `copyWith` carries an OMITTED + // field forward (`audio_service.dart:419-420`), and this used to omit + // `errorCode`, so a code published by an earlier refusal (a premium + // tap, a voice miss) rode along on every later state indefinitely — + // pairing a stale `ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED` with whatever + // message came next. It is cleared even while reconnecting because + // "Reconectando..." is a STATUS, not an error: `setState` still + // forwards a message with a null code + // (`AudioService.java:541-544`). + errorCode: null, ), ); // Opens the masking window BEFORE `_recrearPlayer`, which is what @@ -1590,11 +2061,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler // `playerStateStream` event is the transient `idle` we must not forward // (see [mapearEstadoProceso]). _cambiandoFuente = true; + // Paired with the `finally` below: tells the terminal-state floor that a + // station is genuinely still opening, so it re-arms instead of forcing a + // slow-but-healthy load to `idle`. + _cambiosEnVuelo++; try { await _recrearPlayer(); if (revision != _revisionFuente) return; - await _player.setUrl(mediaItem.id).timeout(_timeoutCambioFuente); + await _player.setUrl(mediaItem.id).timeout(timeoutCambioFuente); // Source swap complete: the fresh player's transient `idle` is behind // us, so stop masking immediately — before anything below can await — // and let a real `idle` through again from here on. @@ -1632,6 +2107,22 @@ class PluriWaveAudioHandler extends BaseAudioHandler '[PluriWave][ServicioAudio] Error inesperado en playMediaItem: $e', ); if (revision == _revisionFuente) { + // Same shape as [_gestionarErrorReproduccion]'s terminal path, and + // for the same reasons — this clause is the THIRD way a station can + // die and it used to be the least survivable of them. + // + // The latch stops the `idle` that follows (`_recrearPlayer` and the + // player's own teardown both produce one) from being forwarded into + // `AudioService._stop()` (`audio_service.dart:1131-1135`). + // + // And the metadata is now KEPT. Clearing it here contradicted the + // sibling path outright: Android Auto drops a session with nothing to + // show, so `mediaItem.add(null)` made PluriWave disappear from the + // car pane the moment an unexpected error hit, and `emisoraActual = + // null` additionally left `_saltarEmisora` with no idea where it was, + // so previous/next stopped working — stranding the driver on the one + // failure they most need to skip out of. + _errorTerminal = true; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, @@ -1639,8 +2130,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler errorMessage: _textos.audioErrorUnexpectedPlayback, ), ); - emisoraActual = null; - this.mediaItem.add(null); } rethrow; } finally { @@ -1652,6 +2141,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler // hand-audited exit path, because a `_cambiandoFuente` stuck at `true` // would mask a REAL stop's `idle` and leave the notification unkillable. _cambiandoFuente = false; + _cambiosEnVuelo--; } } @@ -1685,6 +2175,34 @@ class PluriWaveAudioHandler extends BaseAudioHandler } void _iniciarPlaySinBloquear(MediaItem mediaItem, int revision) { + // Re-validate the user's intent, not just the source revision. + // + // Only `stop()` bumps `_revisionFuente`, so a `pause()` landing WHILE a + // station was still loading did not cancel anything: the load finished, + // this method ran, and the station started playing right after the user + // had asked for silence. `_intencionReproducir` is the flag `pause()` + // already clears (and `play()` already sets), so reading it here is the + // narrow fix. + // + // Deliberately NOT done by bumping `_revisionFuente` inside `pause()`: + // that would abort the whole source change, and a pause landing before + // `_recrearPlayer()` returned would leave a fresh player with NO source + // loaded — the subsequent resume would then call `play()` on nothing and + // produce silence. Letting the load COMPLETE and only withholding the + // `play()` keeps resume working: the source is already there. + if (!_intencionReproducir) { + // The source IS loaded, the user just does not want it playing. Say so + // explicitly: without this the car keeps the `loading` spinner the + // source change opened with, and nothing else would ever replace it — + // the player makes no transition when `play()` is never called. + playbackState.add( + playbackState.value.copyWith( + processingState: AudioProcessingState.ready, + playing: false, + ), + ); + return; + } unawaited( _player.play().catchError((Object error, StackTrace stackTrace) { debugPrint( @@ -1938,6 +2456,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler @override Future play() { _intencionReproducir = true; + // Fresh user intent: whatever terminal error was standing no longer owns + // the screen, so stop masking the player's `idle` (see [_errorTerminal]). + _errorTerminal = false; return _player.play(); } @@ -1946,6 +2467,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler // User (or audio-session interruption) pause: disarm any pending retry — // a stall must never fight an intentional pause (S7-R2-B, S7-R6). _intencionReproducir = false; + _errorTerminal = false; _detenerReconexion(); return _player.pause(); } @@ -1956,6 +2478,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler // so retries never restart playback after a stop (S7-R6). _intencionReproducir = false; _detenerReconexion(); + // Disarm the action-refusal window before it can fire against the idle + // this method is about to publish (see [_limpiarErrorAccionAuto]). + _limpiarErrorAccionAuto(); // Local queue (Design ADR-2): clears alongside the reconnect-cancel // logic above — `onTaskRemoved` (which calls stop()) inherits this for // free (Phase 3 task 3.5). @@ -1971,6 +2496,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler // that in-flight change; its `finally` only re-clears the flag, and // nothing re-arms it (the single `= true` assignment already ran). _cambiandoFuente = false; + // Same reason and the same ordering as `_cambiandoFuente` above: a mask + // still armed here would swallow the real `idle` and leave the + // notification unkillable with a dead Stop button — the exact citation + // this branch exists to answer. + _errorTerminal = false; + // The session is over: whatever this run proved about the mount does not + // carry into the next one (see [_reproduccionEstablecida]). + _reproduccionEstablecida = false; _revisionFuente++; await _player.stop(); // Publish `idle` OURSELVES rather than trusting the player to emit it. @@ -1991,6 +2524,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler processingState: AudioProcessingState.idle, playing: false, errorMessage: null, + // Paired with `errorMessage` above, which shipped alone. `copyWith` + // carries an OMITTED field forward (`audio_service.dart:400-427`), + // so a stopped, idle session went on advertising the code of the + // last refusal — an ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED (4) with the + // message deliberately blanked out, i.e. an error the head unit + // could show but never explain. + errorCode: null, ), ); emisoraActual = null; @@ -2010,12 +2550,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// and a button that is present but inert is worse than no button. @override Future skipToNext() async { - // iap-freemium-unlock (Design ADR-4 backstop): station-to-station - // skipping is a browse/switch action, blocked for free tier regardless - // of queue state. Current-station play/pause/stop is untouched. - if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { - return; - } + // fix/auto-quality-guidelines item 14: the free-tier no-op is GONE. The + // buttons stay advertised for every tier and CYCLE WITHIN the free set + // instead — see [_saltarEmisora]. Withdrawing them from `controls` and + // `systemActions` was the alternative and was rejected: those two lists + // are rebuilt inside a SYNCHRONOUS `playerStateStream` listener, so + // making them tier-dependent would mean either awaiting a prefs read on + // every player event or caching entitlement in a second place; and a + // button that works is better UX than a hole, on a surface where Auto + // reserves the slots anyway. final cola = _colaLocal; if (cola == null) { if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: false); @@ -2037,10 +2580,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// [skipToNext]. @override Future skipToPrevious() async { - // iap-freemium-unlock (Design ADR-4 backstop): mirrors [skipToNext]. - if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { - return; - } + // Mirrors [skipToNext] — see its comment for why the free-tier gate is + // gone (fix/auto-quality-guidelines, item 14). final cola = _colaLocal; if (cola == null) { if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: true); @@ -2081,15 +2622,26 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// would surface as the app going silent mid-drive. Future _saltarEmisora({required bool haciaAtras}) async { try { - final fuente = _fuenteNavegacionGlobal; final actual = emisoraActual; - if (fuente == null || actual == null) return; - final lista = listaParaSaltoEmisora( - actual: actual, - favoritos: await fuente.favoritos(), - misEmisoras: await fuente.misEmisoras(), - todas: await fuente.todas(), - ); + if (actual == null) return; + // fix/auto-quality-guidelines item 14: the list is SCOPED by tier + // rather than the buttons being refused. A free driver cycles the free + // set — which always has at least [emisorasDestacadas.length] entries, + // so neither button is ever a dead end — and a premium driver keeps the + // narrowest-context walk (`listaParaSaltoEmisora`) unchanged. + final List lista; + if (await esPremiumPersistido()) { + final fuente = _fuenteNavegacionGlobal; + if (fuente == null) return; + lista = listaParaSaltoEmisora( + actual: actual, + favoritos: await fuente.favoritos(), + misEmisoras: await fuente.misEmisoras(), + todas: await fuente.todas(), + ); + } else { + lista = await resolverEmisorasDestacadas(); + } final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras); // Reported: in the car these buttons did nothing for radio. Every early // return here is silent, so the log has to say WHICH one fired -- @@ -2141,20 +2693,64 @@ class PluriWaveAudioHandler extends BaseAudioHandler @override Future onTaskRemoved() async { await stop(); + await liberar(); + } + + /// Releases everything this handler owns that can still run on its own: + /// the terminal-state floor timer, the action-refusal window, the + /// reconnect backoff (1/2/4/8/16 s — easily longer than whatever created + /// the handler lives for), anything still queued on `_colaCambioFuente`, + /// every player subscription, the player itself and the browse subjects. + /// + /// The revision bump is the load-bearing part: a queued `_cambiarFuente` + /// body that outlives its owner calls `_crearPlayer()`, which reads the + /// CURRENT [fabricaReproductorPrueba]. In a test suite that means a + /// released handler building a double bound to a LATER test's script and + /// driving it. Bumping the revision makes every queued body return at its + /// entry guard, before it can publish or build anything. + /// + /// Idempotent, and never throws: teardown that can fail halfway is not + /// teardown. Called by [onTaskRemoved] in production and by every test + /// that constructs a handler. + Future liberar() async { + // Invalidates every queued and in-flight source change (see above). + _revisionFuente++; + _intencionReproducir = false; + _cambiandoFuente = false; + _errorTerminal = false; + _reproduccionEstablecida = false; + _vigilanciaTimer?.cancel(); + _vigilanciaTimer = null; + _detenerReconexion(); + _limpiarErrorAccionAuto(); + _desactivarCola(); await _estadoPlayerSub?.cancel(); + _estadoPlayerSub = null; await _bufferedSub?.cancel(); + _bufferedSub = null; await _duracionSub?.cancel(); + _duracionSub = null; await _eventosSub?.cancel(); + _eventosSub = null; await _androidAudioSessionIdSub?.cancel(); - await _player.dispose(); - await _androidAudioSessionIdController.close(); + _androidAudioSessionIdSub = null; + // A player built without the test factory talks to platform channels that + // do not exist under `flutter test`, so disposal is allowed to fail — + // the subscriptions above are already gone either way. + try { + await _player.dispose(); + } catch (_) {} + if (!_androidAudioSessionIdController.isClosed) { + await _androidAudioSessionIdController.close(); + } for (final subject in _childrenSubjects.values) { await subject.close(); } + _childrenSubjects.clear(); // Handler teardown: release the bootstrap-owned `AudioService.asyncError` // subscription too, so it cannot outlive the handler it was instrumenting. // Never throws out of teardown — a failing cleanup hook must not prevent - // the rest of `onTaskRemoved` from having completed above. + // the rest of this method from having completed above. try { await _limpiezaArranqueGlobal?.call(); } catch (_) {} @@ -2180,18 +2776,59 @@ class PluriWaveAudioHandler extends BaseAudioHandler Map? options, ]) async { try { - final constructor = ConstructorArbolAuto(); - // iap-freemium-unlock (Design ADR-4): the AUTHORITATIVE entitlement - // gate, resolved ONCE per call and checked BEFORE any other - // resolution — the backstop against a stale/deep-linked non-root id - // (android-auto-media spec "Free-Tier Browse Never Leaks Real - // Content"). Never blocks the root itself (see that function's doc). + // Every user-readable label the car tree stamps onto a `MediaItem` + // comes from here. `_textos` resolves headlessly through + // [resolverLocalizacionesRespaldo], so this works on the engine + // Android Auto starts without an Activity -- which is the only engine + // a Play reviewer ever gets. + final etiquetas = etiquetasArbolAutoDesde(_textos); + final constructor = ConstructorArbolAuto(etiquetas: etiquetas); + // The "recent" root, resolved BEFORE the entitlement gate. + // + // `onGetRoot` (`AudioService.java:817-821`) hands back `RECENT_ROOT_ID` + // whenever the head unit sets `BrowserRoot.EXTRA_RECENT`, which Android + // Auto does on every reconnect. There was no branch for it, so it fell + // through: free tier hit `respuestaBloqueadaPorEntitlement` and got all + // six stations where the platform expects exactly ONE resume item, and + // premium fell past every branch to `const []` — a dead resume tile. + // + // Tier-independent on purpose (see [ultimaEmisoraPersistida]): the + // station offered here is by definition one this device has already + // played, so resuming it leaks nothing. An absent/corrupt record yields + // an empty list rather than a placeholder — a non-playable row is the + // thing Play cited. + if (parentMediaId == AudioService.recentRootId) { + final ultima = await ultimaEmisoraPersistida(); + return ultima == null + ? const [] + : [constructor.itemEmisora(ultima)]; + } + // The AUTHORITATIVE entitlement gate, resolved ONCE per call and + // checked BEFORE any other resolution — the backstop against a + // stale/deep-linked non-root id from a head unit's cached tree. + // + // fix/auto-quality-guidelines item 10: it now SCOPES content instead of + // blocking actions, so its answer for a premium id is the free tier's + // own playable stations, never a non-playable row. final premium = await esPremiumPersistido(); + final destacadas = + premium ? const [] : await resolverEmisorasDestacadas(); final bloqueada = respuestaBloqueadaPorEntitlement( parentMediaId: parentMediaId, premium: premium, + destacadas: destacadas, ); if (bloqueada != null) return bloqueada; + // The free tier's only folder (item 9). Resolved HERE, before the + // `_fuenteNavegacionGlobal` gate below, exactly like the local-music and + // equalizer branches: its content comes from the binary, so it must + // survive a bind where no browse source has been registered yet — which + // is precisely the bind a Play reviewer's first launch performs. + if (parentMediaId == ConstructorArbolAuto.idDestacadas) { + return constructor.hijosDestacadas( + premium ? await resolverEmisorasDestacadas() : destacadas, + ); + } final fuenteLocal = _fuenteMusicaLocalGlobal; if (parentMediaId == AudioService.browsableRootId) { // fix/android-auto-musica-local: la RAÍZ ya no se decide con el @@ -2207,7 +2844,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler // `canalNoDisponible` lo mantiene, y es el SUBÁRBOL quien explica // el problema (`hijosMusicaLocal`) en vez de dejar una carpeta // vacía. + // + // fix/auto-quality-guidelines item 9: `premium &&` comes FIRST on + // purpose. Dart short-circuits `&&`, so a free-tier root never makes + // the `estadoCarpeta()` call at all — it cannot show Música Local + // anyway, and that call travels over a native channel whose handler + // only exists when an Activity has registered it, so skipping it + // removes a failure mode from the one browse call that must never + // fail. final incluirMusicaLocal = + premium && fuenteLocal != null && await fuenteLocal.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada; @@ -2219,6 +2865,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler final musicaLocal = await hijosMusicaLocal( parentMediaId, fuente: fuenteLocal, + etiquetas: etiquetas, ); if (musicaLocal != null) return musicaLocal; // Ecualizador folder (decision `auto/ecualizador-diseno`): needs no @@ -2279,38 +2926,169 @@ class PluriWaveAudioHandler extends BaseAudioHandler } } + /// Publishes a terminal, EXPLAINED refusal to the car + /// (fix/auto-quality-guidelines, item 12). + /// + /// Every dispatch path that used to `return;` in silence now ends here + /// instead. A silent return is the worst possible answer on a head unit: + /// the driver taps a row (or speaks a command), the assistant accepts it, + /// and absolutely nothing happens with nothing on screen to say why — + /// which is the shape of the defect Google Play cited. + /// `ACTION_PLAY_FROM_SEARCH` in particular is forced into the plugin's + /// `AUTO_ENABLED_ACTIONS`, so a mute handler stays advertised forever and + /// cannot be withdrawn from Dart. + /// + /// `playing`, `mediaItem` and the player itself are deliberately untouched: + /// this error describes the requested ACTION that could not be carried out, + /// not the session. Whatever was already playing keeps playing. + /// + /// CORRECTED: it used to publish [AudioProcessingState.error] + /// unconditionally, which was worse than the silent return it replaced — + /// see [puedePublicarErrorTerminalAuto] for the full mechanism. The + /// processing state now only moves when there is no live session to + /// destroy; otherwise the refusal travels as `errorCode`/`errorMessage` + /// alone, which still reaches `PlaybackStateCompat.setErrorMessage` + /// (`AudioService.java:541-544`) whatever the state is. + void _publicarErrorAuto(int codigo, String mensaje) { + final actual = playbackState.value; + final terminal = puedePublicarErrorTerminalAuto(actual.processingState); + debugPrint( + '[PluriWave][ServicioAudio] rechazo auto codigo=$codigo ' + 'terminal=$terminal sobre ${actual.processingState.name}: $mensaje', + ); + // A previous refusal's window must not outlive the refusal it belonged + // to: this publish takes ownership of the two fields. + _temporizadorErrorAccionAuto?.cancel(); + _temporizadorErrorAccionAuto = null; + _errorAccionAuto = null; + playbackState.add( + actual.copyWith( + processingState: terminal + ? AudioProcessingState.error + : actual.processingState, + errorCode: codigo, + errorMessage: mensaje, + ), + ); + // A TERMINAL refusal stands until something else happens: it IS the + // session state now, the driver has nothing playing to go back to, and + // `error` is not a field that can be quietly withdrawn. + // + // A refusal over a LIVE session is the opposite — it describes an action, + // not the session, and nothing in this class would ever have taken it + // back: `_bufferedSub` republishes through `copyWith` about twice a + // second and `copyWith` carries omitted fields forward + // (`audio_service.dart:400-427`), so `AudioService.java:541-544` re-armed + // `setErrorMessage(code, msg)` on every push for the rest of the + // station's playback. Bound it here. + if (terminal) return; + _errorAccionAuto = (codigo: codigo, mensaje: mensaje); + _temporizadorErrorAccionAuto = Timer( + ventanaErrorAccionAuto, + _limpiarErrorAccionAuto, + ); + } + + /// Takes a standing action refusal back off the head unit (see + /// [ventanaErrorAccionAutoPorDefecto]). Idempotent, and a no-op unless the + /// fields on screen are still the exact pair this handler published. + /// + /// Called by the window timer, by [manejarEstadoPlayer] (a real player + /// transition supersedes a refusal about an action) and by [stop]. + void _limpiarErrorAccionAuto() { + _temporizadorErrorAccionAuto?.cancel(); + _temporizadorErrorAccionAuto = null; + final pendiente = _errorAccionAuto; + if (pendiente == null) return; + _errorAccionAuto = null; + final actual = playbackState.value; + // Someone else owns these fields now — a reconnect status message, or a + // terminal playback error that moved the state itself. Blanking those + // would remove a message that is still true. + if (actual.processingState == AudioProcessingState.error) return; + if (actual.errorCode != pendiente.codigo || + actual.errorMessage != pendiente.mensaje) { + return; + } + playbackState.add(actual.copyWith(errorCode: null, errorMessage: null)); + } + + /// The stations a voice query is matched against, and the station an EMPTY + /// query starts, for the tier resolved by [premium]. + /// + /// Free tier searches ONLY the free set — not favourites + my stations + + /// the catalogue — so a match can never resolve to something the tier + /// cannot then play. + Future> _candidatasBusqueda({ + required bool premium, + required List destacadas, + }) async { + if (!premium) return destacadas; + final fuente = _fuenteNavegacionGlobal; + if (fuente == null) return destacadas; + return [ + ...await fuente.favoritos(), + ...await fuente.misEmisoras(), + ...await fuente.todas(), + // Appended last so they only ever win a query nothing else matched. + ...destacadas, + ]; + } + /// Voice search from the car ("pon Radio Clásica"). /// /// `ACTION_PLAY_FROM_SEARCH` is one of the actions Android for Cars - /// documents as required, and it is now advertised in `systemActions` — so - /// it has to actually do something. Advertising it unimplemented would be + /// documents as required, and it is advertised in `systemActions` — so it + /// has to actually do something. Advertising it unimplemented would be /// worse than omitting it: the assistant would accept the command and /// nothing would play, with no error to explain it. /// - /// Favourites first, then my stations, then the full list, so a station the - /// driver already cares about wins a name tie. Never throws and never plays - /// something arbitrary on a miss — see [emisoraParaBusqueda]. + /// An EMPTY query ("Reproduce PluriWave") starts the last played station, + /// or the first featured one (fix/auto-quality-guidelines, item 13). This + /// used to do NOTHING for either tier — `emisoraParaBusqueda` returns + /// `null` on an empty query and the caller just returned — so the single + /// most likely voice command failed even for a paying customer. Google's + /// voice-actions documentation requires an empty query to start playback. + /// + /// Never throws. A miss now publishes an explained error rather than + /// returning silently; it still never plays something arbitrary. @override Future playFromSearch( String query, [ Map? extras, ]) async { try { - // iap-freemium-unlock (Design ADR-4 backstop): voice search resolves a - // station and switches to it — a browse/switch action, blocked for - // free tier just like `playFromMediaId`/`skipToNext-Previous`. - if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { + final premium = await esPremiumPersistido(); + final destacadas = await resolverEmisorasDestacadas(); + + if (query.trim().isEmpty) { + // `resolverEmisorasDestacadas` puts the last played station first + // when one is persisted, so this is "resume what I was listening + // to", falling back to the first featured station on a fresh install. + final arranque = destacadas.isEmpty ? null : destacadas.first; + if (arranque == null) { + _publicarErrorAuto( + codigoErrorEmisoraNoDisponible, + _textos.autoErrorBusquedaSinResultados, + ); + return; + } + await playMediaItem(mediaItemParaEmisora(arranque, l10n: _textos)); return; } - final fuente = _fuenteNavegacionGlobal; - if (fuente == null) return; - final candidatas = [ - ...await fuente.favoritos(), - ...await fuente.misEmisoras(), - ...await fuente.todas(), - ]; + + final candidatas = await _candidatasBusqueda( + premium: premium, + destacadas: destacadas, + ); final emisora = emisoraParaBusqueda(query, candidatas); - if (emisora == null) return; + if (emisora == null) { + _publicarErrorAuto( + codigoErrorEmisoraNoDisponible, + _textos.autoErrorBusquedaSinResultados, + ); + return; + } await playMediaItem(mediaItemParaEmisora(emisora, l10n: _textos)); } catch (e) { debugPrint( @@ -2325,14 +3103,29 @@ class PluriWaveAudioHandler extends BaseAudioHandler Map? extras, ]) async { try { - // iap-freemium-unlock (Design ADR-4 backstop): the mandatory backstop - // against a head-unit's CACHED browse tree — `getChildren` alone - // cannot stop a stale `emisora:`/`pista:`/`eq_preset:` tap from - // a tree fetched before a downgrade (or from another device). Checked - // BEFORE every branch below, including local tracks and the - // equalizer (android-auto-media spec "Free-Tier Browse Never Leaks - // Real Content (Authoritative Backstop)"). - if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { + // The mandatory backstop against a head unit's CACHED browse tree: + // `getChildren` alone cannot stop a stale `emisora:`/`pista:`/ + // `eq_preset:` tap from a tree fetched before a downgrade (or from + // another device). Checked BEFORE every branch below, including local + // tracks and the equalizer. + // + // fix/auto-quality-guidelines item 11/12: the gate is now content + // scoped, and its refusal is EXPLAINED instead of silent. Only an + // `emisora:` whose uuid is in the free set is free content — + // every other id shape (local tracks, folder-play actions, equalizer + // presets, catalogue stations) resolves `uuid` to `null` here and is + // therefore premium, exactly as before. + final premium = await esPremiumPersistido(); + final destacadas = await resolverEmisorasDestacadas(); + final uuid = uuidDeMediaIdEmisora(mediaId); + if (debeBloquearCambioDeEmisora( + premium: premium, + esEmisoraGratuita: esEmisoraGratuita(uuid, destacadas), + )) { + _publicarErrorAuto( + codigoErrorEmisoraPremium, + _textos.autoErrorEmisoraPremium, + ); return; } // Local-track playback (Design "Local Track Playback Reuses Existing @@ -2346,6 +3139,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler mediaId, fuente: fuenteLocal, reproducir: playMediaItem, + etiquetas: etiquetasArbolAutoDesde(_textos), ); return; } @@ -2385,13 +3179,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler ); return; } - final fuente = _fuenteNavegacionGlobal; - if (fuente == null) return; - await reproducirPorMediaId( + // Station ids. `_fuenteNavegacionGlobal` is null until `main.dart` + // registers it, and used to make this branch return in silence — so a + // tap that arrived before registration did nothing, with nothing said. + // The free set is resolvable straight from the binary, so it stands in + // as the browse source instead (item 12). + final fuente = + _fuenteNavegacionGlobal ?? FuenteEmisorasAutoDestacadas(destacadas); + final reproducida = await reproducirPorMediaId( mediaId, fuente: fuente, reproducir: playMediaItem, ); + if (!reproducida) { + _publicarErrorAuto( + codigoErrorEmisoraNoDisponible, + _textos.autoErrorBusquedaSinResultados, + ); + } } catch (e) { // Spec "Unknown or stale media id": never propagate from the handler. debugPrint( diff --git a/test/arranque_invalidacion_arbol_auto_test.dart b/test/arranque_invalidacion_arbol_auto_test.dart index 6915ed6..347847d 100644 --- a/test/arranque_invalidacion_arbol_auto_test.dart +++ b/test/arranque_invalidacion_arbol_auto_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/main.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'helpers/handlers_audio.dart'; + /// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador. /// /// El disparador anterior era `View.maybeOf(context) != null` dentro de @@ -28,6 +30,8 @@ import 'package:pluriwave/servicios/servicio_audio.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + final crearHandler = registrarHandlersLiberables(); + group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () { test('resumed + coche ya suscrito + latch libre invalida', () { expect( @@ -109,7 +113,7 @@ void main() { 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(); + final handler = crearHandler(); registrarHandler(handler); var invalidaciones = 0; registrarInvalidacionArbolAuto(() => invalidaciones++); @@ -144,7 +148,7 @@ void main() { 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(); + final handler = crearHandler(); registrarHandler(handler); // El coche navegó la raíz durante el arranque headless: el sujeto @@ -190,7 +194,7 @@ void main() { group('hayCocheSuscritoAlArbol', () { test('es false sin handler suscrito y true en cuanto el coche navega un ' 'id', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler(handler); expect(hayCocheSuscritoAlArbol(), isFalse); diff --git a/test/helpers/handlers_audio.dart b/test/helpers/handlers_audio.dart new file mode 100644 index 0000000..b656f0e --- /dev/null +++ b/test/helpers/handlers_audio.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// Test-isolation seam for [PluriWaveAudioHandler]. +/// +/// A handler nobody releases keeps running after the test that built it: its +/// terminal-state floor timer, its `ControladorReconexion` backoff (1/2/4/8/16 +/// s, longer than most of the tests that arm it) and anything still queued on +/// its source-change chain. When one of those finally performs a source change +/// it calls `_crearPlayer()`, which reads the CURRENT +/// [PluriWaveAudioHandler.fabricaReproductorPrueba] — so a dead handler builds +/// a double bound to a LATER test's script and drives it, incrementing that +/// test's counters for work it never asked for. +/// +/// That is why `servicio_audio_transporte_test.dart` behaved differently run +/// alone and run inside the whole suite. A suite that passes under those +/// conditions passes by luck, and luck runs out on a broken build exactly when +/// it matters. +/// +/// Usage — call ONCE at the top of `main()` and build every handler through +/// the returned function: +/// +/// ```dart +/// final crearHandler = registrarHandlersLiberables(); +/// ... +/// final handler = crearHandler(); +/// ``` +/// +/// The `tearDown` it registers covers every group in the file. +PluriWaveAudioHandler Function() registrarHandlersLiberables() { + final creados = []; + tearDown(() async { + // Released in reverse creation order so a handler built on top of an + // earlier one is torn down first. `liberar` is idempotent, so a test that + // already released its own handler is fine. + for (final handler in creados.reversed) { + await handler.liberar(); + } + creados.clear(); + }); + return () { + final handler = PluriWaveAudioHandler(); + creados.add(handler); + return handler; + }; +} diff --git a/test/l10n/etiquetas_arbol_auto_test.dart b/test/l10n/etiquetas_arbol_auto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..14fcd22c6648e2f4dca6a4a815c768c763787bfe GIT binary patch literal 11120 zcmb7K&2HRClAg1kB5fE`EW3)bdoH{3AJlI3v@o({Sn4$}WsNF}%&JO@MV6VQ4^ew zGF9`UR24q&=4n~jRPEMPp=M=q>5QsMV`3t}MBXho%2vvJvaWJFYO2fG*;<#j+VJYU z;8f{6l}|da>{(K*dtYZowPa$Y(W$Ka_FMJ+{=wdHtlls&wJwVbU0F7Vyt(66Vm3ufKMfr!F);_%nM{uhhP6Rd-FbW&v-rnW3D)il=^ zW}y>X@mc+kSwFB>@Wv8rTcB)e6j$}{o57ni1s?JRg^Cw2nGBZ-HAoJ1( z=0&SzvF@1vWC`)e&D2Vt!yx1e?etjfR|?+X@P*t7l8niHK()pSYHrDha1Lf=u5ISj z<Jna7(O{79CSp37g>5v3CP`ohf`J|AJ@ayi8tL2Qwa#p-DyUYNRsw z)69T*0|`ZLt)_Ix<9gP4iQq?=$^f0s>J=Vy z1dixm;ZK)~dIjf(x#z5qq<|L7I;o%_#2jgr)`FLb@gn?QmvC>Jn33m3b?r5jfDAl3 zhyySP>(DaOGx)_C28nD6ec7tcis2MbP={d7cwS_ww+nGO$9hG%VRMj+kxC7m)~Hvz zhlfWe;0@13t{|G#%BF54k7HmNtU0%z3|7GLVo{<9nSWUcmQC)2h&L zYG(CfJ1c-SSe2ZLspUluapsV-fZ`c|G>`(g0PP3QSQpM#@Y2>vp&6YafN5E$jt=)! zhPfG0BIAwa7fFQpJ=}f0r=F`p9LIwx=%!F1tXkw^Rk)#s3xfstB!UlGOn^1;S^_jO zRcR_rNlAGZ5Ee7l66&__C3aoGn`nw?fV0prta>Kqey!aBK{_$;`tvXD*T4Sv;OUfN z2Cm|icWlHAZ z3xXo+I+M~9MeCD~eGX3E(b0DWPR7~J-a9C5}X*`Fk9&{&@}5?X-qmGy%-0Y0Q- z4tp?BEmbt7B=ZKLua9khSgdBHnGQt~WCntE!sa|LDoT2-1D*V^r%v7-sojHvqj!7X zse}EKy|=pu$0NmhA}6`9X$@G|s1LQP?3^+y;JLV@%mm6A7G<_p`0R7%3}Ibo=VN%d z$x}=qh=f1p$Oz;}EZdg%4_zipdr`Dl4HubgDK!ApZhui(Qs<0rj|(%9c2g^PcQD#I z^%c<}VPS(!f`MHu3o7GG2Im0x&^#QJqBU+=77Q)ie^_TJ(uwB*8)Ps-;ns&{dL_Wj zPXx<@z|etDWfDNLyau!-MM=>a3JYSvXTx}FRuq{!Gj8uwV%Ah*yfG!b)uuW+mO2J* zUz9_21@-tcaE2-%TF+(3n8mT3gIj0^IUgChH=5BMUp)FbtT-d--@ zRVwKtfmnwMUPL6ixJg^nrbdwuj$lIzPu2t5``vNC@FKLf;;Jx4(BBlIBEKqavF1 z3>P6VF$ip!kxfMvG!mOZeTRBPBR1gCPz)Oa-|CE%n^LhvYiPSzqVmOV7H^RqU@j(8 zly*UCA4)0)1QsPK8{Xms-hx-oq@MPsM{cJ=1a+a$N6FHM2fLunlMD%g_juTClv64O zkpA_hC)?)4SB*Y^!!yzm{Pg?%x5p>rB-0Lc9HmjL&cz>g#Dis3tx*`Fs)Alvj_CkT zHw#Cqd8Uf?!f?$CBicaZ7!F0PKF|X~YYIeJFN6-u=8| zk5ps{ZJG!<1)iXW=lJo~;uP6q=;|7vgBYUj18EsMHt<71m>QS*cmy_9I>ZL`EC7CV|woc+x~W7$BdZn9kNI?fMWn$fa_OGJ1EBK42~aQCFbBbnn%PG4HrhjU=i^- zaxzVNrXH)8eQ;4b@-0m3XmlhBYABLCOfQW6I>z*6d3Zw>G6y<0^BR?6imkX;6)CYn z$FR)2-^&ynr^hKmM0;Tlyg(V-7*}k&mcpT&+BDa?$ zvKH~2<7_-v<~SAJ_vQt*n7aDy9=4Xn;L`4 z(2FXd@)3R}qP$eX=8m!={ui?LM-<$o8?s7B>3{%Y7{JkaGay#4yb^5dSA9G6l!}@g zHiu2xmW4*bcGu5h|650pgfb5!x5k9`V1e z=W|m=e$C&GJ<`lQQ7Sajt1qZPAvJMbanmDOZBfW$-x1SBC8UT6&yj>c`oA41eZ{t( zeiuJoOehBru5Lx>SKvqhiBbo%Bzh%=ylNQZrNaI~M5x2qmjLQt;mF57DnDYc z8%bVRXzz|fT+}{N=iXUm@NX~N6RFh^O55F>>PqZOXd8m^ZC>gH6}i+2qUsHuQ>sm| zsej5f)k>G=*b|?ly~G=e1_VJ%ick>Y9j&yOc?ZokE^mvlYYF>YIyj)lWssp85`*6j zIug>(p_2;!l{lD$jRW?N4OsQV@5WkP<9}@>9|2tmW1PB~3OV21fkY&BFUJM5boJ1H zOQVe@(|KTRszy#t9#^VD>d=5`om@znhFBC2rwWI&Ayf1F9>6|T#sF5X5I+<)kFd>X zR@!MFK?XBvi}XE8^SisK&-}y})W%E=b_OVnvEjw`fa+8#)T!wf`mszcbca#9CM6CM z&^x6KlHUTLAF;$~hAHDMD&9rbwtmN~_;Tex+7fRG zIWo|Lx^6B<)p-Ctv=~e5(rmCu6;iv21_Lj~L14 zN2&fSb_4-hpy*2Gc)MXDzMJNRJtp2S3A%kI)z+6xksZp-w;R#Ru&d z0VH1Obrh|J)Q?~%9>!Hcu}E11sLuU{6|#RM7N@g_Xh0qAI@bzaOg=dVZZD{@4`k2O z=U?a;8IS$j5(vyX5166`-th;>E-4fNBuB2PiWHrD?AmdPjrn`G4yIATdPYLb*c`=5 z(nh`jgb#iR(ALmz4J?Kubn4WgN5MRF0HUlb9FFFs=o^PDt-Fh%Bqf6iWxR;y4RPkF z(_JU)A8+y`_MTyXByk0??IE*0@;?T7cH#ZDgY!V zUh_(!N8E9#);+ynk`Kur4g4A?-a77n3V$hO z4kT*io~)at#qt+8>_%nn2hcsAAQIsl*I0yRk_ZD9(-5kO(Qn0uQ%-1bj)!I%?#xVM z9K_SVk^gssF6JDiGuo*`v~f4+L!tqWGrwjjrlz@V>1!i4BF>5B@EeD-{@{R`|CB-b zdW>>Q9GKGYJ|#r-&0}Rc7H&)EV0CJIQpZfJ6T5om__&Lzp!RRwnweg%fg}I+SiK0d zq!*9&nWy4^@A!e;$&KF<9r0aO9$4_e-&+_bJ72AaqDed&zMn*=Lq;8 z_JZ{v3dad^))ZMCXQg$#vx;*(xayC+_N`W7s7;1`13<6uaA}gUJq`(j?mi3UXn+vy zoW@*YOlqBTIHBcwyH@J>*W-~|4~L=bY0g%7`3_oh_8vSS@Z5PI#G?mL!chJCxBpc4 zA237M6o>RKLVJOcn{nP7T)9P*255q3E&$62&lB7)ioSU~46o&&cBc^$2k3j5!baos zFX-7*gyZ-f+ABZ*qk~tJl$xA}%!ito5=t4XJsjhNoJN8Us9wPFf1(wQ!-*K*ZQx%i zXkO^#^Z#fS{r~^r4has<=?(&3H`lsw`=w7W6sz zt~TN49s2(P;2D(bzFtXSJBaIQaMcxVF?#Ss$a@mPC8abkZVip% J8Rov;`X@=8YkL3y literal 0 HcmV?d00001 diff --git a/test/l10n/identical_value_allowlist.dart b/test/l10n/identical_value_allowlist.dart index 11f3661..ff6f1f8 100644 --- a/test/l10n/identical_value_allowlist.dart +++ b/test/l10n/identical_value_allowlist.dart @@ -300,4 +300,10 @@ const Set<(String locale, String key)> identicalValueAllowlist = { 'restaurarCompras', ), // iap-freemium-unlock new key -- "Restaurar compras" is the standard // Portuguese store wording and coincides with es word for word. + ( + 'pt', + 'autoCarpetaFavoritos', + ), // fix/auto-quality-guidelines car-tree label -- "Favoritos" is the same + // word in pt and es, exactly like the already-listed ('pt', + // 'favoritesTitle') above, which carries this very value. }; diff --git a/test/servicios/emisoras_destacadas_test.dart b/test/servicios/emisoras_destacadas_test.dart new file mode 100644 index 0000000..8487ad2 --- /dev/null +++ b/test/servicios/emisoras_destacadas_test.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/emisoras_destacadas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Free-tier featured set (fix/auto-quality-guidelines, item 6). +/// +/// The whole compliance story rests on this: a Play reviewer on a fresh +/// install is ALWAYS free tier, has no network catalogue snapshot, no +/// favourites, no custom stations and no `ultima_emisora_v1` — so the free +/// root's single folder MUST still resolve to real, playable stations from +/// nothing but the binary itself. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const ultima = Emisora( + uuid: 'uuid-ultima', + nombre: 'Ultima escuchada', + url: 'https://ultima.example/stream', + ); + + group('resolverEmisorasDestacadas', () { + test('cold bind: sin red, sin EstadoRadio y con prefs vacías devuelve ' + '>= 3 emisoras reales', () async { + SharedPreferences.setMockInitialValues({}); + + final destacadas = await resolverEmisorasDestacadas(); + + expect(destacadas.length, greaterThanOrEqualTo(3)); + expect( + destacadas.every((e) => e.uuid.isNotEmpty), + isTrue, + reason: 'un uuid vacío no se puede resolver desde emisora:', + ); + expect( + destacadas.every( + (e) => e.url.startsWith('http://') || e.url.startsWith('https://'), + ), + isTrue, + ); + expect( + destacadas.map((e) => e.uuid).toSet().length, + destacadas.length, + reason: 'uuids duplicados romperían porUuid', + ); + }); + + test('con ultima_emisora_v1 presente: va PRIMERA y no se duplica', + () async { + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: jsonEncode(ultima.toMap()), + }); + + final destacadas = await resolverEmisorasDestacadas(); + + expect(destacadas.first.uuid, ultima.uuid); + expect( + destacadas.where((e) => e.uuid == ultima.uuid).length, + 1, + reason: 'la última escuchada no puede aparecer dos veces', + ); + expect(destacadas.length, emisorasDestacadas.length + 1); + }); + + test('la última escuchada YA curada no añade una segunda fila', () async { + final yaCurada = emisorasDestacadas.first; + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: jsonEncode(yaCurada.toMap()), + }); + + final destacadas = await resolverEmisorasDestacadas(); + + expect(destacadas.first.uuid, yaCurada.uuid); + expect(destacadas.length, emisorasDestacadas.length); + }); + + test('ultima_emisora_v1 corrupta degrada al set curado, nunca lanza', + () async { + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: 'no-es-json{{', + }); + + final destacadas = await resolverEmisorasDestacadas(); + + expect(destacadas.length, emisorasDestacadas.length); + }); + }); + + group('esEmisoraGratuitaPorUuid', () { + test('un uuid curado es gratuito', () async { + SharedPreferences.setMockInitialValues({}); + + expect( + await esEmisoraGratuitaPorUuid(emisorasDestacadas.first.uuid), + isTrue, + ); + }); + + test('la última escuchada es gratuita aunque no esté curada', () async { + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: jsonEncode(ultima.toMap()), + }); + + expect(await esEmisoraGratuitaPorUuid(ultima.uuid), isTrue); + }); + + test('un uuid del catálogo Radio Browser NO es gratuito', () async { + SharedPreferences.setMockInitialValues({}); + + expect(await esEmisoraGratuitaPorUuid('uuid-del-catalogo'), isFalse); + }); + + test('uuid vacío nunca es gratuito', () async { + SharedPreferences.setMockInitialValues({}); + + expect(await esEmisoraGratuitaPorUuid(''), isFalse); + }); + }); + + test('claveUltimaEmisora coincide con la que persiste EstadoRadio', () { + expect(claveUltimaEmisora, 'ultima_emisora_v1'); + }); +} diff --git a/test/servicios/navegacion_auto_destacadas_test.dart b/test/servicios/navegacion_auto_destacadas_test.dart new file mode 100644 index 0000000..5e30e46 --- /dev/null +++ b/test/servicios/navegacion_auto_destacadas_test.dart @@ -0,0 +1,267 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/emisoras_destacadas.dart'; +import 'package:pluriwave/servicios/navegacion_auto.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Free-tier Android Auto surface (fix/auto-quality-guidelines, items 7, 8 +/// and 10). +/// +/// Google Play returned "Approved with Issues" against the Android for Cars +/// App Quality Guidelines on version code 157. The free root advertised four +/// folders that each dead-ended on a single non-playable "Función Premium" +/// row, and on a cold headless bind every one of the underlying lists is +/// empty anyway. This suite pins the replacement: ONE browsable folder that +/// resolves to real, playable stations. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FuenteEmisorasAutoLocal.porUuid — item 7', () { + // Cold bind shape: `todas()` is `_snapshotTodas ?? const []`, no + // favourites (sqflite is not initialised under `flutter test`, so the + // read throws and degrades to `[]`), and a custom-stations path that + // does not exist. + FuenteEmisorasAutoLocal fuenteFria() => FuenteEmisorasAutoLocal( + resolverRutaCustom: () async => 'no/existe/emisoras_custom.json', + ); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('en frío resuelve un uuid destacado (antes devolvía null y la fila ' + 'no hacía nada al tocarla)', () async { + final fuente = fuenteFria(); + + final resuelta = await fuente.porUuid(emisorasDestacadas.first.uuid); + + expect(resuelta, isNotNull); + expect(resuelta!.url, emisorasDestacadas.first.url); + }); + + test('en frío resuelve la última escuchada persistida', () async { + const ultima = Emisora( + uuid: 'uuid-ultima', + nombre: 'Ultima', + url: 'https://ultima.example/stream', + ); + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: + '{"uuid":"uuid-ultima","nombre":"Ultima",' + '"url":"https://ultima.example/stream"}', + }); + + final resuelta = await fuenteFria().porUuid(ultima.uuid); + + expect(resuelta?.url, ultima.url); + }); + + test('un uuid desconocido sigue devolviendo null', () async { + expect(await fuenteFria().porUuid('uuid-inexistente'), isNull); + }); + + test('el snapshot vivo gana al set destacado para el MISMO uuid', + () async { + final fuente = fuenteFria(); + final delCatalogo = Emisora( + uuid: emisorasDestacadas.first.uuid, + nombre: 'Version viva', + url: 'https://viva.example/stream', + ); + fuente.actualizarSnapshot(todas: [delCatalogo]); + + final resuelta = await fuente.porUuid(delCatalogo.uuid); + + expect(resuelta?.url, 'https://viva.example/stream'); + }); + }); + + group('raiz(premium:) — item 8', () { + test('free: exactamente UNA carpeta navegable, y ninguna de las cuatro ' + 'que morían en la fila premium', () { + final constructor = ConstructorArbolAuto(); + + // `incluirMusicaLocal: true` a propósito: ni siquiera con carpeta + // local configurada puede el tier gratuito ver ese nodo. + final libre = constructor.raiz(incluirMusicaLocal: true, premium: false); + + expect(libre, hasLength(1)); + expect(libre.single.id, ConstructorArbolAuto.idDestacadas); + expect(libre.single.playable, isFalse); + expect(libre.single.title, isNotEmpty); + expect( + libre.map((m) => m.id), + isNot( + anyOf( + contains(ConstructorArbolAuto.idFavoritos), + contains(ConstructorArbolAuto.idTodas), + contains(ConstructorArbolAuto.idMisEmisoras), + contains(ConstructorArbolAuto.idMusicaLocal), + ), + ), + ); + }); + + test('premium: el árbol de hoy, sin cambios (guardia de regresión)', () { + final constructor = ConstructorArbolAuto(); + + expect( + constructor + .raiz(incluirMusicaLocal: true, premium: true) + .map((m) => m.id), + [ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + ], + ); + expect( + constructor + .raiz(incluirMusicaLocal: false, premium: true) + .map((m) => m.id), + [ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ], + ); + expect( + constructor + .raiz(incluirMusicaLocal: true, premium: true) + .every((m) => m.playable == false), + isTrue, + ); + }); + + test('la raíz del tier gratuito SIEMPRE lleva una carpeta navegable: ' + 'audio_service 0.18.18 descarta los rootHints, así que un root de ' + 'un solo item PLAYABLE se renderiza vacío en una unidad que solo ' + 'acepta FLAG_BROWSABLE', () { + final libre = ConstructorArbolAuto().raiz( + incluirMusicaLocal: false, + premium: false, + ); + + expect(libre.any((m) => m.playable == false), isTrue); + }); + + test('el titulo de la unica carpeta gratuita lo decide el LLAMANTE, no ' + 'una constante castellana de este archivo (hallazgo 4)', () { + final libre = ConstructorArbolAuto().raiz( + incluirMusicaLocal: false, + premium: false, + tituloDestacadas: 'Listen', + ); + + expect( + libre.single.title, + 'Listen', + reason: + 'this one label is 100% of the browse tree a free-tier (i.e. ' + 'every Play reviewer) driver ever sees; the pure builder stays ' + 'AppLocalizations-free, so the handler has to hand it the string', + ); + }); + }); + + group('hijosDestacadas — item 8/9', () { + test('mapea a items PLAYABLE con id emisora:', () { + final items = ConstructorArbolAuto().hijosDestacadas(emisorasDestacadas); + + expect(items, hasLength(emisorasDestacadas.length)); + expect(items.every((m) => m.playable == true), isTrue); + expect(items.first.id, 'emisora:${emisorasDestacadas.first.uuid}'); + expect(items.every((m) => m.artUri != null), isTrue); + }); + + test('lista vacía devuelve lista vacía, nunca lanza', () { + expect(ConstructorArbolAuto().hijosDestacadas(const []), isEmpty); + }); + }); + + group('respuestaBloqueadaPorEntitlement — item 10', () { + List? gate(String id, {required bool premium}) => + respuestaBloqueadaPorEntitlement( + parentMediaId: id, + premium: premium, + destacadas: emisorasDestacadas, + ); + + test('premium: nada se bloquea', () { + for (final id in [ + AudioService.browsableRootId, + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + ConstructorArbolAuto.idDestacadas, + 'emisora:uuid-del-catalogo', + ]) { + expect(gate(id, premium: true), isNull, reason: id); + } + }); + + test('free: la raíz y el contenido gratuito PASAN', () { + expect(gate(AudioService.browsableRootId, premium: false), isNull); + expect(gate(ConstructorArbolAuto.idDestacadas, premium: false), isNull); + for (final e in emisorasDestacadas) { + expect(gate('emisora:${e.uuid}', premium: false), isNull); + } + }); + + test('free: el catálogo premium se bloquea', () { + for (final id in [ + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, + ConstructorArbolAuto.idMusicaLocal, + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idEcualizador, + 'grupo:algo', + 'emisora:uuid-del-catalogo', + 'pista:doc-id', + ]) { + expect(gate(id, premium: false), isNotNull, reason: id); + } + }); + + test('la rama bloqueada devuelve el contenido gratuito, NUNCA una fila ' + 'no reproducible: eso es exactamente lo que Play citó', () { + final bloqueada = gate(ConstructorArbolAuto.idTodas, premium: false); + + expect(bloqueada, isNotNull); + expect(bloqueada, isNotEmpty); + expect( + bloqueada!.every((m) => m.playable == true), + isTrue, + reason: 'una fila no reproducible en el árbol es la cita de Play', + ); + expect(bloqueada.map((m) => m.id), [ + for (final e in emisorasDestacadas) 'emisora:${e.uuid}', + ]); + }); + + test('sin destacadas resolubles la rama bloqueada sigue sin inventar una ' + 'fila muerta', () { + final bloqueada = respuestaBloqueadaPorEntitlement( + parentMediaId: ConstructorArbolAuto.idTodas, + premium: false, + destacadas: const [], + ); + + expect(bloqueada, isEmpty); + }); + }); + + test('idPremiumInfo / itemPremiumBloqueado ya no existen — item 10', () { + // Guardia estructural: si alguien los reintroduce, este archivo deja de + // compilar por el `expect` de abajo, no por un comentario. La única + // prueba real es que `ConstructorArbolAuto` no expone ningún item no + // reproducible fuera de las carpetas. + final libre = ConstructorArbolAuto().raiz( + incluirMusicaLocal: true, + premium: false, + ); + + expect(libre.every((m) => m.id != 'premium:info'), isTrue); + }); +} diff --git a/test/servicios/navegacion_auto_gating_test.dart b/test/servicios/navegacion_auto_gating_test.dart index 5541582..1d3661f 100644 --- a/test/servicios/navegacion_auto_gating_test.dart +++ b/test/servicios/navegacion_auto_gating_test.dart @@ -1,106 +1,142 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart'; -/// Android Auto entitlement gating (android-auto-media spec "Free-Tier -/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content", -/// design.md ADR-4). All pure — no handler instantiation needed -/// (`PluriWaveAudioHandler` cannot be constructed in a unit test). +/// Android Auto entitlement gating — the id-shape matrix. +/// +/// REWRITTEN for fix/auto-quality-guidelines item 10. This suite used to +/// assert the opposite design: that every non-root id, for a free-tier user, +/// collapsed to a single non-playable `premium:info` row. Google Play cited +/// that browse tree against the Android for Cars App Quality Guidelines, so +/// the contract is now content-scoping — the free tier sees LESS, never a +/// row that does nothing. +/// +/// [respuestaBloqueadaPorEntitlement]'s return VALUE is covered in +/// `navegacion_auto_destacadas_test.dart`; this file pins the decision +/// surface ([idPermitidoEnFree]) across every id shape the tree can produce, +/// including the stale/deep-linked ones a head unit's cached tree replays. void main() { - group('raiz(premium:) — root keeps its labels for every tier', () { - test('premium: identical to today\'s tree (regression guard)', () { - final constructor = ConstructorArbolAuto(); + const gratuitas = [ + Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'), + Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'), + ]; - final premiumConLocal = constructor.raiz( - incluirMusicaLocal: true, - premium: true, + group('idPermitidoEnFree', () { + test('la raíz siempre pasa: es lo único que decide qué ve el tier', () { + expect( + idPermitidoEnFree( + AudioService.browsableRootId, + destacadas: gratuitas, + ), + isTrue, ); - final premiumSinLocal = constructor.raiz( - incluirMusicaLocal: false, - premium: true, - ); - - expect(premiumConLocal.map((m) => m.id), [ - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idMusicaLocal, - ]); - expect(premiumConLocal.every((m) => m.playable == false), isTrue); - expect(premiumConLocal.every((m) => m.displaySubtitle == null), isTrue); - expect(premiumSinLocal.map((m) => m.id), [ - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - ]); }); - test('free: same folder ids/titles, non-blank, never playable', () { - final constructor = ConstructorArbolAuto(); - - final libre = constructor.raiz(incluirMusicaLocal: true, premium: false); - - expect(libre, isNotEmpty); - expect(libre.map((m) => m.id), [ - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idMusicaLocal, - ]); - expect(libre.every((m) => m.playable == false), isTrue); - }); - }); - - test( - 'itemPremiumBloqueado(): id fijo, no reproducible, etiqueta premium', - () { - final item = ConstructorArbolAuto().itemPremiumBloqueado(); - - expect(item.id, 'premium:info'); - expect(item.playable, isFalse); - expect(item.title, isNotEmpty); - }, - ); - - group('respuestaBloqueadaPorEntitlement — backstop de navegacion', () { - test('root nunca es bloqueada (root siempre resuelve via raiz)', () { - final respuesta = respuestaBloqueadaPorEntitlement( - parentMediaId: AudioService.browsableRootId, - premium: false, + test('la carpeta gratuita pasa', () { + expect( + idPermitidoEnFree( + ConstructorArbolAuto.idDestacadas, + destacadas: gratuitas, + ), + isTrue, ); - - expect(respuesta, isNull); }); - test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () { + test('un emisora: del set gratuito pasa', () { + for (final e in gratuitas) { + expect( + idPermitidoEnFree('emisora:${e.uuid}', destacadas: gratuitas), + isTrue, + reason: e.uuid, + ); + } + }); + + test('las carpetas premium NO pasan', () { for (final id in [ ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idMusicaLocal, ConstructorArbolAuto.idEcualizador, - // Stale/deep-linked id from before a downgrade — the backstop must - // not special-case known ids (Spec "Stale folder id bypass - // attempt"). - 'emisora:algun-uuid-viejo', - 'grupo:algo', ]) { - final respuesta = respuestaBloqueadaPorEntitlement( - parentMediaId: id, - premium: false, - ); - expect(respuesta, hasLength(1)); - expect(respuesta!.single.id, 'premium:info'); + expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse, + reason: id); } }); - test('cualquier id no-root, en premium, no es bloqueada', () { - final respuesta = respuestaBloqueadaPorEntitlement( - parentMediaId: ConstructorArbolAuto.idFavoritos, - premium: true, + test('un id rancio/deep-link de antes de una bajada de tier NO pasa: ' + 'ésa es la propiedad de seguridad que el rediseño tenía que ' + 'conservar', () { + for (final id in [ + 'emisora:uuid-del-catalogo', + 'grupo:algun-grupo', + 'pista:doc-id', + 'carpeta_local:doc-id', + 'carpeta_local_reproducir:doc-id', + 'carpeta_local_aleatorio:doc-id', + 'eq_preset:Rock', + 'premium:info', // la fila muerta que ya no existe + '', + ]) { + expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse, + reason: id); + } + }); + + test('emisora: con uuid vacío NO pasa (id malformado, no comodín)', () { + expect(idPermitidoEnFree('emisora:', destacadas: gratuitas), isFalse); + }); + + test('con el set gratuito vacío solo pasan la raíz y su carpeta', () { + expect( + idPermitidoEnFree(AudioService.browsableRootId, destacadas: const []), + isTrue, + ); + expect( + idPermitidoEnFree( + ConstructorArbolAuto.idDestacadas, + destacadas: const [], + ), + isTrue, + ); + expect( + idPermitidoEnFree('emisora:libre-1', destacadas: const []), + isFalse, + ); + }); + }); + + group('respuestaBloqueadaPorEntitlement', () { + test('premium: ningún id se bloquea, ni siquiera uno inventado', () { + for (final id in [ + AudioService.browsableRootId, + ConstructorArbolAuto.idTodas, + 'emisora:cualquier-cosa', + 'basura', + ]) { + expect( + respuestaBloqueadaPorEntitlement( + parentMediaId: id, + premium: true, + destacadas: gratuitas, + ), + isNull, + reason: id, + ); + } + }); + + test('free: lo bloqueado NUNCA incluye un item no reproducible', () { + final bloqueada = respuestaBloqueadaPorEntitlement( + parentMediaId: ConstructorArbolAuto.idMisEmisoras, + premium: false, + destacadas: gratuitas, ); - expect(respuesta, isNull); + expect(bloqueada, isNotNull); + expect(bloqueada!.every((m) => m.playable == true), isTrue); }); }); } diff --git a/test/servicios/navegacion_auto_localizacion_test.dart b/test/servicios/navegacion_auto_localizacion_test.dart new file mode 100644 index 0000000..a724e8e --- /dev/null +++ b/test/servicios/navegacion_auto_localizacion_test.dart @@ -0,0 +1,221 @@ +import 'dart:ui' show Locale; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.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'; + +/// Every user-readable label of the Android Auto browse tree is translated. +/// +/// The owner's rule, after Play saw a Spanish-only car tree on a head unit +/// in any of the 13 shipped locales: anything a user can read gets +/// translated. The two ARB guards (`arb_parity_test`/`arb_anti_copy_test`) +/// only ever see strings that already entered the ARB system, so neither +/// could catch a label hardcoded in `navegacion_auto.dart` that never +/// became a key. This file closes that hole from the CONSUMPTION side +/// (the tree really renders the injected locale); +/// `test/l10n/etiquetas_arbol_auto_test.dart` closes it from the SOURCE +/// side (no new hardcoded label can be added at all). +Future> _sinMetadatos(List ids) async => + const {}; + +List _pistas(int cuantas, {String prefijo = 'cancion'}) => + List.generate( + cuantas, + (i) => NodoLocal( + documentId: 'doc-$prefijo-$i', + nombre: '${prefijo}_${i.toString().padLeft(3, '0')}.mp3', + esDirectorio: false, + ), + ); + +void main() { + final ingles = lookupAppLocalizations(const Locale('en')); + + group('EtiquetasArbolAuto desde AppLocalizations', () { + test('mapea cada etiqueta del árbol a su clave ARB del locale', () { + final etiquetas = etiquetasArbolAutoDesde(ingles); + + expect(etiquetas.escuchar, ingles.autoCarpetaEscuchar); + expect(etiquetas.favoritos, ingles.autoCarpetaFavoritos); + expect(etiquetas.todasLasEmisoras, ingles.autoCarpetaTodas); + expect(etiquetas.misEmisoras, ingles.autoCarpetaMisEmisoras); + expect(etiquetas.musicaLocal, ingles.autoCarpetaMusicaLocal); + expect( + etiquetas.musicaLocalNoDisponible, + ingles.autoMusicaLocalNoDisponible, + ); + expect(etiquetas.cargarMas, ingles.autoCargarMas); + expect(etiquetas.ordenarPorCalidad, ingles.autoOrdenarPorCalidad); + expect(etiquetas.reproducirCarpeta, ingles.autoReproducirCarpeta); + expect(etiquetas.reproducirAleatorio, ingles.autoReproducirAleatorio); + expect(etiquetas.pistaSinNombre, ingles.autoPistaSinNombre); + }); + + test('ninguna etiqueta inglesa cae en el castellano de respaldo', () { + final etiquetas = etiquetasArbolAutoDesde(ingles); + const respaldo = EtiquetasArbolAuto.respaldo; + + expect(etiquetas.favoritos, isNot(respaldo.favoritos)); + expect(etiquetas.todasLasEmisoras, isNot(respaldo.todasLasEmisoras)); + expect(etiquetas.misEmisoras, isNot(respaldo.misEmisoras)); + expect(etiquetas.musicaLocal, isNot(respaldo.musicaLocal)); + expect( + etiquetas.musicaLocalNoDisponible, + isNot(respaldo.musicaLocalNoDisponible), + ); + expect(etiquetas.cargarMas, isNot(respaldo.cargarMas)); + expect(etiquetas.ordenarPorCalidad, isNot(respaldo.ordenarPorCalidad)); + expect(etiquetas.reproducirCarpeta, isNot(respaldo.reproducirCarpeta)); + expect(etiquetas.reproducirAleatorio, isNot(respaldo.reproducirAleatorio)); + expect(etiquetas.pistaSinNombre, isNot(respaldo.pistaSinNombre)); + }); + }); + + group('ConstructorArbolAuto rotula con las etiquetas inyectadas', () { + final constructor = ConstructorArbolAuto( + etiquetas: etiquetasArbolAutoDesde(ingles), + ); + + test('la raíz premium rotula sus cuatro carpetas en el locale', () { + final raiz = constructor.raiz(incluirMusicaLocal: true, premium: true); + + expect(raiz.map((i) => i.title).toList(), [ + ingles.autoCarpetaFavoritos, + ingles.autoCarpetaTodas, + ingles.autoCarpetaMisEmisoras, + ingles.autoCarpetaMusicaLocal, + ]); + }); + + test('la raíz gratuita sigue rotulando Escuchar en el locale', () { + final raiz = constructor.raiz(incluirMusicaLocal: false, premium: false); + + expect(raiz.single.title, ingles.autoCarpetaEscuchar); + }); + + test('el item de música local no disponible va en el locale', () { + expect( + constructor.itemLocalNoDisponible().title, + ingles.autoMusicaLocalNoDisponible, + ); + }); + + test('las acciones de carpeta y la entrada de orden van en el ' + 'locale', () async { + final items = await constructor.itemsLocales( + _pistas(3), + documentIdPadre: 'padre', + metadatosDe: _sinMetadatos, + ); + + final titulos = items.map((i) => i.title).toList(); + expect(titulos, contains(ingles.autoReproducirCarpeta)); + expect(titulos, contains(ingles.autoReproducirAleatorio)); + expect(titulos, contains(ingles.autoOrdenarPorCalidad)); + }); + + test('el item "cargar más" de las tres vistas paginadas va en el ' + 'locale', () async { + final nodos = _pistas(60); + + final porNombre = await constructor.itemsLocales( + nodos, + documentIdPadre: 'padre', + metadatosDe: _sinMetadatos, + ); + final porCalidad = await constructor.itemsLocalesOrdenCalidad( + nodos, + documentIdPadre: 'padre', + metadatosDe: _sinMetadatos, + ); + final porBucket = await constructor.itemsLocalesBucket( + _pistas(60, prefijo: 'apple'), + documentIdPadre: 'padre', + idxBucket: 0, + metadatosDe: _sinMetadatos, + ); + + expect(porNombre.last.title, ingles.autoCargarMas); + expect(porCalidad.last.title, ingles.autoCargarMas); + expect(porBucket.last.title, ingles.autoCargarMas); + }); + + test('un nombre de fichero en blanco cae en la pista sin nombre del ' + 'locale', () async { + final items = await constructor.itemsLocales( + const [ + NodoLocal( + documentId: 'doc-vacio', + nombre: ' ', + esDirectorio: false, + ), + ], + documentIdPadre: 'padre', + metadatosDe: _sinMetadatos, + ); + + final pista = items.singleWhere((i) => i.id.startsWith('pista:')); + expect(pista.title, ingles.autoPistaSinNombre); + }); + + test('los rangos alfabéticos NO se traducen: son rangos de letras ' + 'latinas, no prosa', () async { + final items = await constructor.itemsLocales( + _pistas(60), + documentIdPadre: 'padre', + metadatosDe: _sinMetadatos, + ); + + expect(items.map((i) => i.title), containsAll(['A-F', 'G-M'])); + }); + }); + + group('hijosMusicaLocal propaga las etiquetas', () { + test('la carpeta raíz local rotula sus acciones en el locale', () async { + final items = await hijosMusicaLocal( + ConstructorArbolAuto.idMusicaLocal, + fuente: _FuenteLocalFalsa(), + etiquetas: etiquetasArbolAutoDesde(ingles), + ); + + expect( + items!.map((i) => i.title), + containsAll([ + ingles.autoReproducirCarpeta, + ingles.autoReproducirAleatorio, + ]), + ); + }); + }); +} + +/// Minimal in-memory [FuenteMusicaLocalAuto]: one playable track at the +/// tree root, no metadata, native channel available. +class _FuenteLocalFalsa implements FuenteMusicaLocalAuto { + @override + Future estadoCarpeta() async => + EstadoCarpetaLocal.configurada; + + @override + Future> hijos(String documentId) async => + documentId.isEmpty + ? const [ + NodoLocal( + documentId: 'doc-0', + nombre: 'cancion.mp3', + esDirectorio: false, + ), + ] + : const []; + + @override + Future> metadatosDe(List documentIds) => + _sinMetadatos(documentIds); + + @override + Future uriContenidoDePista(String documentId) async => + 'content://fake/$documentId'; +} diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index cd589fb..9a24d09 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2135,9 +2135,11 @@ void main() { /// 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`. + /// La etiqueta sale de `EtiquetasArbolAuto.musicaLocalNoDisponible`, + /// como TODAS las etiquetas legibles del árbol del coche: todo lo que + /// un usuario lee se traduce. Este test solo comprueba que hay UNA + /// etiqueta no vacía; el idioma concreto lo cubre + /// `navegacion_auto_localizacion_test.dart`. 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( diff --git a/test/servicios/servicio_audio_auto_free_test.dart b/test/servicios/servicio_audio_auto_free_test.dart new file mode 100644 index 0000000..507df8a --- /dev/null +++ b/test/servicios/servicio_audio_auto_free_test.dart @@ -0,0 +1,679 @@ +import 'dart:async'; +import 'dart:ui' show Locale; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/pista_local.dart'; +import 'package:pluriwave/servicios/emisoras_destacadas.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'; + +import '../helpers/handlers_audio.dart'; + +/// Free-tier Android Auto surface, handler side (fix/auto-quality-guidelines, +/// items 9, 11, 12, 13, 14). +/// +/// Every test here runs with `_fuenteNavegacionGlobal` NEVER registered — +/// this file never calls `registrarFuenteNavegacion`. That is the exact bind +/// a Play reviewer performs: Android Auto starts the headless engine, and +/// until (and even after) `main.dart` wires its sources, the car's browse and +/// play paths must produce real, playable content out of the binary alone. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final crearHandler = registrarHandlersLiberables(); + + late _GuionReproductor guion; + + setUp(() { + guion = _GuionReproductor(); + PluriWaveAudioHandler.fabricaReproductorPrueba = + (pipeline, carga) => _ReproductorFalso(guion, pipeline, carga); + // Fresh install = free tier: `esPremiumPersistido` is + // `getBool('compra_premium_v1') ?? false`, and there is no trial key. + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(() { + PluriWaveAudioHandler.fabricaReproductorPrueba = null; + PluriWaveAudioHandler.lectorLocalePlataforma = + PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto; + }); + + AppLocalizations textos() => lookupAppLocalizations(const Locale('es')); + + group('debeBloquearCambioDeEmisora — item 11', () { + test('free + emisora gratuita: NO bloquea', () { + expect( + debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true), + isFalse, + ); + }); + + test('free + emisora premium: bloquea (propiedad de seguridad original — ' + 'un emisora: rancio de antes de una bajada de tier no puede ' + 'sonar)', () { + expect( + debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false), + isTrue, + ); + }); + + test('premium: nunca bloquea', () { + expect( + debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false), + isFalse, + ); + expect( + debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: true), + isFalse, + ); + }); + }); + + group('getChildren — item 9', () { + test('sin fuente de navegación registrada, la carpeta gratuita devuelve ' + '>= 3 items REPRODUCIBLES', () async { + final handler = crearHandler(); + + final items = await handler.getChildren( + ConstructorArbolAuto.idDestacadas, + ); + + expect(items.length, greaterThanOrEqualTo(3)); + expect(items.every((m) => m.playable == true), isTrue); + expect(items.every((m) => m.id.startsWith('emisora:')), isTrue); + }); + + test('la raíz gratuita es UNA carpeta y no paga el round trip nativo de ' + 'estadoCarpeta()', () async { + final fuenteLocal = _FuenteMusicaLocalEspia(); + registrarFuenteMusicaLocal(fuenteLocal); + final handler = crearHandler(); + + final raiz = await handler.getChildren(AudioService.browsableRootId); + + expect(raiz.map((m) => m.id), [ConstructorArbolAuto.idDestacadas]); + expect( + fuenteLocal.llamadasEstadoCarpeta, + 0, + reason: + 'estadoCarpeta() viaja por un MethodChannel que NO existe en el ' + 'motor headless; el tier gratuito no puede ver Música Local, así ' + 'que preguntarlo solo añade una vía de fallo en la raíz', + ); + }); + + test('una carpeta premium NO devuelve una fila no reproducible: devuelve ' + 'el contenido gratuito', () async { + final handler = crearHandler(); + + final items = await handler.getChildren(ConstructorArbolAuto.idTodas); + + expect(items, isNotEmpty); + expect(items.every((m) => m.playable == true), isTrue); + expect(items.every((m) => m.id != 'premium:info'), isTrue); + }); + }); + + group('playFromMediaId — item 12', () { + test('free + uuid gratuito: SUENA', () async { + final handler = crearHandler(); + final destacada = emisorasDestacadas.first; + + await handler.playFromMediaId('emisora:${destacada.uuid}'); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas, contains(destacada.url)); + }); + + test('free + uuid premium: publica error CON errorCode y errorMessage ' + 'localizado, nunca vuelve en silencio', () async { + PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en'); + final handler = crearHandler(); + + await handler.playFromMediaId('emisora:uuid-del-catalogo'); + await pumpEventQueue(); + + final estado = handler.playbackState.value; + expect(estado.processingState, AudioProcessingState.error); + expect(estado.errorCode, isNotNull); + expect( + estado.errorMessage, + lookupAppLocalizations(const Locale('en')).autoErrorEmisoraPremium, + ); + expect(guion.urlsSolicitadas, isEmpty); + }); + + test('free + pista local: bloqueada, y también publica el error', () async { + final handler = crearHandler(); + + await handler.playFromMediaId('pista:doc-id'); + await pumpEventQueue(); + + expect( + handler.playbackState.value.processingState, + AudioProcessingState.error, + ); + }); + + test('free + uuid inexistente en NINGÚN sitio: error, no silencio', + () async { + final handler = crearHandler(); + + await handler.playFromMediaId('emisora:'); + await pumpEventQueue(); + + expect( + handler.playbackState.value.processingState, + AudioProcessingState.error, + ); + }); + }); + + group('playFromSearch — items 12 y 13', () { + test('free: una consulta que casa con el set gratuito SUENA', () async { + final handler = crearHandler(); + final destacada = emisorasDestacadas.first; + + await handler.playFromSearch(destacada.nombre); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas, contains(destacada.url)); + }); + + test('free: una consulta sin resultados publica error localizado, nunca ' + 'vuelve en silencio', () async { + PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es'); + final handler = crearHandler(); + + await handler.playFromSearch('emisora que no existe en ningun sitio'); + await pumpEventQueue(); + + final estado = handler.playbackState.value; + expect(estado.processingState, AudioProcessingState.error); + expect(estado.errorCode, isNotNull); + expect(estado.errorMessage, textos().autoErrorBusquedaSinResultados); + }); + + test('consulta VACÍA ("Reproduce PluriWave") arranca algo — free', () async { + final handler = crearHandler(); + + await handler.playFromSearch(''); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas, isNotEmpty); + }); + + test('consulta VACÍA arranca algo — PREMIUM (antes fallaba incluso para ' + 'un cliente que había pagado)', () async { + SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); + final handler = crearHandler(); + + await handler.playFromSearch(' '); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas, isNotEmpty); + }); + + test('consulta vacía prefiere la ÚLTIMA escuchada', () async { + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: + '{"uuid":"uuid-ultima","nombre":"Ultima",' + '"url":"https://ultima.example/stream"}', + }); + final handler = crearHandler(); + + await handler.playFromSearch(''); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas, ['https://ultima.example/stream']); + }); + }); + + group('un rechazo NO destruye una sesion que esta sonando — hallazgo 1', () { + /// Puts the handler in the exact state the reviewer reproduces: a free + /// station tapped from the browse tree and audibly playing. + Future sonando() async { + final handler = crearHandler(); + await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); + await pumpEventQueue(); + guion.ultimoReproductor!.emitir( + PlayerState(true, ProcessingState.ready), + ); + await pumpEventQueue(); + expect( + handler.playbackState.value.playing, + isTrue, + reason: 'precondicion: la emisora esta sonando', + ); + return handler; + } + + test( + 'una busqueda por voz fallida deja el estado publicado FUERA de error y ' + 'sigue ofreciendo play/pause/stop', + () async { + final handler = await sonando(); + + // The free candidate set is only the six compiled-in stations, so + // almost any spoken station name misses. That must not cost the + // driver the whole now-playing screen. + await handler.playFromSearch('BBC'); + await pumpEventQueue(); + + final estado = handler.playbackState.value; + expect( + estado.processingState, + isNot(AudioProcessingState.error), + reason: + 'AudioService.java:601-611 maps `error` to STATE_ERROR with the ' + '`playing` flag IGNORED, so publishing it over live audio ' + 'replaces the transport row with an error the session can never ' + 'clear: playerStateStream is .distinct() (nothing more comes ' + 'from a steadily playing ExoPlayer) and _bufferedSub re-asserts ' + 'it ~2x/second through copyWith', + ); + expect( + estado.playing, + isTrue, + reason: 'el audio sigue sonando; el estado tiene que decirlo', + ); + expect( + estado.systemActions, + containsAll([ + MediaAction.play, + MediaAction.pause, + MediaAction.stop, + ]), + ); + expect( + estado.controls.map((c) => c.action), + containsAll([MediaAction.pause, MediaAction.stop]), + ); + }, + ); + + test('y el motivo del fallo SIGUE llegando al head unit', () async { + PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es'); + final handler = await sonando(); + + await handler.playFromSearch('BBC'); + await pumpEventQueue(); + + // AudioService.java:541-544 calls `setErrorMessage` from `setState` + // regardless of processingState, so the text still reaches + // PlaybackStateCompat without STATE_ERROR. + expect( + handler.playbackState.value.errorMessage, + textos().autoErrorBusquedaSinResultados, + ); + expect(handler.playbackState.value.errorCode, isNotNull); + }); + + test( + 'sin sesion viva el rechazo SI es terminal: error explicado (el caso en ' + 'que el coche no tiene nada que perder)', + () async { + final handler = crearHandler(); + + await handler.playFromSearch('BBC'); + await pumpEventQueue(); + + expect( + handler.playbackState.value.processingState, + AudioProcessingState.error, + ); + expect(handler.playbackState.value.errorMessage, isNotNull); + }, + ); + + /// CONTRACT for an action refusal published over a LIVE session + /// (`_publicarErrorAuto`'s non-terminal branch): + /// + /// 1. the code and the message are published immediately and stand for + /// [PluriWaveAudioHandler.ventanaErrorAccionAuto] (the two tests + /// above pin step 1); + /// 2. when that window elapses they are BOTH cleared, and nothing else + /// about the state moves; + /// 3. any real player transition arriving first clears them early — a + /// genuine state change supersedes a stale refusal; + /// 4. only the fields this refusal published are ever cleared, so a + /// reconnect status message that replaced them survives. + /// + /// It has to be bounded: `_bufferedSub` republishes + /// `playbackState.value.copyWith(...)` ~2x/second and `copyWith` carries + /// every omitted field forward (audio_service.dart:400-427), so + /// `AudioService.java:541-544` re-calls `setErrorMessage(code, msg)` on + /// every one of those pushes. Without a clear, one voice miss makes the + /// session advertise an error for the rest of the station's playback, + /// over audible healthy audio. + group('y el rechazo es TRANSITORIO: nada lo arrastra para siempre', () { + setUp(() { + PluriWaveAudioHandler.ventanaErrorAccionAuto = const Duration( + milliseconds: 80, + ); + }); + + tearDown(() { + PluriWaveAudioHandler.ventanaErrorAccionAuto = + PluriWaveAudioHandler.ventanaErrorAccionAutoPorDefecto; + }); + + test('al pasar la ventana, codigo y mensaje desaparecen', () async { + final handler = await sonando(); + + await handler.playFromSearch('BBC'); + await pumpEventQueue(); + expect( + handler.playbackState.value.errorCode, + isNotNull, + reason: 'precondicion: el rechazo se publico', + ); + + await Future.delayed(const Duration(milliseconds: 250)); + + final estado = handler.playbackState.value; + expect( + estado.errorCode, + isNull, + reason: + 'every later push carries the code forward through copyWith, so ' + 'the session would keep telling the head unit it is in error ' + 'while the station plays perfectly', + ); + expect(estado.errorMessage, isNull); + expect( + estado.playing, + isTrue, + reason: 'clearing the refusal must not touch the session itself', + ); + expect( + estado.processingState, + isNot(AudioProcessingState.error), + reason: 'nor its processing state', + ); + }); + + test( + 'y un cambio de estado real del reproductor los limpia antes', + () async { + final handler = await sonando(); + + await handler.playFromSearch('BBC'); + await pumpEventQueue(); + expect(handler.playbackState.value.errorMessage, isNotNull); + + // The driver pauses: a genuine transition. `manejarEstadoPlayer` + // omitted both fields, so `copyWith` carried the refusal into the + // paused state and every state after it. + handler.manejarEstadoPlayer( + PlayerState(false, ProcessingState.ready), + ); + await pumpEventQueue(); + + final estado = handler.playbackState.value; + expect(estado.errorCode, isNull); + expect(estado.errorMessage, isNull); + }, + ); + + test('y stop() limpia el codigo, no solo el mensaje', () async { + final handler = await sonando(); + + await handler.playFromSearch('BBC'); + await pumpEventQueue(); + expect(handler.playbackState.value.errorCode, isNotNull); + + await handler.stop(); + await pumpEventQueue(); + + final estado = handler.playbackState.value; + expect( + estado.errorCode, + isNull, + reason: + 'stop() cleared errorMessage but omitted errorCode, so the idle ' + 'it publishes shipped a stale ERROR_CODE_PREMIUM_ACCOUNT_' + 'REQUIRED (4) with no message to explain it', + ); + expect(estado.errorMessage, isNull); + }); + }); + }); + + group('errorCode se limpia en un cambio de fuente — item 6', () { + test('un rechazo premium seguido de una emisora gratuita NO arrastra el ' + 'codigo de error', () async { + final handler = crearHandler(); + + await handler.playFromMediaId('emisora:uuid-del-catalogo'); + await pumpEventQueue(); + expect( + handler.playbackState.value.errorCode, + isNotNull, + reason: 'precondicion: el rechazo publico un codigo', + ); + + await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); + await pumpEventQueue(); + + expect( + handler.playbackState.value.errorCode, + isNull, + reason: + '`_cambiarFuente` cleared errorMessage but omitted errorCode, and ' + 'copyWith carries an omitted field forward, so a stale code rode ' + 'along indefinitely', + ); + expect(handler.playbackState.value.errorMessage, isNull); + }); + }); + + group('getChildren(recentRootId) — reanudacion del head unit', () { + test('con una ultima emisora persistida devuelve EXACTAMENTE un item ' + 'reproducible', () async { + SharedPreferences.setMockInitialValues({ + claveUltimaEmisora: + '{"uuid":"uuid-ultima","nombre":"Ultima",' + '"url":"https://ultima.example/stream"}', + }); + final handler = crearHandler(); + + final items = await handler.getChildren(AudioService.recentRootId); + + expect( + items, + hasLength(1), + reason: + 'onGetRoot (AudioService.java:817-821) answers "recent" whenever ' + 'the head unit sends EXTRA_RECENT, which Android Auto does on ' + 'reconnect, and the platform expects exactly one resume item — ' + 'free tier used to fall through and return all six stations', + ); + expect(items.single.playable, isTrue); + expect(items.single.id, 'emisora:uuid-ultima'); + }); + + test('sin ultima emisora devuelve lista vacia y no lanza', () async { + final handler = crearHandler(); + + expect(await handler.getChildren(AudioService.recentRootId), isEmpty); + }); + + test('premium tambien obtiene su tile de reanudacion, no una lista ' + 'vacia', () async { + SharedPreferences.setMockInitialValues({ + 'compra_premium_v1': true, + claveUltimaEmisora: + '{"uuid":"uuid-premium","nombre":"Premium",' + '"url":"https://premium.example/stream"}', + }); + final handler = crearHandler(); + + final items = await handler.getChildren(AudioService.recentRootId); + + expect(items, hasLength(1)); + expect(items.single.id, 'emisora:uuid-premium'); + }); + }); + + group('la raiz gratuita esta LOCALIZADA — hallazgo 4', () { + test('en un motor headless en ingles la unica carpeta que un revisor de ' + 'Play ve NO sale en castellano', () async { + PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en'); + final handler = crearHandler(); + + final raiz = await handler.getChildren(AudioService.browsableRootId); + + expect(raiz, hasLength(1)); + expect( + raiz.single.title, + lookupAppLocalizations(const Locale('en')).autoCarpetaEscuchar, + reason: + 'raiz(premium: false) is 100% of the browse tree a Play reviewer ' + 'ever sees, on a device in any of the 13 shipped locales — the ' + 'hardcoded-Spanish car-label convention stops being defensible ' + 'once one label IS the whole free root', + ); + expect(raiz.single.title, isNot('Escuchar')); + }); + + test('en castellano sigue diciendo Escuchar', () async { + PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es'); + final handler = crearHandler(); + + final raiz = await handler.getChildren(AudioService.browsableRootId); + + expect(raiz.single.title, 'Escuchar'); + }); + }); + + group('botones de salto — item 14', () { + test('free: el salto CICLA dentro del set gratuito en vez de no hacer ' + 'nada', () async { + final handler = crearHandler(); + await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); + await pumpEventQueue(); + + await handler.skipToNext(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url); + }); + + test('free: el salto hacia atrás envuelve al final del set', () async { + final handler = crearHandler(); + await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); + await pumpEventQueue(); + + await handler.skipToPrevious(); + await pumpEventQueue(); + + expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url); + }); + + test('free: los botones se SIGUEN anunciando (un boton que funciona es ' + 'mejor UX que un hueco, y controls/systemActions se construyen en un ' + 'listener sincrono que no puede await-ear prefs)', () async { + final handler = crearHandler(); + await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}'); + await pumpEventQueue(); + // `controls`/`systemActions` are only rebuilt from a real player event, + // so the double has to emit one for this assertion to mean anything. + guion.ultimoReproductor!.emitir( + PlayerState(true, ProcessingState.ready), + ); + await pumpEventQueue(); + + final estado = handler.playbackState.value; + expect(estado.systemActions, contains(MediaAction.skipToNext)); + expect(estado.systemActions, contains(MediaAction.skipToPrevious)); + }); + }); +} + +/// Cuenta los round trips nativos que la raíz del árbol dispara. +class _FuenteMusicaLocalEspia implements FuenteMusicaLocalAuto { + int llamadasEstadoCarpeta = 0; + + @override + Future estadoCarpeta() async { + llamadasEstadoCarpeta++; + return EstadoCarpetaLocal.configurada; + } + + @override + Future> hijos(String documentId) async => const []; + + @override + Future uriContenidoDePista(String documentId) async => null; + + @override + Future> metadatosDe( + List documentIds, + ) async => const {}; +} + +/// Misma forma que el doble de `servicio_audio_transporte_test.dart`. +class _GuionReproductor { + int llamadasPlay = 0; + final urlsSolicitadas = []; + + /// The handler rebuilds its player on every source change, so a test that + /// needs to drive player events has to reach the LATEST instance. + _ReproductorFalso? ultimoReproductor; +} + +class _ReproductorFalso extends AudioPlayer { + _ReproductorFalso( + this._guion, + AudioPipeline pipeline, + AudioLoadConfiguration carga, + ) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) { + _guion.ultimoReproductor = this; + } + + final _GuionReproductor _guion; + final _estados = StreamController.broadcast(); + + void emitir(PlayerState estado) => _estados.add(estado); + + @override + Stream get playerStateStream => _estados.stream; + + @override + Future setUrl( + String url, { + Map? headers, + Duration? initialPosition, + bool preload = true, + dynamic tag, + }) async { + _guion.urlsSolicitadas.add(url); + return null; + } + + @override + Future play() async { + _guion.llamadasPlay++; + } + + @override + Future pause() async {} + + @override + Future stop() async {} + + @override + Future setVolume(double volume) async {} + + @override + Future dispose() async { + await _estados.close(); + } +} diff --git a/test/servicios/servicio_audio_eq_estado_unico_test.dart b/test/servicios/servicio_audio_eq_estado_unico_test.dart index 14be0e9..a85a95f 100644 --- a/test/servicios/servicio_audio_eq_estado_unico_test.dart +++ b/test/servicios/servicio_audio_eq_estado_unico_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState; import 'package:pluriwave/servicios/servicio_audio.dart'; +import '../helpers/handlers_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 @@ -28,6 +30,8 @@ import 'package:pluriwave/servicios/servicio_audio.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + final crearHandler = registrarHandlersLiberables(); + 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); @@ -46,7 +50,7 @@ void main() { group('registrarHandler (A — seeding)', () { test('consults the injected read port exactly once and seeds the handler ' 'with the persisted value', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); var lecturas = 0; registrarHandler( @@ -68,7 +72,7 @@ void main() { test('a read failure leaves the handler on the safe default instead of ' 'propagating', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler( handler, @@ -81,7 +85,7 @@ void main() { test('without a read port the handler is left untouched (widget tests, ' 'fakes)', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); await handler.setEcualizadorActivo(false); registrarHandler(handler); @@ -106,7 +110,7 @@ void main() { 'starts from the persisted value, not from a hardcoded default', () async { // One engine does the read `registrarHandler` performs in main.dart. - final primero = PluriWaveAudioHandler(); + final primero = crearHandler(); // Pin the module cache to the OPPOSITE value first. Without this the // test passes for the wrong reason: whatever ran before may already @@ -123,7 +127,7 @@ void main() { // Now the construction window: a handler built by `AudioService.init`'s // builder, with no port of its own yet. - final segundo = PluriWaveAudioHandler(); + final segundo = crearHandler(); expect( segundo.ecualizadorActivo, @@ -136,12 +140,12 @@ void main() { test('the cache follows what the handler itself writes, in both ' 'directions', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler(handler); await handler.setEcualizadorActivo(false); expect( - PluriWaveAudioHandler().ecualizadorActivo, + crearHandler().ecualizadorActivo, isFalse, reason: 'the write side of the cache: a toggle must be visible to the ' @@ -149,14 +153,14 @@ void main() { ); await handler.setEcualizadorActivo(true); - expect(PluriWaveAudioHandler().ecualizadorActivo, isTrue); + expect(crearHandler().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 handler = crearHandler(); final escrituras = []; registrarHandler( @@ -177,7 +181,7 @@ void main() { }); test('seeding from disk does NOT write back to disk', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); final escrituras = []; registrarHandler( @@ -192,7 +196,7 @@ void main() { }); test('a failing write port never breaks the toggle', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler( handler, @@ -248,7 +252,7 @@ void main() { group('customAction dispatch (C — zero coverage before this)', () { test('the accionEqToggle literal routes through decidirToggleEq', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler(handler); await handler.setEcualizadorActivo(true); @@ -261,7 +265,7 @@ void main() { test('a car toggle persists through the same write port as a phone ' 'toggle', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); final escrituras = []; registrarHandler( handler, @@ -282,7 +286,7 @@ void main() { }); test('an unknown custom action is a silent no-op', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler(handler); final antes = handler.ecualizadorActivo; @@ -380,7 +384,7 @@ void main() { test('the first non-idle event re-asserts the native effect exactly ' 'once, and staying active never re-asserts again', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler(handler); handler.simularEcualizadorDisponible(true); @@ -410,7 +414,7 @@ void main() { test('going idle re-arms the edge, so stop + play re-asserts again — ' 'this is the `_reproductorActivo = proc != idle` line', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler(handler); handler.simularEcualizadorDisponible(true); @@ -435,7 +439,7 @@ void main() { }); test('with no native effect attached nothing is ever re-asserted', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler(handler); // `_eqDisponible` is false off-device, which is also the real // "device has no Equalizer effect" case. @@ -451,7 +455,7 @@ void main() { 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(); + final handler = crearHandler(); registrarHandler(handler); handler.playbackState.add( handler.playbackState.value.copyWith( diff --git a/test/servicios/servicio_audio_gating_test.dart b/test/servicios/servicio_audio_gating_test.dart index fa6debd..b2a0f7c 100644 --- a/test/servicios/servicio_audio_gating_test.dart +++ b/test/servicios/servicio_audio_gating_test.dart @@ -6,6 +6,8 @@ import 'package:pluriwave/servicios/navegacion_auto.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../helpers/handlers_audio.dart'; + /// Android Auto play-path backstop (design.md ADR-4, android-auto-media /// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station /// Playback Unaffected By Free Tier"): `playFromMediaId`, `playFromSearch`, @@ -20,12 +22,30 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('free tier: bloquea cualquier cambio de emisora/salto', () { - expect(debeBloquearCambioDeEmisora(premium: false), isTrue); + final crearHandler = registrarHandlersLiberables(); + + // fix/auto-quality-guidelines item 11: the gate is CONTENT-scoped now. + // Blocking every switch for the free tier is what made the car surface + // useless for the only tier a Play reviewer can be in. + test('free tier: bloquea una emisora del catálogo premium', () { + expect( + debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false), + isTrue, + ); + }); + + test('free tier: NO bloquea una emisora del set gratuito', () { + expect( + debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true), + isFalse, + ); }); test('premium: nunca bloquea', () { - expect(debeBloquearCambioDeEmisora(premium: true), isFalse); + expect( + debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false), + isFalse, + ); }); /// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la @@ -51,7 +71,7 @@ void main() { test('registrarHandler conecta la invalidación al handler: una llamada ' 'notifica la raíz Y Música Local', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); registrarHandler(handler); final raiz = >[]; @@ -86,13 +106,13 @@ void main() { 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(); + final handler = crearHandler(); expect(handler.subscribeToChildren('musica_local').hasValue, isFalse); }); test('memoiza por id: dos llamadas devuelven el MISMO stream', () { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); expect( identical( @@ -112,7 +132,7 @@ void main() { test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito', () async { - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); final stream = handler.subscribeToChildren('musica_local'); final recibidos = >[]; final sub = stream.listen(recibidos.add); @@ -143,7 +163,7 @@ void main() { Future> idsRaizCon(EstadoCarpetaLocal estado) async { registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado)); - final handler = PluriWaveAudioHandler(); + final handler = crearHandler(); final items = await handler.getChildren(AudioService.browsableRootId); return items.map((i) => i.id).toList(); } diff --git a/test/servicios/servicio_audio_transporte_test.dart b/test/servicios/servicio_audio_transporte_test.dart new file mode 100644 index 0000000..a6fd707 --- /dev/null +++ b/test/servicios/servicio_audio_transporte_test.dart @@ -0,0 +1,736 @@ +import 'dart:async'; +import 'dart:ui' show Locale; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +import '../helpers/handlers_audio.dart'; + +/// Android for Cars App Quality Guidelines — transport state machine. +/// +/// Google Play returned "Approved with Issues" against version code 157: +/// «clicking on stop button makes the entire app useless». These tests drive +/// the REAL [PluriWaveAudioHandler] against a scripted [AudioPlayer] double +/// (installed through [PluriWaveAudioHandler.fabricaReproductorPrueba]) so +/// the published `playbackState` sequence — the only thing Android Auto ever +/// sees — can be asserted end to end. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final crearHandler = registrarHandlersLiberables(); + + late _GuionReproductor guion; + + setUp(() { + guion = _GuionReproductor(); + PluriWaveAudioHandler.fabricaReproductorPrueba = + (pipeline, carga) => _ReproductorFalso(guion, pipeline, carga); + }); + + tearDown(() { + PluriWaveAudioHandler.fabricaReproductorPrueba = null; + }); + + group('stop() durante cambios de fuente en vuelo (P0 — botón Stop)', () { + test( + 'dos cambios encolados y un stop: el ultimo estado publicado es idle, ' + 'nunca vuelve a loading', + () async { + final handler = crearHandler(); + final publicados = []; + final sub = handler.playbackState.listen( + (estado) => publicados.add(estado.processingState), + ); + + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) + .catchError((_) {}), + ); + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://b', title: 'B')) + .catchError((_) {}), + ); + + await handler.stop(); + // Drain both queued source changes: they must discover the stale + // revision WITHOUT ever publishing again. + await pumpEventQueue(); + await sub.cancel(); + + expect( + publicados.last, + AudioProcessingState.idle, + reason: + 'a stale queued source change must never rewrite `loading` over ' + 'the `idle` that stop() published — that is what leaves Android ' + 'Auto spinning forever on a dead session. Secuencia: $publicados', + ); + }, + ); + }); + + group('pause() durante un cambio de fuente en vuelo (P0 — botón Pausa)', () { + test('la emisora NO arranca: _player.play() nunca se invoca', () async { + guion.completerSetUrl = Completer(); + final handler = crearHandler(); + + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) + .catchError((_) {}), + ); + await pumpEventQueue(); + expect( + guion.llamadasSetUrl, + 1, + reason: 'precondicion: el cambio de fuente esta en vuelo', + ); + + await handler.pause(); + guion.completerSetUrl!.complete(null); + await pumpEventQueue(); + + expect( + guion.llamadasPlay, + 0, + reason: + 'the user pressed Pause while the station was loading — the load ' + 'finishing afterwards must never start playback behind their back', + ); + }); + + test( + 'y el coche no se queda en el spinner: el estado publicado sale de ' + 'loading', + () async { + guion.completerSetUrl = Completer(); + final handler = crearHandler(); + + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) + .catchError((_) {}), + ); + await pumpEventQueue(); + + await handler.pause(); + guion.completerSetUrl!.complete(null); + await pumpEventQueue(); + + expect( + handler.playbackState.value.processingState, + isNot(AudioProcessingState.loading), + reason: + 'withholding the play() must not leave the car showing the ' + 'spinner the load started with — nothing else will publish, ' + 'because the player never transitions', + ); + expect(handler.playbackState.value.playing, isFalse); + }, + ); + }); + + group('Suelo de estado terminal (P0 — nunca un spinner eterno)', () { + setUp(() { + PluriWaveAudioHandler.vigilanciaTransitoria = const Duration( + milliseconds: 60, + ); + }); + + tearDown(() { + PluriWaveAudioHandler.vigilanciaTransitoria = + PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto; + }); + + test( + 'un buffering publicado SIN carga viva cae a un estado terminal dentro ' + 'de la ventana', + () async { + final handler = crearHandler(); + + handler.manejarEstadoPlayer( + PlayerState(false, ProcessingState.buffering), + ); + expect( + handler.playbackState.value.processingState, + AudioProcessingState.buffering, + reason: 'precondicion: el coche esta viendo el spinner', + ); + + await Future.delayed(const Duration(milliseconds: 250)); + + expect( + handler.playbackState.value.processingState, + isIn(const [ + AudioProcessingState.ready, + AudioProcessingState.idle, + AudioProcessingState.error, + ]), + reason: + 'the only exits from loading/buffering are player events that ' + '.distinct() can swallow — without a floor the car spins ' + 'forever over a session nobody is driving', + ); + }, + ); + + test('una carga LEGITIMA en vuelo no se interrumpe', () async { + guion.completerSetUrl = Completer(); + final handler = crearHandler(); + + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) + .catchError((_) {}), + ); + await pumpEventQueue(); + + await Future.delayed(const Duration(milliseconds: 250)); + + expect( + handler.playbackState.value.processingState, + AudioProcessingState.loading, + reason: + 'the watchdog is a floor for a STALLED state machine, not a cap ' + 'on how long a slow station may take to open', + ); + }); + + test( + 'un mount estancado NO cae en un idle mudo: publica un motivo legible ' + '(hallazgo 3)', + () async { + final handler = crearHandler(); + + // Exact shape of a stalled icecast mount: the socket opens, `setUrl` + // returns inside the timeout (so no TimeoutException and no + // PlayerException — `_esErrorDeRed` never fires and the reconnect + // machine is never entered), and then no data ever arrives. State + // sits at buffering with `_cambiosEnVuelo` already back to 0. + handler.manejarEstadoPlayer( + PlayerState(false, ProcessingState.buffering), + ); + + await Future.delayed(const Duration(milliseconds: 250)); + + final estado = handler.playbackState.value; + expect( + estado.processingState, + AudioProcessingState.error, + reason: + 'a bare `idle` routes straight into `AudioService._stop()` ' + '(audio_service.dart:1131-1135), so the driver got silence, a ' + 'dead session and no explanation. `error` keeps the session ' + 'alive and carries a message', + ); + expect( + estado.errorMessage, + isNotNull, + reason: + 'the floor must say something the driver can read and act on', + ); + }, + ); + + test( + 'un rebuffer normal a mitad de emision NO se convierte en error ' + '(regresion: el suelo miraba solo processingState)', + () async { + final handler = crearHandler(); + + // Established playback: the stream delivered audio and ExoPlayer + // reached `ready` while playing. This is what separates a re-buffer + // from a mount that never produced a byte. + handler.manejarEstadoPlayer(PlayerState(true, ProcessingState.ready)); + // Ordinary mid-stream re-buffer: `bufferForPlaybackAfterRebuffer` is + // 5 s, so a tunnel or an LTE handover routinely holds this state for + // longer than the floor's window. ExoPlayer raised no error, so + // `_intentarReconexion` never ran and `reintentoPendiente` is false; + // `_cambiosEnVuelo` is already 0 because the non-blocking + // `_iniciarPlaySinBloquear` returned long ago. + handler.manejarEstadoPlayer( + PlayerState(true, ProcessingState.buffering), + ); + expect( + handler.playbackState.value.processingState, + AudioProcessingState.buffering, + reason: 'precondicion: el reproductor esta rellenando el buffer', + ); + + await Future.delayed(const Duration(milliseconds: 250)); + + final estado = handler.playbackState.value; + expect( + estado.processingState, + isNot(AudioProcessingState.error), + reason: + 'a self-recovering re-buffer over live audio must never be ' + 'converted into a hard STATE_ERROR: the driver is in a tunnel, ' + 'not on a dead mount, and `_errorTerminal` latches so nothing ' + 'the player emits afterwards could undo it', + ); + expect( + estado.playing, + isTrue, + reason: + 'the player still owns the timeline — publishing `playing: ' + 'false` over it desynchronises the head unit transport row', + ); + expect( + estado.errorMessage, + isNull, + reason: 'nothing failed, so there is nothing to tell the driver', + ); + }, + ); + + test( + 'un mount que NUNCA entrego audio sigue cayendo al suelo aunque el ' + 'reproductor diga playing: true', + () async { + final handler = crearHandler(); + + // `just_audio`'s `playing` is the play-when-ready intent flag: it + // flips to true the moment `play()` is called, whether or not a + // single byte ever arrives. So the stalled icecast mount the floor + // exists for reports `playing: true` too — `playing` alone can never + // be the discriminator. + handler.manejarEstadoPlayer( + PlayerState(true, ProcessingState.buffering), + ); + + await Future.delayed(const Duration(milliseconds: 250)); + + final estado = handler.playbackState.value; + expect( + estado.processingState, + AudioProcessingState.error, + reason: + 'no `ready` was ever reached on this run, so nothing is ' + 're-buffering: the driver is staring at a spinner and the floor ' + 'is the only exit', + ); + expect(estado.errorMessage, isNotNull); + }, + ); + + test( + 'la ventana del suelo respeta el presupuesto de diez segundos hasta el ' + 'primer mensaje', + () { + expect( + PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto, + lessThanOrEqualTo(const Duration(seconds: 10)), + reason: + 'the floor is the ONLY exit for a stalled mount, so its window ' + 'IS the time-to-first-message for that failure mode; twenty ' + "seconds was double the code's own cited budget", + ); + }, + ); + }); + + group('Error terminal de reproduccion: la sesion sobrevive (hallazgo 2)', () { + test( + 'el ultimo estado publicado es error CON mensaje, y no lo sigue un idle', + () async { + // A non-network failure: `_esErrorDeRed` is false, so this goes + // straight down the terminal path instead of the reconnect machine. + guion.errorSetUrl = Exception('mount muerto'); + final handler = crearHandler(); + final publicados = []; + final sub = handler.playbackState.listen( + (estado) => publicados.add(estado.processingState), + ); + + await handler + .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) + .catchError((_) {}); + await pumpEventQueue(); + + // What `_player.stop()` really does: just_audio.dart:1016-1025 + // switches to the idle dummy platform, so `playerStateStream` emits a + // distinct (playing:false, idle). The double cannot do that on its + // own, so the test drives the exact event the real player would. + guion.ultimoReproductor!.emitir( + PlayerState(false, ProcessingState.idle), + ); + await pumpEventQueue(); + await sub.cancel(); + + final estado = handler.playbackState.value; + expect( + estado.processingState, + AudioProcessingState.error, + reason: + 'forwarding that idle makes audio_service call ' + 'AudioService._stop() -> deactivateMediaSession() + stopSelf(), ' + 'so PluriWave dropped off the Android Auto playback surface a ' + 'single event-loop turn after showing the error', + ); + expect(estado.errorMessage, isNotNull); + expect( + publicados.last, + isNot(AudioProcessingState.idle), + reason: 'secuencia publicada: $publicados', + ); + expect( + handler.mediaItem.value, + isNotNull, + reason: + 'Android Auto drops a session with no metadata to show, so ' + 'nulling the media item on the error path makes the app vanish ' + 'from the car pane even when the state itself survives — the ' + 'station that failed has to keep its name on screen', + ); + }, + ); + + test( + 'un stop() del usuario DESPUES del error sigue produciendo un idle real ' + '(la sesion tiene que poder morir cuando el conductor lo pide)', + () async { + guion.errorSetUrl = Exception('mount muerto'); + final handler = crearHandler(); + + await handler + .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) + .catchError((_) {}); + await pumpEventQueue(); + expect( + handler.playbackState.value.processingState, + AudioProcessingState.error, + reason: 'precondicion', + ); + + await handler.stop(); + await pumpEventQueue(); + + expect( + handler.playbackState.value.processingState, + AudioProcessingState.idle, + reason: + 'suppressing the error-driven idle must NEVER make the Stop ' + 'button unkillable — that is the original citation', + ); + }, + ); + }); + + group('Presupuesto de tiempo hasta el primer mensaje (<= 10 s)', () { + tearDown(() { + PluriWaveAudioHandler.timeoutCambioFuente = + PluriWaveAudioHandler.timeoutCambioFuentePorDefecto; + }); + + test('el timeout por defecto deja el primer mensaje dentro de 10 s', () { + expect( + PluriWaveAudioHandler.timeoutCambioFuentePorDefecto, + lessThanOrEqualTo(const Duration(seconds: 10)), + reason: + 'Android for Cars App Quality Guidelines allow ten seconds before ' + 'the driver must be told something; the first attempt alone used ' + 'to burn twelve', + ); + }); + + test( + 'una fuente que nunca responde publica un mensaje visible al agotar el ' + 'primer intento', + () async { + PluriWaveAudioHandler.timeoutCambioFuente = const Duration( + milliseconds: 100, + ); + guion.setUrlCuelga = true; + final handler = crearHandler(); + + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://muerta', title: 'X')) + .catchError((_) {}), + ); + await Future.delayed(const Duration(milliseconds: 300)); + + expect( + handler.playbackState.value.errorMessage, + isNotNull, + reason: + 'the backoff used to publish `buffering` with errorMessage: ' + 'null, so the car showed a silent spinner for the whole ~100 s ' + 'reconnect window', + ); + expect( + handler.playbackState.value.processingState, + AudioProcessingState.buffering, + reason: 'still retrying — the message rides ON TOP of the retry', + ); + }, + ); + + test('los reintentos siguen DETRAS del mensaje', () async { + PluriWaveAudioHandler.timeoutCambioFuente = const Duration( + milliseconds: 100, + ); + guion.setUrlCuelga = true; + final handler = crearHandler(); + + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://muerta', title: 'X')) + .catchError((_) {}), + ); + // First backoff delay is 1 s (ControladorReconexion default). + await Future.delayed(const Duration(milliseconds: 1300)); + + expect( + guion.llamadasSetUrl, + greaterThanOrEqualTo(2), + reason: 'the reconnect machine keeps working after the first message', + ); + expect( + handler.playbackState.value.errorMessage, + isNotNull, + reason: + 'and the message survives the retry: re-entering `_cambiarFuente` ' + 'must not blank the car screen back to a silent spinner', + ); + }); + }); + + /// A handler nobody released goes on running: its terminal-state floor + /// timer, its `ControladorReconexion` backoff (1/2/4/8/16 s, which easily + /// outlives the test that armed it) and whatever is still queued on + /// `_colaCambioFuente`. When one of those finally performs a source change + /// it calls `_crearPlayer()`, which reads the CURRENT static + /// `fabricaReproductorPrueba` — so it builds a double bound to a LATER + /// test's script and increments that test's counters for work it never + /// asked for. A suite that passes under those conditions passes by luck. + group('Liberacion del handler: nada sobrevive al test que lo creo', () { + tearDown(() { + PluriWaveAudioHandler.timeoutCambioFuente = + PluriWaveAudioHandler.timeoutCambioFuentePorDefecto; + }); + + test( + 'un handler liberado NO vuelve a construir un reproductor contra la ' + 'fabrica del test siguiente', + () async { + PluriWaveAudioHandler.timeoutCambioFuente = const Duration( + milliseconds: 60, + ); + guion.setUrlCuelga = true; + final handler = crearHandler(); + + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://muerta', title: 'X')) + .catchError((_) {}), + ); + // Long enough for the source-change timeout to fire and the reconnect + // machine to arm its first backoff retry (1 s). + await Future.delayed(const Duration(milliseconds: 200)); + expect( + guion.llamadasSetUrl, + 1, + reason: 'precondicion: hay un reintento armado detras', + ); + + await handler.liberar(); + + // Exactly what the framework does between tests: a brand-new script + // and a factory bound to it. Nothing from the previous test may + // reach this. + final guionSiguiente = _GuionReproductor(); + PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) => + _ReproductorFalso(guionSiguiente, pipeline, carga); + + await Future.delayed(const Duration(milliseconds: 1400)); + + expect( + guionSiguiente.llamadasSetUrl, + 0, + reason: + 'the leaked backoff retry re-enters `_cambiarFuente`, which ' + 'calls `_crearPlayer()` and therefore reads whatever factory is ' + 'installed NOW — attributing a dead handler s work to the test ' + 'that happens to be running', + ); + expect( + guionSiguiente.ultimoReproductor, + isNull, + reason: 'no player at all may be built against the new script', + ); + }, + ); + + test('liberar() es idempotente', () async { + final handler = crearHandler(); + + await handler.liberar(); + await handler.liberar(); + }); + }); + + group('Idioma de la superficie del coche (motor sin Activity)', () { + tearDown(() { + PluriWaveAudioHandler.lectorLocalePlataforma = + PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto; + }); + + /// Drives a NON-network failure through the real source-change path so the + /// terminal error message published to the car can be read back. + Future mensajeDeError(PluriWaveAudioHandler handler) async { + guion.errorSetUrl = Exception('boom'); + await handler + .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) + .catchError((_) {}); + await pumpEventQueue(); + return handler.playbackState.value.errorMessage; + } + + test( + 'sin configurarLocalizaciones, los mensajes salen en el locale de la ' + 'plataforma, no en es', + () async { + PluriWaveAudioHandler.lectorLocalePlataforma = () => + const Locale('en'); + final handler = crearHandler(); + + final mensaje = await mensajeDeError(handler); + + expect( + mensaje, + lookupAppLocalizations(const Locale('en')).audioErrorUnexpectedPlayback, + reason: + '`configurarLocalizaciones` only ever runs from ' + '`mini_reproductor.dart` didChangeDependencies. The headless ' + 'Android Auto engine has no Activity and no widget tree, so it ' + 'never ran there and every car message came out in Spanish', + ); + expect( + mensaje, + isNot( + lookupAppLocalizations( + const Locale('es'), + ).audioErrorUnexpectedPlayback, + ), + ); + }, + ); + + test('un locale de plataforma no soportado conserva el respaldo es', () async { + PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('sw'); + final handler = crearHandler(); + + expect( + await mensajeDeError(handler), + lookupAppLocalizations(const Locale('es')).audioErrorUnexpectedPlayback, + reason: 'the existing fallback must survive an unresolvable locale', + ); + }); + + test('configurarLocalizaciones sigue teniendo prioridad', () async { + PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en'); + final handler = crearHandler(); + handler.configurarLocalizaciones( + lookupAppLocalizations(const Locale('fr')), + ); + + expect( + await mensajeDeError(handler), + lookupAppLocalizations(const Locale('fr')).audioErrorUnexpectedPlayback, + reason: 'the phone UI still owns the locale once a widget tree exists', + ); + }); + }); +} + +/// Shared script/observation record for every [_ReproductorFalso] the handler +/// builds (it rebuilds its player on every source change, so counters cannot +/// live on the instance). +class _GuionReproductor { + int llamadasPlay = 0; + int llamadasSetUrl = 0; + final urlsSolicitadas = []; + + /// When set, `setUrl` completes with this error instead of succeeding. + Object? errorSetUrl; + + /// When true, `setUrl` never completes (simulates a dead stream that only + /// the source-change timeout can end). + bool setUrlCuelga = false; + + /// When set, `setUrl` returns this completer's future, so a test can hold a + /// source change mid-flight and release it after acting on the handler. + Completer? completerSetUrl; + + /// The handler rebuilds its player on every source change, so a test that + /// needs to drive a player event has to reach the LATEST instance. + _ReproductorFalso? ultimoReproductor; +} + +/// A [AudioPlayer] whose platform-touching methods are replaced by the script +/// above. Everything else (the rx subjects the constructor wires up) is the +/// real thing, so the handler's stream plumbing is exercised unchanged. +class _ReproductorFalso extends AudioPlayer { + _ReproductorFalso( + this._guion, + AudioPipeline pipeline, + AudioLoadConfiguration carga, + ) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) { + _guion.ultimoReproductor = this; + } + + final _GuionReproductor _guion; + final _estados = StreamController.broadcast(); + + /// Drives the exact `playerStateStream` event the real player would emit. + void emitir(PlayerState estado) { + if (!_estados.isClosed) _estados.add(estado); + } + + @override + Stream get playerStateStream => _estados.stream; + + @override + Future setUrl( + String url, { + Map? headers, + Duration? initialPosition, + bool preload = true, + dynamic tag, + }) { + _guion.llamadasSetUrl++; + _guion.urlsSolicitadas.add(url); + if (_guion.setUrlCuelga) return Completer().future; + final pendiente = _guion.completerSetUrl; + if (pendiente != null) return pendiente.future; + final error = _guion.errorSetUrl; + if (error != null) return Future.error(error); + return Future.value(null); + } + + @override + Future play() async { + _guion.llamadasPlay++; + } + + @override + Future pause() async {} + + @override + Future stop() async {} + + @override + Future setVolume(double volume) async {} + + @override + Future dispose() async { + await _estados.close(); + } +}