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
* occurrence time + minutes, clamped to now + minutes when the target is
* 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.
*
* 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? {
cancelAutoSilence(id)
val spec = readSpec(id) ?: return null
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
val target = occurrenceAt + safeMinutes * 60_000L
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
Log.d(
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? {
val spec = readSpec(id) ?: return null
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
val target = occurrenceAt + safeMinutes * 60_000L
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
Log.d(
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
/// `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
@@ -137,55 +137,114 @@ void main() {
});
group('transport row keeps its shape', () {
/// Mirrors `_controlesTransporte`'s construction. Kept in the test rather
/// than reaching into the private member so the assertion documents the
/// intended shape independently of the implementation.
// Calls the REAL builder, never a copy of it. This group used to
// re-declare the list inline, which meant it stayed green while asserting
// a shape lib/ no longer produced — a guard blind to the thing it guards.
List<MediaControl> transporte({
required bool colaActiva,
required bool playing,
required bool eqDisponible,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
...controlesEcualizadorPersonalizados(
disponible: eqDisponible,
activo: true,
l10n: lookupAppLocalizations(const Locale('es')),
),
];
}) => construirControlesTransporte(
colaActiva: colaActiva,
playing: playing,
eqDisponible: eqDisponible,
eqActivo: 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 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)', () {
final controles = transporte(
colaActiva: colaActiva,
playing: playing,
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;
expect(controles.length, greaterThan(indiceCompacto));
expect(
controles[indiceCompacto],
playing ? MediaControl.pause : MediaControl.play,
);
// The custom action must never sit among the transport buttons:
// `nativeActions` and `customActions` are built by walking this
// list in order, so an interleaved custom action would renumber
// the notification's own actions.
final indiceCustom = controles.indexWhere(
(c) => c.customAction != null,
);
expect(indiceCustom, controles.length - 1);
for (final sdk33 in [false, true]) {
final row = nativas(controles, sdk33: sdk33);
expect(row.length, greaterThan(indiceCompacto));
expect(
row[indiceCompacto],
playing ? MediaControl.pause : MediaControl.play,
reason:
'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,
]);
});
});
}