diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index c44f327..da2afe6 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -341,14 +341,41 @@ class EstadoAlarmas extends ChangeNotifier { /// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten /// lines below with the identical hazard and no guard, and it stayed that /// way until a user lost a whole week of alarms. Do not re-inline it. - DateTime _ocurrenciaSonando(AlarmaMusical? alarma) { + DateTime _ocurrenciaSonando(AlarmaMusical? alarma) => + _ocurrenciaValida(alarma); + + /// How far ahead the PRE-NOTICE notification's occurrence may legitimately + /// sit: it is armed exactly this far before the alarm, so between the + /// reminder appearing and the user tapping it, the occurrence has not + /// happened yet and rejecting it would be wrong. + /// + /// Mirrors `AlarmScheduler.PRE_NOTICE_MILLIS` (30 min). Both sides must + /// agree or one of them starts discarding perfectly good anchors. + static const ventanaPreaviso = Duration(minutes: 30); + + /// [_ocurrenciaSonando] generalized with a forward allowance, and with an + /// externally-supplied [propuesta] taking priority when it survives the + /// same check. + /// + /// [propuesta] is what the NATIVE side reported as the occurrence its + /// notification was about. It is trusted first — it is better evidence than + /// anything reconstructed here — but only after being validated, because it + /// can arrive as a fallback the caller invented (`app.dart` substitutes + /// `alarma.proximaEjecucion` when the native event carries no occurrence, + /// and that field may already point at tomorrow). + DateTime _ocurrenciaValida( + AlarmaMusical? alarma, { + DateTime? propuesta, + Duration margen = Duration.zero, + }) { final ahora = servicio.ahora(); final limite = ahora.add( - ServicioProgramacionAlarmas.toleranciaDisparoInminente, + margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente, ); DateTime? sonando(DateTime? candidata) => candidata != null && !candidata.isAfter(limite) ? candidata : null; - return sonando(alarma?.snoozeOrigen) ?? + return sonando(propuesta) ?? + sonando(alarma?.snoozeOrigen) ?? sonando(alarma?.proximaEjecucion) ?? sonando(alarma?.ultimaEjecucionGestionada) ?? ahora; @@ -382,6 +409,24 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } + /// "Posponer" on the PRE-NOTICE notification. + /// + /// Reported on-device: this left the alarm snoozed for 1400+ minutes — a + /// whole day — instead of the configured few. The native lane got its guard + /// in 7054a4c, but Dart runs AFTERWARDS on this path (the receiver's + /// `postponeNext` fires, then `startActivity`, then this) and persists + + /// reschedules, so whatever it computes is the value that survives. It was + /// the last snooze path in the codebase with NO occurrence guard at all: + /// it took [ejecucion] on faith and turned it straight into the next alarm. + /// + /// And [ejecucion] is not trustworthy: `app.dart` falls back to + /// `alarma.proximaEjecucion` whenever the native event carries no + /// occurrence, and that field can already point at tomorrow. + /// + /// Validated through [_ocurrenciaValida] with a [ventanaPreaviso] + /// allowance — unlike the ringing-screen paths this occurrence legitimately + /// has NOT arrived yet, which is exactly why `_ocurrenciaSonando` could not + /// simply be reused here. Future posponerProximaDesdePreaviso( AlarmaMusical alarma, int minutos, @@ -389,14 +434,23 @@ class EstadoAlarmas extends ChangeNotifier { ) async { _error = null; final seguros = _snoozeSeguro(minutos); - final snoozeHasta = ejecucion.add(Duration(minutes: seguros)); + final ocurrencia = _ocurrenciaValida( + alarma, + propuesta: ejecucion, + margen: ventanaPreaviso, + ); + final snoozeHasta = ocurrencia.add(Duration(minutes: seguros)); debugPrint( - '[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}', + '[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros propuesta=${ejecucion.toIso8601String()} ocurrencia=${ocurrencia.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}', ); await android.ocultarNotificacionAlarma(alarma.id); final config = await servicio.posponerEjecucionHasta( alarma.id, - ejecucion, + // The VALIDATED occurrence, not the raw parameter: this becomes both + // `snoozeOrigen` and `ultimaEjecucionGestionada`, so passing the + // unchecked value here would poison the very state a9da855/0430059 + // exist to keep clean. + ocurrencia, snoozeHasta, ); _aplicar(config); diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 4df7555..056e586 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -315,20 +315,19 @@ class ConstructorArbolAuto { /// optionally Música Local, Ecualizador), all non-playable. /// /// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer - /// folder" rule that used to live in this doc comment (commit `2403da3`, - /// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4 - /// line 40). That rule was sound when written, but predated on-device - /// feedback showing that Android Auto custom actions don't surface - /// enough state for choosing among six presets: a monochrome icon cannot - /// legibly encode "which preset", and many head units render a custom - /// action icon-first, hiding its label. `Ecualizador` is a real - /// browsable folder again: "Desactivar" first, then the six factory - /// presets, the active one marked (children built by - /// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays - /// free of any `AppLocalizations` dependency, unlike that builder). - /// Always present, and LAST in the list (after Música Local, when - /// included) -- unlike [idMusicaLocal] it is never conditionally hidden. - /// Do not "restore" the no-folder rule without re-reading that decision. + /// There is NO `Ecualizador` folder. The car's only equalizer control is + /// the on/off custom action on the playback screen + /// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which + /// the driver reaches from all three player views without leaving them. + /// + /// The folder existed briefly (`8423ccd`) because custom actions were + /// thought unable to convey enough state for a six-preset choice. Owner + /// decision after driving with it: a browsable preset list is more + /// interaction than a driver wants, and on/off is the only equalizer + /// control that belongs in a car. Preset selection stays on the phone. + /// This lands back on the redesign mockup's original rule ("sin carpeta de + /// ecualizador", turn t4 line 40), now for a road-tested reason rather than + /// an assumed one. /// /// `Música Local` is OMITTED entirely (not just empty) unless /// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder @@ -339,7 +338,6 @@ class ConstructorArbolAuto { _carpeta(idTodas, 'Todas las emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'), if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), - _carpeta(idEcualizador, 'Ecualizador'), ]; MediaItem _carpeta(String id, String titulo) => MediaItem( diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 18a3a7d..3130f1a 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -564,8 +564,22 @@ class ServicioAlarmas { final ahora = _reloj(); // S2-R5: a disabled alarm must not keep a pending snooze; clearing it // here guarantees the snoozed occurrence dies with the alarm. + // Self-heal for a snooze target parked absurdly far out — the reported + // "posponer left it 1400+ minutes away". A legitimate snooze can never + // reach here: posponerEjecucion clamps to `minutos.clamp(1, 120)` and the + // anchor is now guarded on both the native and Dart sides, so anything + // past that ceiling is a leftover from a build that had neither guard. + // Without this, an alarm poisoned before the fix keeps showing tomorrow + // on every tick — the user reinstalls, sees no change, and reasonably + // concludes nothing was fixed. Generous margin over the 120-minute cap so + // a real long snooze is never mistaken for corruption. + const techoSnooze = Duration(hours: 3); + final snoozeCorrupto = + alarma.snoozeHasta != null && + alarma.snoozeHasta!.isAfter(ahora.add(techoSnooze)); final snoozeActivo = alarma.activa && + !snoozeCorrupto && alarma.snoozeHasta != null && alarma.snoozeHasta!.isAfter(ahora); // Self-heal for state poisoned before the Detener anchor fix: a stop diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 82508a8..3af9821 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -578,6 +578,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler late AudioPlayer _player = _crearPlayer(); StreamSubscription? _estadoPlayerSub; StreamSubscription? _bufferedSub; + StreamSubscription? _duracionSub; StreamSubscription? _eventosSub; StreamSubscription? _androidAudioSessionIdSub; final _androidAudioSessionIdController = StreamController.broadcast(); @@ -720,6 +721,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler cambiandoFuente: _cambiandoFuente, ), playing: playing, + // Reported: in Android Auto the progress bar and the time labels of + // a local track never move. `updatePosition` was NEVER set anywhere + // in this file, so it stayed at its `Duration.zero` default while + // `copyWith` refreshed `updateTime` to now on every push + // (audio_service.dart:411-413, :256). A client extrapolates + // `updatePosition + (now - updateTime) * speed`, so it was told + // "position 0, as of right now" over and over — a bar pinned at the + // start. The phone UI never noticed because it reads + // `_player.positionStream` directly. + updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, ), @@ -728,7 +739,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler }); _bufferedSub = _player.bufferedPositionStream.listen((pos) { - playbackState.add(playbackState.value.copyWith(bufferedPosition: pos)); + playbackState.add( + playbackState.value.copyWith( + bufferedPosition: pos, + // Must ride along: `copyWith` stamps a fresh `updateTime` but keeps + // the old `updatePosition`, so a push without it actively tells the + // client the PREVIOUS position is current NOW — freezing the bar + // between player-state events. This stream ticks ~2/s, which is + // what keeps the car's bar smooth. + updatePosition: _player.position, + ), + ); + }); + + // Duration arrives asynchronously once the source is parsed, and Android + // Auto draws no progress bar for a MediaItem without one. Radio streams + // report null (correct: live audio has no length) and are left alone. + _duracionSub = _player.durationStream.listen((duracion) { + final actual = mediaItem.value; + if (duracion == null || actual == null) return; + if (actual.duration == duracion) return; + mediaItem.add(actual.copyWith(duration: duracion)); }); _eventosSub = _player.playbackEventStream.listen( @@ -1180,6 +1211,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler Future _recrearPlayer() async { await _estadoPlayerSub?.cancel(); await _bufferedSub?.cancel(); + await _duracionSub?.cancel(); await _eventosSub?.cancel(); await _androidAudioSessionIdSub?.cancel(); @@ -1486,6 +1518,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler await stop(); await _estadoPlayerSub?.cancel(); await _bufferedSub?.cancel(); + await _duracionSub?.cancel(); await _eventosSub?.cancel(); await _androidAudioSessionIdSub?.cancel(); await _player.dispose(); @@ -1535,6 +1568,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler // external data source, unlike every branch below it -- checked // before the `_fuenteNavegacionGlobal` gate, mirroring how the // local-music branch above is also resolved before that gate. + // The Ecualizador folder is no longer offered by `raiz()` (owner + // decision: the car keeps only the on/off toggle on the playback + // screen). This branch stays as a TRANSITIONAL courtesy: Android Auto + // caches browse trees on the head unit, so a stale "Ecualizador" entry + // can survive the update for a session or two. Answering it keeps that + // leftover working instead of opening an empty dead folder. Delete + // once no head unit can still be holding the old tree. if (parentMediaId == ConstructorArbolAuto.idEcualizador) { return itemsEcualizadorAuto( activo: _ecualizadorActivo, diff --git a/test/estado/estado_alarmas_preaviso_ancla_test.dart b/test/estado/estado_alarmas_preaviso_ancla_test.dart new file mode 100644 index 0000000..ef58f50 --- /dev/null +++ b/test/estado/estado_alarmas_preaviso_ancla_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes_alarmas.dart'; + +/// Reported twice on-device: "Posponer" on the pre-notice notification left +/// the alarm snoozed 1400+ minutes — a whole day — instead of the configured +/// few minutes. +/// +/// The first fix (7054a4c) guarded the NATIVE anchor, and it was not enough, +/// because Dart runs AFTERWARDS on this path: the receiver's `postponeNext` +/// fires, then `startActivity`, then `app.dart` dispatches here, and this +/// method persists and reschedules. Whatever Dart computes is the value that +/// survives. It was the last snooze path with no occurrence guard at all. +/// +/// It also cannot simply reuse `_ocurrenciaSonando`: the pre-notice's +/// occurrence legitimately has NOT arrived yet (the reminder is armed 30 min +/// ahead), so the ringing-screen guard would reject a perfectly good anchor. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + AlarmaMusical diaria(String id) => AlarmaMusical( + id: id, + nombre: 'Mañana', + hora: 16, + minuto: 20, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + snoozeMinutos: 5, + ); + + ({EstadoAlarmas estado, FakePuertoAlarmasAndroid android}) montar( + DateTime Function() reloj, + ) { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: reloj), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + return (estado: estado, android: android); + } + + test('una ocurrencia del DÍA SIGUIENTE se rechaza: pospone minutos, ' + 'no 24 horas', () async { + // The exact reported shape. The pre-notice for today's 16:20 is on + // screen at 16:16; the anchor handed in points at TOMORROW (either the + // native spec was already advanced, or app.dart fell back to a + // proximaEjecucion that had moved on). + var ahora = DateTime(2026, 8, 3, 16, 0); + final m = montar(() => ahora); + await m.estado.guardarAlarma(diaria('p1')); + + ahora = DateTime(2026, 8, 3, 16, 16); + await m.estado.posponerProximaDesdePreaviso( + m.estado.alarmas.single, + 5, + DateTime(2026, 8, 4, 16, 20), // <- tomorrow + ); + + final snooze = m.estado.alarmas.single.snoozeHasta!; + final minutos = snooze.difference(ahora).inMinutes; + expect( + minutos, + lessThan(60), + reason: + 'la alarma quedó a $minutos min ($snooze). El reporte fue "más de ' + '1400 minutos"; cualquier cosa por encima de una hora es el mismo bug', + ); + expect( + m.estado.alarmas.single.snoozeOrigen, + isNot(DateTime(2026, 8, 4, 16, 20)), + reason: + 'el ancla sin validar también se guarda como snoozeOrigen y como ' + 'ultimaEjecucionGestionada — envenenaría el estado que a9da855 y ' + '0430059 existen para mantener limpio', + ); + }); + + test('la ocurrencia REAL del preaviso se respeta aunque esté en el futuro: ' + 'ancla en la ocurrencia + N, no en ahora + N', () async { + // The whole reason this path needs its own guard instead of reusing + // _ocurrenciaSonando: 30 minutes ahead is legitimate here. + var ahora = DateTime(2026, 8, 3, 16, 0); + final m = montar(() => ahora); + await m.estado.guardarAlarma(diaria('p2')); + + // Pre-notice fires at 15:50; the user taps at 15:52, 28 min before. + ahora = DateTime(2026, 8, 3, 15, 52); + await m.estado.posponerProximaDesdePreaviso( + m.estado.alarmas.single, + 5, + DateTime(2026, 8, 3, 16, 20), + ); + + expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25)); + expect(m.estado.alarmas.single.snoozeOrigen, DateTime(2026, 8, 3, 16, 20)); + }); + + test('un snooze ya envenenado en disco se cura al recalcular', () async { + // Devices that ran the buggy build carry snoozeHasta = tomorrow in + // SharedPreferences. Without healing it, the alarm keeps reporting + // tomorrow on every tick and the user sees no change after updating. + final ahora = DateTime(2026, 8, 3, 16, 0); + final servicio = ServicioAlarmas(reloj: () => ahora); + + await servicio.guardarAlarma( + diaria('p4').copyWith( + snoozeHasta: DateTime(2026, 8, 4, 16, 25), + snoozeOrigen: DateTime(2026, 8, 4, 16, 20), + ), + ); + + final alarma = (await servicio.recalcularTodas()).alarmas.single; + + expect(alarma.snoozeHasta, isNull); + expect(alarma.proximaProgramable, DateTime(2026, 8, 3, 16, 20)); + }); + + test('un snooze legítimo de 2 horas NO se toca', () async { + // posponerEjecucion clamps to 120 minutes, so the ceiling has to sit + // above that or the heal would eat real snoozes. + final ahora = DateTime(2026, 8, 3, 16, 0); + final servicio = ServicioAlarmas(reloj: () => ahora); + final hasta = DateTime(2026, 8, 3, 18, 0); + + await servicio.guardarAlarma( + diaria('p5').copyWith(snoozeHasta: hasta, snoozeOrigen: ahora), + ); + + expect( + (await servicio.recalcularTodas()).alarmas.single.snoozeHasta, + hasta, + ); + }); + + test('un ancla absurdamente lejana cae a la ocurrencia propia de la alarma, ' + 'no a un valor inventado', () async { + var ahora = DateTime(2026, 8, 3, 16, 0); + final m = montar(() => ahora); + await m.estado.guardarAlarma(diaria('p3')); + + ahora = DateTime(2026, 8, 3, 16, 10); + await m.estado.posponerProximaDesdePreaviso( + m.estado.alarmas.single, + 5, + DateTime(2027, 1, 1, 16, 20), // absurd + ); + + // proximaEjecucion (today 16:20) is inside the pre-notice window, so it + // is the right fallback and the snooze lands on 16:25. + expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25)); + }); +} diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 5328d9f..6b94dc4 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -238,37 +238,10 @@ void main() { }); group('ConstructorArbolAuto.raiz', () { - test('con incluirMusicaLocal: true devuelve exactamente 5 carpetas no ' - 'reproducibles con los ids esperados, terminando en Ecualizador', () { + test('con incluirMusicaLocal: true devuelve exactamente 4 carpetas no ' + 'reproducibles con los ids esperados', () { final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true); - expect(raiz, hasLength(5)); - final ids = raiz.map((item) => item.id).toSet(); - expect( - ids, - equals({ - ConstructorArbolAuto.idFavoritos, - ConstructorArbolAuto.idTodas, - ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idMusicaLocal, - ConstructorArbolAuto.idEcualizador, - }), - ); - for (final item in raiz) { - expect(item.playable, isFalse); - expect(item.title, isNotEmpty); - } - // Decision `auto/ecualizador-diseno`: Ecualizador is always LAST, - // after Música Local when it is present. - expect(raiz[raiz.length - 2].id, ConstructorArbolAuto.idMusicaLocal); - expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); - }); - - test('con incluirMusicaLocal: false devuelve exactamente 4 carpetas — ' - 'Música Local queda OCULTA, no vacía, y Ecualizador sigue presente y ' - 'al final', () { - final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false); - expect(raiz, hasLength(4)); final ids = raiz.map((item) => item.id).toSet(); expect( @@ -277,25 +250,50 @@ void main() { ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idMisEmisoras, - ConstructorArbolAuto.idEcualizador, + ConstructorArbolAuto.idMusicaLocal, + }), + ); + for (final item in raiz) { + expect(item.playable, isFalse); + expect(item.title, isNotEmpty); + } + expect(raiz.last.id, ConstructorArbolAuto.idMusicaLocal); + }); + + test('con incluirMusicaLocal: false devuelve exactamente 3 carpetas — ' + 'Música Local queda OCULTA, no vacía', () { + final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false); + + expect(raiz, hasLength(3)); + final ids = raiz.map((item) => item.id).toSet(); + expect( + ids, + equals({ + ConstructorArbolAuto.idFavoritos, + ConstructorArbolAuto.idTodas, + ConstructorArbolAuto.idMisEmisoras, }), ); expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal))); - expect(raiz.last.id, ConstructorArbolAuto.idEcualizador); }); - test('la raíz SÍ ofrece la carpeta de ecualizador -- decisión ' - '`auto/ecualizador-diseno` SUPERSEDE la regla anterior de "sin ' - 'carpeta de ecualizador" (commit `2403da3`); este test REEMPLAZA ' - 'deliberadamente al test homónimo previo que afirmaba lo contrario, ' - 'no es una regresión', () { + test('la raíz NO ofrece la carpeta de ecualizador -- decisión owner ' + 'tras conducir con ella: on/off en pantalla es el único control de ' + 'ecualizador que pertenece en el coche, la selección de preset se ' + 'queda en el móvil. Esto REEMPLAZA deliberadamente al test homónimo ' + 'previo que afirmaba lo contrario (que a su vez había reemplazado la ' + 'ausencia original en `2403da3`) -- no es una regresión, es la ' + 'segunda vuelta de la misma decisión con evidencia real de uso', () { final ids = ConstructorArbolAuto() .raiz(incluirMusicaLocal: true) .map((item) => item.id) .toList(); - expect(ids, contains(ConstructorArbolAuto.idEcualizador)); + expect(ids, isNot(contains(ConstructorArbolAuto.idEcualizador))); + // The id constant itself is NOT deleted: getChildren still answers it + // transitionally for a head unit with a stale cached browse tree. See + // servicio_audio.dart's getChildren for that courtesy branch. expect(ConstructorArbolAuto.idEcualizador, 'ecualizador'); }); });