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
@@ -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,