merge: native snooze anchor guard and Android Auto EQ slot

This commit is contained in:
2026-08-05 10:17:48 +02:00
3 changed files with 245 additions and 76 deletions
@@ -336,19 +336,64 @@ class AlarmScheduler(private val context: Context) {
} }
} }
/**
* The occurrence a "close this one" action is really acting on, never a
* future one.
*
* Reported on-device: pressing Posponer left the alarm snoozed for ~1444
* minutes (24h04m) instead of the configured few. The chain, all inside
* this file: [onAlarmFired] runs from the receiver BEFORE the ringing
* notification exists, and it persists `snoozeOriginMillis = null` plus a
* `triggerAtMillis` already advanced to TOMORROW by
* [computeNextTriggerMillis]. The snooze anchor was then plain
* `spec.snoozeOriginMillis ?: spec.triggerAtMillis`, so it picked up
* tomorrow. The old clamp (`if (target > now) target else now + minutes`)
* could not catch it: it only rescues anchors in the PAST, and an anchor
* +24h out sails straight through.
*
* [maxAheadMillis] is how far ahead an occurrence may legitimately sit for
* the calling surface: ~0 (just the shared imminence tolerance) for the
* ringing notification, but a full [PRE_NOTICE_MILLIS] for the pre-notice
* notification, whose occurrence has genuinely not happened yet.
*
* Mirrors `EstadoAlarmas._ocurrenciaSonando` on the Dart side, which was
* added in a9da855 for the exact same defect after 9c7cf4e had fixed only
* one of two adjacent callers. The native lane never got that guard.
* `lastHandledAtMillis` is the last fallback because [onAlarmFired] sets
* it to the occurrence that just rang -- note it is NOT purely native
* state (scheduleAlarm takes it from the Dart channel), so `now` has to
* remain the floor.
*/
private fun anchorOccurrenceMillis(
spec: NativeAlarmSpec,
now: Long,
maxAheadMillis: Long = 0L
): Long {
val limit = now + maxAheadMillis + IMMINENT_TOLERANCE_MILLIS
fun usable(candidate: Long?): Long? = candidate?.takeIf { it <= limit }
return usable(spec.snoozeOriginMillis)
?: usable(spec.triggerAtMillis)
?: usable(spec.lastHandledAtMillis)
?: now
}
/** /**
* Snoozes using the SAME anchor as [postponeNext] (Design 2.2): the * Snoozes using the SAME anchor as [postponeNext] (Design 2.2): the
* occurrence time + minutes, clamped to now + minutes when the target is * occurrence time + minutes, clamped to now + minutes when the target is
* already past. Returns the resulting snooze so the caller can report it * already past. Returns the resulting snooze so the caller can report it
* back to Flutter (single source of truth), or null if the spec is gone. * back to Flutter (single source of truth), or null if the spec is gone.
*
* The occurrence comes from [anchorOccurrenceMillis] with no forward
* allowance: this is the RINGING notification's button, so the occurrence
* it closes has already arrived.
*/ */
fun snooze(id: String, minutes: Int): NativeSnoozeResult? { fun snooze(id: String, minutes: Int): NativeSnoozeResult? {
cancelAutoSilence(id) cancelAutoSilence(id)
val spec = readSpec(id) ?: return null val spec = readSpec(id) ?: return null
val safeMinutes = sanitizeSnoozeMinutes(minutes) val safeMinutes = sanitizeSnoozeMinutes(minutes)
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
val target = occurrenceAt + safeMinutes * 60_000L
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val occurrenceAt = anchorOccurrenceMillis(spec, now)
val target = occurrenceAt + safeMinutes * 60_000L
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
Log.d( Log.d(
tag, tag,
@@ -369,12 +414,24 @@ class AlarmScheduler(private val context: Context) {
) )
} }
/**
* Postpones from the PRE-NOTICE notification, whose occurrence has
* legitimately not arrived yet -- it is armed [PRE_NOTICE_MILLIS] ahead.
* So unlike [snooze] this allows an anchor that far forward, but no
* further: an anchor beyond that window is a spec already advanced to a
* later day, which is exactly the state that produced the reported ~24h
* snooze. See [anchorOccurrenceMillis].
*/
fun postponeNext(id: String, minutes: Int): Long? { fun postponeNext(id: String, minutes: Int): Long? {
val spec = readSpec(id) ?: return null val spec = readSpec(id) ?: return null
val safeMinutes = sanitizeSnoozeMinutes(minutes) val safeMinutes = sanitizeSnoozeMinutes(minutes)
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
val target = occurrenceAt + safeMinutes * 60_000L
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val occurrenceAt = anchorOccurrenceMillis(
spec,
now,
maxAheadMillis = PRE_NOTICE_MILLIS
)
val target = occurrenceAt + safeMinutes * 60_000L
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
Log.d( Log.d(
tag, tag,
+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 /// Content-style extras for the Ecualizador folder's items (decision
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto /// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than /// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
@@ -673,6 +724,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
speed: _player.speed, speed: _player.speed,
), ),
); );
_trazarEstadoPublicado();
}); });
_bufferedSub = _player.bufferedPositionStream.listen((pos) { _bufferedSub = _player.bufferedPositionStream.listen((pos) {
@@ -704,51 +756,52 @@ class PluriWaveAudioHandler extends BaseAudioHandler
}); });
} }
/// The full transport `controls` list for a `playbackState` push (item 4): String? _ultimaTrazaEstado;
/// the existing skip/play-pause/stop set, plus the equalizer's custom
/// actions appended at the end. Appending (rather than interleaving) keeps /// Logs the state actually handed to `AudioService.setState`, once per real
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext] /// change (this fires on every player event, so unconditional logging would
/// at their existing indices 0-3, so `androidCompactActionIndices` /// bury the signal).
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
/// ///
/// A custom action here reaches the CAR ONLY, never the phone notification. /// Exists for one open question that static reading could not settle: the
/// `AudioService.setState` (AudioService.java:513-520) splits the list in /// Android Auto playback screen shows PLAY while a station is audibly
/// two: `createCustomAction` returns non-null for a control carrying a /// playing. The car does NOT take that icon from `controls` — it takes it
/// `customAction`, and that control goes into `customActions` — which feeds /// from `PlaybackStateCompat.getState()` (AudioService.java:601-611), where
/// `PlaybackStateCompat` and therefore the car's playback screen. Every /// `ready` + `playing` is the only combination that yields `STATE_PLAYING`;
/// other control falls to the `else` branch and becomes a /// `idle` gives `STATE_NONE`, which is what a freshly created session
/// `NotificationCompat.Action` in `nativeActions`, the list the media /// carries (:319) and what a car would render as a play button. Every
/// notification is built from. The two never mix, so the equalizer toggle /// `playbackState.add` in this file was audited and none publishes
/// cannot displace a transport button and cannot shift the indices /// `playing: false` while audio runs, so the failing input is unknown and
/// `androidCompactActionIndices` points at. /// any fix would be guesswork.
/// ///
/// THE ONE RULE for anything added here with a `customAction`: its /// `eqDisponible` rides along because the equalizer custom action is gated
/// `androidIcon` must name a drawable that really exists, and its `label` /// on it and the flag is otherwise unobservable — one car session answers
/// must be non-empty in EVERY locale. `getResourceId` (:415-420) resolves /// both questions at once:
/// the icon by name through `getIdentifier` and yields 0 when it misses, /// adb logcat -s ServicioAudio
/// and `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an void _trazarEstadoPublicado() {
/// empty label — a throw at :515 aborts `setState` before final s = playbackState.value;
/// `mediaSession.setPlaybackState` (:552), taking the whole media session final traza =
/// down with it. `servicio_audio_controles_notificacion_test.dart` holds 'proc=${s.processingState.name} playing=${s.playing} '
/// that line: it reads `android/app/src/main/res/drawable/` and fails if an 'eqDisponible=$_eqDisponible eqActivo=$_ecualizadorActivo '
/// icon named here has no file behind it. '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({ List<MediaControl> _controlesTransporte({
required bool colaActiva, required bool colaActiva,
required bool playing, required bool playing,
}) => [ }) => construirControlesTransporte(
if (colaActiva) MediaControl.skipToPrevious, colaActiva: colaActiva,
if (playing) MediaControl.pause else MediaControl.play, playing: playing,
MediaControl.stop, eqDisponible: _eqDisponible,
if (colaActiva) MediaControl.skipToNext, eqActivo: _ecualizadorActivo,
..._controlesEqPersonalizados(), l10n: _textos,
]; );
List<MediaControl> _controlesEqPersonalizados() =>
controlesEcualizadorPersonalizados(
disponible: _eqDisponible,
activo: _ecualizadorActivo,
l10n: _textos,
);
/// Re-pushes `playbackState` with a freshly built controls list (item 4): /// Re-pushes `playbackState` with a freshly built controls list (item 4):
/// called whenever EQ availability/enabled state changes outside a /// called whenever EQ availability/enabled state changes outside a
@@ -137,55 +137,114 @@ void main() {
}); });
group('transport row keeps its shape', () { group('transport row keeps its shape', () {
/// Mirrors `_controlesTransporte`'s construction. Kept in the test rather // Calls the REAL builder, never a copy of it. This group used to
/// than reaching into the private member so the assertion documents the // re-declare the list inline, which meant it stayed green while asserting
/// intended shape independently of the implementation. // a shape lib/ no longer produced — a guard blind to the thing it guards.
List<MediaControl> transporte({ List<MediaControl> transporte({
required bool colaActiva, required bool colaActiva,
required bool playing, required bool playing,
required bool eqDisponible, required bool eqDisponible,
}) => [ }) => construirControlesTransporte(
if (colaActiva) MediaControl.skipToPrevious, colaActiva: colaActiva,
if (playing) MediaControl.pause else MediaControl.play, playing: playing,
MediaControl.stop, eqDisponible: eqDisponible,
if (colaActiva) MediaControl.skipToNext, eqActivo: true,
...controlesEcualizadorPersonalizados( l10n: lookupAppLocalizations(const Locale('es')),
disponible: eqDisponible, );
activo: true,
l10n: lookupAppLocalizations(const Locale('es')), /// What `AudioService.setState` (AudioService.java:513-521) would route to
), /// `nativeActions` — the ONLY list the phone's media notification is built
]; /// from, and the list `androidCompactActionIndices` indexes into.
///
/// On Android 13+ `MediaControl.stop` also becomes a custom action
/// (:466-469), so pass [sdk33] to model that split.
List<MediaControl> nativas(
List<MediaControl> controles, {
required bool sdk33,
}) =>
controles
.where((c) => c.customAction == null)
.where((c) => !(sdk33 && c == MediaControl.stop))
.toList();
for (final colaActiva in [false, true]) { for (final colaActiva in [false, true]) {
for (final playing in [false, true]) { for (final playing in [false, true]) {
test('compact index still points at play/pause ' test('compact index still points at play/pause on BOTH API levels '
'(colaActiva=$colaActiva playing=$playing)', () { '(colaActiva=$colaActiva playing=$playing)', () {
final controles = transporte( final controles = transporte(
colaActiva: colaActiva, colaActiva: colaActiva,
playing: playing, playing: playing,
eqDisponible: true, eqDisponible: true,
); );
// androidCompactActionIndices is `[colaActiva ? 1 : 0]`. The EQ
// action is APPENDED, so the native transport buttons keep
// indices 0-3 and the collapsed shade still shows play/pause.
final indiceCompacto = colaActiva ? 1 : 0; final indiceCompacto = colaActiva ? 1 : 0;
expect(controles.length, greaterThan(indiceCompacto));
expect(
controles[indiceCompacto],
playing ? MediaControl.pause : MediaControl.play,
);
// The custom action must never sit among the transport buttons: for (final sdk33 in [false, true]) {
// `nativeActions` and `customActions` are built by walking this final row = nativas(controles, sdk33: sdk33);
// list in order, so an interleaved custom action would renumber expect(row.length, greaterThan(indiceCompacto));
// the notification's own actions. expect(
final indiceCustom = controles.indexWhere( row[indiceCompacto],
(c) => c.customAction != null, playing ? MediaControl.pause : MediaControl.play,
); reason:
expect(indiceCustom, controles.length - 1); 'androidCompactActionIndices is [colaActiva ? 1 : 0] and it '
'indexes nativeActions (AudioService.java:613-618, :637-639)',
);
}
}); });
} }
} }
test('the equalizer comes BEFORE stop, so it wins the first custom-action '
'slot on the car', () {
// Reported on v1.2.14+136: the toggle was in the binary but invisible on
// the head unit. On Android 13+ stop is ALSO a custom action, and it used
// to be first — a unit exposing one slot showed stop and buried the
// equalizer in an overflow menu.
final controles = transporte(
colaActiva: true,
playing: true,
eqDisponible: true,
);
final custom = controles.where(
(c) => c.customAction != null || c == MediaControl.stop,
);
expect(
custom.first.customAction,
isNotNull,
reason: 'on Android 13+ this is the order the car receives them in',
);
});
test('reordering did NOT disturb the notification row', () {
// The whole safety argument for the swap: nativeActions must come out
// [prev?, play/pause, stop, next?] on <13 and [prev?, play/pause, next?]
// on 13+, exactly as before the equalizer moved.
final controles = transporte(
colaActiva: true,
playing: true,
eqDisponible: true,
);
expect(nativas(controles, sdk33: false), [
MediaControl.skipToPrevious,
MediaControl.pause,
MediaControl.stop,
MediaControl.skipToNext,
]);
expect(nativas(controles, sdk33: true), [
MediaControl.skipToPrevious,
MediaControl.pause,
MediaControl.skipToNext,
]);
});
test('a device with no equalizer gets the exact pre-existing list', () {
expect(transporte(colaActiva: true, playing: true, eqDisponible: false), [
MediaControl.skipToPrevious,
MediaControl.pause,
MediaControl.stop,
MediaControl.skipToNext,
]);
});
}); });
} }