From f01c0911f72fec5c1288d14b758eab01827426c5 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 7 Aug 2026 11:38:27 +0200 Subject: [PATCH] fix(auto): guard shipped resources, walk favourite groups when skipping History review requested by the owner: when and why did the Android Auto UI stop working. ANSWER: 31 July, commit 2540556, "give the equalizer actions distinct, state-aware icons". 9eff760 (31-07) androidIcon: 'drawable/ic_stat_pluriwave' -> in the APK 2540556 (31-07) androidIcon: 'drawable/ic_auto_eq_on' -> NEVER in it That commit swapped a drawable that shipped for two that the stale CI resource cache never included. From that moment getResourceId returned 0, PlaybackStateCompat.CustomAction.Builder threw, and the throw aborted AudioService.setState before the session was published -- so every Android Auto symptom chased since is one line of that commit. The bitter part is that 2540556 was itself a fix for a report about two identical icons. Three changes. 1. CI guard. The build now unzips the release APK and fails if a drawable resolved by NAME at runtime is missing. Resolution by name cannot fail at compile time -- it fails in the car, silently, with id 0. This class of bug shipped undetected for a week; it cannot ship again. 2. Skipping stations now walks the favourites GROUP first, as requested: group -> all favourites -> my stations -> catalogue. Two deliberate exclusions, both tested: `sinAsignarId` is the ABSENCE of a group, not a group, so those walk all favourites; and a one-member group falls through too, or both buttons would be dead ends. The group is read from the FAVOURITE record, never from the playing station -- that one is rebuilt by emisoraDesdeMediaItem, which carries no group id and would always report "unfiled". 3. Diagnostics on the station skip. It was reported as doing nothing for radio, and every early return in that method is silent: an empty list and a single-entry list look identical from outside. The log now names which one fired, so the next capture answers it instead of another hypothesis. Tests: 1161 -> 1165. --- .gitea/workflows/build.yml | 30 +++++++++ lib/servicios/navegacion_auto.dart | 53 +++++++++++---- lib/servicios/servicio_audio.dart | 9 +++ test/servicios/auto_salto_emisora_test.dart | 73 +++++++++++++++++++++ 4 files changed, 153 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 438372c..421ba7e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -134,6 +134,36 @@ jobs: - name: Build APK release run: flutter build apk --release + # Guardian de recursos: el APK debe contener los drawables que el codigo + # resuelve POR NOMBRE en runtime (getResources().getIdentifier). + # + # Un nombre que no resuelve devuelve id 0, y eso no falla la + # compilacion: falla en el coche. Concretamente + # PlaybackStateCompat.CustomAction.Builder lanza con icono 0, ese throw + # aborta AudioService.setState antes de activar la sesion de medios, y + # Android Auto se queda con la interfaz congelada. Paso exactamente eso + # entre el 31-07 (commit 2540556) y el 07-08 sin que nada lo detectara. + # + # Anadir un drawable nuevo referenciado por nombre => anadirlo aqui. + - name: Verificar recursos criticos en el APK + run: | + APK=build/app/outputs/flutter-apk/app-release.apk + FALTAN=0 + for RECURSO in ic_auto_eq_on ic_auto_eq_off ic_stat_pluriwave; do + if unzip -l "$APK" | grep -q "res/drawable/$RECURSO.xml"; then + echo "OK res/drawable/$RECURSO.xml" + else + echo "FALTA res/drawable/$RECURSO.xml" + FALTAN=1 + fi + done + if [ "$FALTAN" -ne 0 ]; then + echo "" + echo "Hay drawables resueltos por nombre que NO estan en el APK." + echo "En runtime resolveran a id 0 y tumbaran la sesion de medios." + exit 1 + fi + - name: Build AAB release run: flutter build appbundle --release diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 9f14fb8..300128f 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -936,15 +936,26 @@ Future reproducirPorMediaId( await reproducir(item); } -/// Which list previous/next should walk for [actual]: the NARROWEST list the -/// station actually belongs to, favourites first, then my stations, then the -/// full catalogue. +/// Which list previous/next should walk for [actual]: the NARROWEST context +/// the station belongs to. /// -/// Narrowest-first is the point. "Next station" while playing a favourite -/// should land on the next favourite, not on entry 4,318 of a 50,000-station -/// catalogue that happens to sit beside it alphabetically. Falling through to -/// [todas] only when the station is in neither curated list keeps the button -/// working for a station reached by search. +/// Tightest first: +/// 1. its FAVOURITES GROUP, when it is a favourite filed under a real group, +/// 2. all favourites, +/// 3. my stations, +/// 4. the full catalogue. +/// +/// The group tier is what the owner asked for: driving with a themed group, +/// "next" should stay inside that group rather than wander across every +/// favourite. And "next" from a favourite must never land on entry 4,318 of a +/// 50,000-station catalogue that happens to sit beside it alphabetically. +/// Falling through to [todas] only when the station is in neither curated +/// list keeps the button alive for a station reached by search. +/// +/// [GrupoFavoritos.sinAsignarId] is deliberately NOT treated as a group: it +/// is the ABSENCE of one, so those stations walk all favourites instead of a +/// bucket that only means "unfiled". A group with a single member also falls +/// through to all favourites — otherwise both buttons would be dead ends. /// /// Returns an empty list when [actual] is in none of them, which /// [emisoraVecina] turns into "do nothing". @@ -954,10 +965,28 @@ List listaParaSaltoEmisora({ required List misEmisoras, required List todas, }) { - bool contiene(List lista) => lista.any((e) => e.uuid == actual.uuid); - if (contiene(favoritos)) return favoritos; - if (contiene(misEmisoras)) return misEmisoras; - if (contiene(todas)) return todas; + Emisora? enLista(List lista) { + for (final e in lista) { + if (e.uuid == actual.uuid) return e; + } + return null; + } + + // The FAVOURITE record is the authority on the group, never `actual`: the + // playing station is rebuilt from a MediaItem by `emisoraDesdeMediaItem`, + // which carries no group id and would always report "sin asignar". + final favorita = enLista(favoritos); + if (favorita != null) { + final grupo = favorita.grupoFavoritosId; + if (grupo != GrupoFavoritos.sinAsignarId) { + final delGrupo = + favoritos.where((e) => e.grupoFavoritosId == grupo).toList(); + if (delGrupo.length > 1) return delGrupo; + } + return favoritos; + } + if (enLista(misEmisoras) != null) return misEmisoras; + if (enLista(todas) != null) return todas; return const []; } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 87be70f..d760a45 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -1544,6 +1544,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler todas: await fuente.todas(), ); 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 -- + // an empty list (the station matched none of the three) and a station + // that is in a list of one are indistinguishable from outside. + debugPrint( + '[PluriWave][ServicioAudio] salto emisora atras=$haciaAtras ' + 'actual=${actual.nombre} uuid=${actual.uuid} ' + 'lista=${lista.length} destino=${destino?.nombre ?? "NINGUNO"}', + ); if (destino == null) return; await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos)); } catch (e) { diff --git a/test/servicios/auto_salto_emisora_test.dart b/test/servicios/auto_salto_emisora_test.dart index cb5cb0c..6bb0bd7 100644 --- a/test/servicios/auto_salto_emisora_test.dart +++ b/test/servicios/auto_salto_emisora_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart'; /// Requested: the Android Auto playback screen must offer previous/next for @@ -99,4 +100,76 @@ void main() { ); }); }); + + group('navegación por GRUPO de favoritos (pedido por el dueño)', () { + Emisora favorita(String uuid, String grupo) => Emisora( + uuid: uuid, + nombre: uuid, + url: 'https://example.com/$uuid', + grupoFavoritosId: grupo, + ); + + final rock1 = favorita('rock1', 'g-rock'); + final rock2 = favorita('rock2', 'g-rock'); + final jazz1 = favorita('jazz1', 'g-jazz'); + final suelta = favorita('suelta', GrupoFavoritos.sinAsignarId); + + test('sonando una favorita de un grupo, se recorre SOLO ese grupo', () { + expect( + listaParaSaltoEmisora( + actual: rock1, + favoritos: [rock1, jazz1, rock2, suelta], + misEmisoras: const [], + todas: [rock1, jazz1, rock2, suelta], + ), + [rock1, rock2], + ); + }); + + test('el grupo se lee del registro de FAVORITOS, no de lo que suena: la ' + 'emisora reconstruida desde el MediaItem no lleva grupo', () { + // emisoraDesdeMediaItem no puede saber el grupo -> llega "sin asignar". + final reconstruida = Emisora( + uuid: 'rock1', + nombre: 'Rock 1', + url: 'https://example.com/rock1', + ); + expect(reconstruida.grupoFavoritosId, GrupoFavoritos.sinAsignarId); + expect( + listaParaSaltoEmisora( + actual: reconstruida, + favoritos: [rock1, jazz1, rock2], + misEmisoras: const [], + todas: const [], + ), + [rock1, rock2], + reason: 'si se leyera de `actual` caeríamos a todos los favoritos', + ); + }); + + test('"sin asignar" NO es un grupo: recorre todos los favoritos', () { + expect( + listaParaSaltoEmisora( + actual: suelta, + favoritos: [rock1, suelta, jazz1], + misEmisoras: const [], + todas: const [], + ), + [rock1, suelta, jazz1], + ); + }); + + test('un grupo de UNA sola emisora cae a todos los favoritos, para no ' + 'dejar los dos botones muertos', () { + expect( + listaParaSaltoEmisora( + actual: jazz1, + favoritos: [rock1, rock2, jazz1], + misEmisoras: const [], + todas: const [], + ), + [rock1, rock2, jazz1], + ); + }); + }); }