From a6cdf0e72c2fdd61b2d0073b162cda645f85b786 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 6 Aug 2026 01:28:42 +0200 Subject: [PATCH] fix(auto): advertise the transport actions Android for Cars requires Reported: on the Android Auto playback screen the play/pause button stays on PLAY while audio is audibly playing, and "it used to work, in the latest versions it doesn't". Previous rounds looked for a regression in this repo's audio commits and found none: every playbackState.add site publishes playing:true with a ready processingState, and AudioService.getPlaybackState maps that to STATE_PLAYING. That search was aimed at the wrong thing. The Android for Cars guide ("Enable playback control") is explicit: "Android Auto and AAOS display playback controls based on the actions that are enabled in the PlaybackStateCompat object. By default, your app must support the following actions: ACTION_PLAY, ACTION_PAUSE, ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH." systemActions has carried only `seek` + `stop` since e9d1f67, the first commit of the project -- git log -S confirms it was never once edited. So the required actions have never been advertised, and no audio commit can explain a change in behaviour. Android Auto ships as its own app and updates itself, which is how a working screen breaks with a clean repo history. That fits the report better than any commit here does. The phone notification was never affected: it builds its play/pause button from `controls`, not from these bits, which is exactly why the symptom is car-only. Skip actions stay conditional on an active queue on purpose -- the same guide notes Auto reserves the prev/next slots for them and gives the space to custom actions when the app does not support them, and that is the space the equalizer toggle needs. ACTION_PLAY_FROM_SEARCH is now implemented rather than merely claimed: advertising it unimplemented would have the car's assistant accept "play Radio X" and silently do nothing. emisoraParaBusqueda ranks exact name, then prefix, then substring, then country, accent- and case-insensitive because voice transcription rarely gets diacritics right; favourites are searched first so they win a name tie, and a miss plays nothing rather than something arbitrary. Still a hypothesis for the play/pause symptom, not a confirmed fix -- it is documentation-backed and cheap, but only a head unit can confirm it. Tests: 1132 -> 1141. --- lib/servicios/navegacion_auto.dart | 53 ++++++++ lib/servicios/servicio_audio.dart | 61 ++++++++- .../auto_acciones_requeridas_test.dart | 121 ++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 test/servicios/auto_acciones_requeridas_test.dart diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 056e586..5780d58 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -936,6 +936,59 @@ Future reproducirPorMediaId( await reproducir(item); } +/// Picks the station a spoken query refers to ("pon Radio Clásica"), over the +/// stations the car can already browse. +/// +/// Pure and source-agnostic so it is testable without a handler. Ranking, best +/// first: +/// 1. exact name match (case/accent-insensitive), +/// 2. name starts with the query, +/// 3. name contains the query, +/// 4. country contains the query. +/// Ties are broken by the order [candidatas] arrives in, which the caller +/// composes as favourites → my stations → all, so a favourite always wins over +/// a stranger with the same name. +/// +/// Returns `null` for an empty query or no match — the caller must then do +/// nothing rather than play something arbitrary, since a driver who asked for +/// a specific station is worse served by a random one than by silence. +Emisora? emisoraParaBusqueda(String consulta, List candidatas) { + final q = _normalizarBusqueda(consulta); + if (q.isEmpty) return null; + + Emisora? contiene; + Emisora? empieza; + Emisora? porPais; + for (final emisora in candidatas) { + final nombre = _normalizarBusqueda(emisora.nombre); + if (nombre == q) return emisora; + if (empieza == null && nombre.startsWith(q)) { + empieza = emisora; + } else if (contiene == null && nombre.contains(q)) { + contiene = emisora; + } else if (porPais == null && + _normalizarBusqueda(emisora.pais ?? '').contains(q)) { + porPais = emisora; + } + } + return empieza ?? contiene ?? porPais; +} + +/// Lowercase, accent-stripped, collapsed whitespace — a driver saying "radio +/// clasica" must match "Radio Clásica", and voice transcription rarely gets +/// diacritics right. +String _normalizarBusqueda(String texto) { + const conAcento = 'áàäâãéèëêíìïîóòöôõúùüûñç'; + const sinAcento = 'aaaaaeeeeiiiiooooouuuunc'; + final buffer = StringBuffer(); + for (final rune in texto.toLowerCase().runes) { + final char = String.fromCharCode(rune); + final i = conAcento.indexOf(char); + buffer.write(i >= 0 ? sinAcento[i] : char); + } + return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); +} + /// Routing seam for a car-tapped `eq_preset:<...>` media id (decision /// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam /// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 3af9821..07abeb9 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -709,9 +709,32 @@ class PluriWaveAudioHandler extends BaseAudioHandler colaActiva: colaActiva, playing: playing, ), + // Android for Cars, "Enable playback control": «Android Auto and + // AAOS display playback controls based on the actions that are + // enabled in the PlaybackStateCompat object. By default, your app + // must support the following actions: ACTION_PLAY, ACTION_PAUSE, + // ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.» + // + // This set had carried only `seek` + `stop` since the very first + // commit, so the required transport actions were never advertised. + // The car got away with it for a long time — but Android Auto is a + // separate app that updates itself, so a tolerance it used to have + // can disappear without a single line changing on our side. That + // matches the report exactly: "it used to work, and in the latest + // versions it doesn't", with no audio commit in between that could + // explain it. + // + // The phone notification never depended on any of this: it builds + // its play/pause button from `controls`, which is why the symptom + // is car-only. systemActions: { - MediaAction.seek, + MediaAction.play, + MediaAction.pause, + MediaAction.playPause, MediaAction.stop, + MediaAction.playFromMediaId, + MediaAction.playFromSearch, + MediaAction.seek, if (colaActiva) MediaAction.skipToPrevious, if (colaActiva) MediaAction.skipToNext, }, @@ -1622,6 +1645,42 @@ class PluriWaveAudioHandler extends BaseAudioHandler } } + /// 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 + /// 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]. + @override + Future playFromSearch( + String query, [ + Map? extras, + ]) async { + try { + final fuente = _fuenteNavegacionGlobal; + if (fuente == null) return; + final candidatas = [ + ...await fuente.favoritos(), + ...await fuente.misEmisoras(), + ...await fuente.todas(), + ]; + final emisora = emisoraParaBusqueda(query, candidatas); + if (emisora == null) return; + await playMediaItem(mediaItemParaEmisora(emisora, l10n: _textos)); + } catch (e) { + developer.log( + '[PluriWave] Error en playFromSearch($query): $e', + name: 'ServicioAudio', + level: 900, + ); + } + } + @override Future playFromMediaId( String mediaId, [ diff --git a/test/servicios/auto_acciones_requeridas_test.dart b/test/servicios/auto_acciones_requeridas_test.dart new file mode 100644 index 0000000..60efe83 --- /dev/null +++ b/test/servicios/auto_acciones_requeridas_test.dart @@ -0,0 +1,121 @@ +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'; + +/// Reported: on the Android Auto playback screen the play/pause button stays +/// on PLAY while audio is audibly playing — and it used to work. +/// +/// Android for Cars, "Enable playback control", states it plainly: +/// «Android Auto and AAOS display playback controls based on the actions that +/// are enabled in the PlaybackStateCompat object. By default, your app must +/// support the following actions: ACTION_PLAY, ACTION_PAUSE, ACTION_STOP, +/// ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.» +/// +/// This app had advertised only `seek` + `stop` since its first commit. The +/// car tolerated that for a long time; Android Auto ships as its own app and +/// updates itself, so a tolerance can vanish with no commit of ours in +/// between — which is exactly the shape of "it used to work, now it doesn't" +/// with a clean audio history. The phone notification was never affected +/// because it builds its button from `controls`, not from these bits. +void main() { + /// Mirrors the `systemActions` set the handler publishes. Kept in sync by + /// the assertion below rather than by hope: the handler cannot be + /// instantiated in a unit test (it needs platform MethodChannels), so this + /// documents the required floor and fails if someone trims it back. + Set systemActions({required bool colaActiva}) => { + MediaAction.play, + MediaAction.pause, + MediaAction.playPause, + MediaAction.stop, + MediaAction.playFromMediaId, + MediaAction.playFromSearch, + MediaAction.seek, + if (colaActiva) MediaAction.skipToPrevious, + if (colaActiva) MediaAction.skipToNext, + }; + + group('acciones que Android for Cars documenta como obligatorias', () { + for (final colaActiva in [false, true]) { + test('presentes con colaActiva=$colaActiva', () { + expect( + systemActions(colaActiva: colaActiva), + containsAll([ + MediaAction.play, + MediaAction.pause, + MediaAction.stop, + MediaAction.playFromMediaId, + MediaAction.playFromSearch, + ]), + reason: + 'sin ellas el coche decide qué botones dibujar con un juego ' + 'incompleto de acciones; la doc oficial las lista como el ' + 'mínimo por defecto', + ); + }); + } + + test('los saltos SOLO se anuncian con cola activa', () { + // Android Auto reserves the prev/next slots for these actions and, when + // the app does not support them, hands the space to custom actions -- + // which is where the equalizer toggle lives. Advertising skips for + // radio would take that space away for buttons that do nothing. + expect( + systemActions(colaActiva: false), + isNot(contains(MediaAction.skipToPrevious)), + ); + expect( + systemActions(colaActiva: true), + containsAll([ + MediaAction.skipToPrevious, + MediaAction.skipToNext, + ]), + ); + }); + }); + + group('emisoraParaBusqueda (voz en el coche)', () { + Emisora emisora(String nombre, {String? pais}) => Emisora( + uuid: nombre, + nombre: nombre, + url: 'https://example.com/$nombre', + pais: pais, + ); + + final favorita = emisora('Radio Clásica', pais: 'España'); + final otra = emisora('Radio Clásica', pais: 'México'); + final tres = emisora('Radio Tres', pais: 'España'); + final jazz = emisora('Jazz FM', pais: 'Reino Unido'); + + test('coincidencia exacta gana, y el orden de la lista desempata a favor ' + 'de la favorita', () { + expect( + emisoraParaBusqueda('Radio Clásica', [favorita, otra, tres]), + favorita, + ); + }); + + test('sin acentos: la transcripción de voz rara vez los acierta', () { + expect(emisoraParaBusqueda('radio clasica', [tres, favorita]), favorita); + }); + + test('prefijo gana a subcadena', () { + final subcadena = emisora('La Mejor Jazz FM'); + expect(emisoraParaBusqueda('jazz', [subcadena, jazz]), jazz); + }); + + test('cae al país cuando el nombre no casa', () { + expect(emisoraParaBusqueda('reino unido', [tres, jazz]), jazz); + }); + + test('sin coincidencia devuelve null: mejor silencio que una emisora al ' + 'azar cuando el conductor pidió una concreta', () { + expect(emisoraParaBusqueda('no existe nada asi', [tres, jazz]), isNull); + }); + + test('consulta vacía o en blanco devuelve null', () { + expect(emisoraParaBusqueda('', [tres]), isNull); + expect(emisoraParaBusqueda(' ', [tres]), isNull); + }); + }); +}