fix(alarmas,auto): guard the native snooze anchor, surface the EQ in the car

Three reported issues, two fixed and one instrumented.

1. Posponer left the alarm snoozed ~1444 minutes (24h04m).

Traced end to end in Kotlin. onAlarmFired runs from the receiver BEFORE the
ringing notification exists, and persists snoozeOriginMillis = null plus a
triggerAtMillis already advanced to TOMORROW by computeNextTriggerMillis.
snooze() then anchored on `spec.snoozeOriginMillis ?: spec.triggerAtMillis`
and picked up tomorrow. The existing clamp could not catch it: it only
rescues anchors in the PAST, so an anchor +24h out sails through. The
countdown text is honest -- ceilMinutes(snoozeUntil - now) over Dart's own
template -- the corrupt value is snoozeUntil. With N=5 and a tap at T+1min
the arithmetic lands on 1444 exactly.

This is the defect a9da855 fixed on the Dart side with
EstadoAlarmas._ocurrenciaSonando, after 9c7cf4e had fixed only one of two
adjacent callers. The native lane never got that guard. Now it has
anchorOccurrenceMillis, with a per-surface forward allowance: none for
snooze() (the ringing notification closes an occurrence that has arrived)
and a full PRE_NOTICE_MILLIS for postponeNext() (the pre-notice
notification's occurrence legitimately has not).

No Kotlin test source set exists in this project, so CI cannot verify this
and no Dart test sees it (all use FakePuertoAlarmasAndroid). Verified by
reading; needs an on-device pass.

2. The equalizer toggle stayed invisible on the Android Auto playback
screen even on v1.2.14+136, which does contain it.

On Android 13+ createCustomAction (AudioService.java:466-469) turns
MediaControl.stop into a custom action too, so the car receives TWO in list
order and stop was first -- a head unit exposing one custom-action slot
shows that and buries the rest in an overflow menu. The equalizer now
precedes stop and wins the slot; it is the better occupant, since the car
has its own path to stop playback while the equalizer is reachable no other
way from that screen.

No platform detection needed, and the phone notification is untouched on
every API level: nativeActions comes out [prev?, play/pause, stop, next?]
below 13 and [prev?, play/pause, next?] on 13+, exactly as before. Both are
now asserted.

The list also moves to a public construirControlesTransporte. The guard
test used to re-declare its own copy of the shape, so it stayed green while
asserting a list lib/ no longer produced. It calls the real builder now.

3. Android Auto shows PLAY while a station is audibly playing: NOT fixed,
deliberately.

The car takes that icon from PlaybackStateCompat.getState()
(AudioService.java:601-611), not from controls -- so none of the recent
controls work can be the cause. All eight playbackState.add sites were
audited and none publishes playing:false while audio runs, which leaves no
traced input to fix. A proposed resync off bufferedPositionStream was
rejected: it can publish a spurious idle, which AudioService.java:565-567
turns into stop() and tears down the foreground service -- the exact
regression abc6b47 fixed, on the highest-frequency listener in the handler.

Added instead a change-gated trace of the state actually published, with
eqDisponible alongside it (that flag gates the equalizer action and is
otherwise unobservable). One car session with `adb logcat -s ServicioAudio`
settles both this and issue 2.

Tests: 1124 -> 1127.
This commit is contained in:
2026-08-05 10:17:41 +02:00
parent 3a4dc3b4b1
commit 7054a4c871
3 changed files with 245 additions and 76 deletions
+93 -40
View File
@@ -328,6 +328,57 @@ List<MediaControl> controlesEcualizadorPersonalizados({
];
}
/// The handler's full transport `controls` list for a `playbackState` push.
///
/// Top-level and public so tests exercise THIS function rather than a copy of
/// its shape. `servicio_audio_controles_notificacion_test.dart` used to
/// re-declare the list inline, which meant it stayed green while asserting a
/// shape `lib/` no longer produced — a guard that cannot see the thing it
/// guards. `PluriWaveAudioHandler` itself cannot be instantiated in a unit
/// test (a real `just_audio.AudioPlayer` needs platform MethodChannels), so
/// pulling the pure part out is the only way to test the real thing.
///
/// ORDER MATTERS, and only for the car.
///
/// On Android 13+ `createCustomAction` (AudioService.java:466-469) turns
/// [MediaControl.stop] into a `CUSTOM_ACTION_STOP` custom action too. So on a
/// modern phone the car receives TWO custom actions, in list order, and a head
/// unit that exposes a single custom-action slot shows only the first and
/// buries the rest in an overflow menu — which is why the equalizer toggle
/// stayed invisible on the playback screen even once it was back in this list
/// (reported on v1.2.14+136, which does contain it).
///
/// The equalizer therefore goes BEFORE `stop`, and wins that slot on purpose:
/// the car already has its own path to stop playback and Auto's template
/// renders play/pause itself, while the equalizer is reachable no other way
/// from this screen.
///
/// The phone notification is untouched by that ordering, on every API level.
/// `setState` (AudioService.java:513-521) splits this list by whether a
/// control carries a `customAction`: on 13+ `stop` goes to `customActions`
/// (never the notification) and the equalizer was never in `nativeActions`
/// anyway; below 13 the equalizer is the only custom action and `stop` stays
/// native. Either way `nativeActions` comes out as
/// `[prev?, play/pause, stop, next?]`, and `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) still lands on play/pause.
List<MediaControl> construirControlesTransporte({
required bool colaActiva,
required bool playing,
required bool eqDisponible,
required bool eqActivo,
required AppLocalizations l10n,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
...controlesEcualizadorPersonalizados(
disponible: eqDisponible,
activo: eqActivo,
l10n: l10n,
),
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
];
/// Content-style extras for the Ecualizador folder's items (decision
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
@@ -673,6 +724,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
speed: _player.speed,
),
);
_trazarEstadoPublicado();
});
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
@@ -704,51 +756,52 @@ class PluriWaveAudioHandler extends BaseAudioHandler
});
}
/// The full transport `controls` list for a `playbackState` push (item 4):
/// the existing skip/play-pause/stop set, plus the equalizer's custom
/// actions appended at the end. Appending (rather than interleaving) keeps
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
/// at their existing indices 0-3, so `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
String? _ultimaTrazaEstado;
/// Logs the state actually handed to `AudioService.setState`, once per real
/// change (this fires on every player event, so unconditional logging would
/// bury the signal).
///
/// A custom action here reaches the CAR ONLY, never the phone notification.
/// `AudioService.setState` (AudioService.java:513-520) splits the list in
/// two: `createCustomAction` returns non-null for a control carrying a
/// `customAction`, and that control goes into `customActions` — which feeds
/// `PlaybackStateCompat` and therefore the car's playback screen. Every
/// other control falls to the `else` branch and becomes a
/// `NotificationCompat.Action` in `nativeActions`, the list the media
/// notification is built from. The two never mix, so the equalizer toggle
/// cannot displace a transport button and cannot shift the indices
/// `androidCompactActionIndices` points at.
/// Exists for one open question that static reading could not settle: the
/// Android Auto playback screen shows PLAY while a station is audibly
/// playing. The car does NOT take that icon from `controls` — it takes it
/// from `PlaybackStateCompat.getState()` (AudioService.java:601-611), where
/// `ready` + `playing` is the only combination that yields `STATE_PLAYING`;
/// `idle` gives `STATE_NONE`, which is what a freshly created session
/// carries (:319) and what a car would render as a play button. Every
/// `playbackState.add` in this file was audited and none publishes
/// `playing: false` while audio runs, so the failing input is unknown and
/// any fix would be guesswork.
///
/// THE ONE RULE for anything added here with a `customAction`: its
/// `androidIcon` must name a drawable that really exists, and its `label`
/// must be non-empty in EVERY locale. `getResourceId` (:415-420) resolves
/// the icon by name through `getIdentifier` and yields 0 when it misses,
/// and `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an
/// empty label — a throw at :515 aborts `setState` before
/// `mediaSession.setPlaybackState` (:552), taking the whole media session
/// down with it. `servicio_audio_controles_notificacion_test.dart` holds
/// that line: it reads `android/app/src/main/res/drawable/` and fails if an
/// icon named here has no file behind it.
/// `eqDisponible` rides along because the equalizer custom action is gated
/// on it and the flag is otherwise unobservable — one car session answers
/// both questions at once:
/// adb logcat -s ServicioAudio
void _trazarEstadoPublicado() {
final s = playbackState.value;
final traza =
'proc=${s.processingState.name} playing=${s.playing} '
'eqDisponible=$_eqDisponible eqActivo=$_ecualizadorActivo '
'custom=${s.controls.where((c) => c.customAction != null).length} '
'controles=${s.controls.length}';
if (traza == _ultimaTrazaEstado) return;
_ultimaTrazaEstado = traza;
developer.log(traza, name: 'ServicioAudio');
}
/// Binds [construirControlesTransporte] — which holds the whole contract,
/// including why the equalizer must precede `stop` — to this handler's live
/// equalizer state.
List<MediaControl> _controlesTransporte({
required bool colaActiva,
required bool playing,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
..._controlesEqPersonalizados(),
];
List<MediaControl> _controlesEqPersonalizados() =>
controlesEcualizadorPersonalizados(
disponible: _eqDisponible,
activo: _ecualizadorActivo,
l10n: _textos,
);
}) => construirControlesTransporte(
colaActiva: colaActiva,
playing: playing,
eqDisponible: _eqDisponible,
eqActivo: _ecualizadorActivo,
l10n: _textos,
);
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
/// called whenever EQ availability/enabled state changes outside a