diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt index 699396b..6fca362 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) { ) ) Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}") + NativeSchedulingFailures.clear( + appContext, + spec.id, + NativeSchedulingFailures.TYPE_PRE_NOTICE + ) } catch (_: SecurityException) { + // Silent before this fix: the main alarm can still arm via + // setAlarmClock (exempt from the exact-alarm permission), so + // the alarm itself rings while its 30-minute reminder simply + // never appears, with nothing surfaced anywhere but logcat. Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}") + NativeSchedulingFailures.record( + appContext, + spec.id, + NativeSchedulingFailures.TYPE_PRE_NOTICE + ) } } else if (spec.triggerAtMillis > now) { appContext.sendBroadcast( @@ -846,8 +860,22 @@ class AlarmScheduler(private val context: Context) { // the native recompute inside scheduleSpec. scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true) Log.d(tag, "alarm.reschedule OK id=$id") + NativeSchedulingFailures.clear( + appContext, + id, + NativeSchedulingFailures.TYPE_RESCHEDULE + ) } catch (error: Throwable) { + // Silent before this fix: one alarm's reschedule failure used + // to just log and move to the next id, leaving that ONE + // alarm unscheduled after a reboot/unlock/app-update with no + // signal anywhere but logcat. Log.e(tag, "alarm.reschedule failed id=$id", error) + NativeSchedulingFailures.record( + appContext, + id, + NativeSchedulingFailures.TYPE_RESCHEDULE + ) } } } @@ -855,6 +883,18 @@ class AlarmScheduler(private val context: Context) { fun pendingAlarmCount(): Int = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size + /** + * Scheduling-reliability failures the native side recorded on its own + * (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground- + * service start, and post-boot/unlock reschedule failures never go + * through a Dart method-channel call that could throw, so they are + * persisted here instead and synced by Flutter on the next app launch + * -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own + * cold-start-sync shape. + */ + fun scheduleFailures(): List> = + NativeSchedulingFailures.all(appContext) + fun handledOccurrences(): List> = prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty() .mapNotNull { id -> @@ -1265,3 +1305,91 @@ class AlarmScheduler(private val context: Context) { private fun JSONObject.optNullableLong(name: String): Long? = if (has(name) && !isNull(name)) optLong(name) else null + +/** + * Persisted store for scheduling-reliability failures the native side + * catches and previously only logged (fix/alarmas-fallos-silenciosos, item + * 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice), + * a refused foreground-service start (PluriWaveAlarmService.start), and a + * per-alarm reschedule failure after boot/unlock + * (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object + * (not nested in [AlarmScheduler]'s own instance state) so + * [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler] + * instance of its own -- can record a failure too, using the exact same + * [Context]-scoped, device-protected-storage `SharedPreferences` file + * [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by + * hand since Kotlin constants cannot be shared across files without a third + * file). + * + * Only the LATEST failure per alarm is kept (mirrors the Dart-side + * `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability + * signal, not an audit log. + */ +object NativeSchedulingFailures { + private const val PREFS = "pluriwave_alarm_scheduler" + private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids" + private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_" + private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_" + + /** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */ + const val TYPE_PRE_NOTICE = "preNoticeFailed" + + /** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */ + const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed" + + /** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */ + const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed" + + private fun prefs(context: Context) = + context.applicationContext.createDeviceProtectedStorageContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun record(context: Context, id: String, type: String) { + if (id.isBlank()) return + val store = prefs(context) + val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet() + ids.add(id) + store.edit() + .putStringSet(KEY_FAILURE_IDS, ids) + .putString("$KEY_FAILURE_TYPE_PREFIX$id", type) + .putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis()) + .apply() + Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type") + } + + /** + * Clears the failure recorded for [id] ONLY when its current type is + * [type] -- type-scoped on purpose (mirrors the Dart-side + * `limpiarFalloProgramacion`), so a foreground-service success never + * erases an unrelated, still-outstanding pre-notice failure for the + * same alarm. + */ + fun clear(context: Context, id: String, type: String) { + val store = prefs(context) + val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null) + if (storedType != type) return + val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet() + if (!ids.remove(id)) return + store.edit() + .putStringSet(KEY_FAILURE_IDS, ids) + .remove("$KEY_FAILURE_TYPE_PREFIX$id") + .remove("$KEY_FAILURE_AT_PREFIX$id") + .apply() + } + + fun all(context: Context): List> { + val store = prefs(context) + return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id -> + val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null) + ?: return@mapNotNull null + val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L) + .takeIf { it > 0L } + ?: return@mapNotNull null + mapOf( + "alarmId" to id, + "type" to type, + "atMillis" to at + ) + } + } +} diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 29e4c7d..b80032b 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -250,6 +250,10 @@ class MainActivity : AudioServiceActivity() { Log.d(tag, "alarm.channel getNativeSnoozeState") result.success(alarmScheduler.nativeSnoozeStates()) } + "getNativeSchedulingFailures" -> { + Log.d(tag, "alarm.channel getNativeSchedulingFailures") + result.success(alarmScheduler.scheduleFailures()) + } "setNotificationStrings" -> { val args = call.arguments as? Map<*, *> if (args != null) { diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt index 9e9a424..32b6b67 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() { startForeground(NOTIFICATION_ID, notification) } } catch (error: Throwable) { + // Silent before this fix: same user-visible symptom as a refused + // startForegroundService (the ring never actually starts) -- + // recorded under the SAME tipo so the alarms list surfaces it + // regardless of which of the two calls the OS refused. Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error) + NativeSchedulingFailures.record( + this, + alarmId, + NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE + ) releaseWakeLock() // Second documented clear site (feedback item, READ-5): this // branch never reaches stopEverything(), so without the same @@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() { stopSelf() return } + NativeSchedulingFailures.clear( + this, + alarmId, + NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE + ) startAudio( alarmId, stationName, @@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() { fun start(context: Context, source: Intent) { ensureChannel(context) + val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) val intent = Intent(context, PluriWaveAlarmService::class.java).apply { action = PluriWaveAlarmReceiver.ACTION_FIRE putExtras(source) @@ -761,8 +776,27 @@ class PluriWaveAlarmService : Service() { try { ContextCompat.startForegroundService(context, intent) Log.d(TAG, "alarm.service start requested") + if (!alarmId.isNullOrBlank()) { + NativeSchedulingFailures.clear( + context, + alarmId, + NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE + ) + } } catch (error: Throwable) { + // Silent before this fix: a fire-and-forget call from the + // receiver's ACTION_FIRE branch -- if the OS refuses the + // foreground-service start (background-restricted app), the + // ring never happens and nothing surfaced it anywhere but + // logcat, "as if there were no alarm at all". Log.e(TAG, "alarm.service start failed", error) + if (!alarmId.isNullOrBlank()) { + NativeSchedulingFailures.record( + context, + alarmId, + NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE + ) + } } } diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index 29f2447..e6a7636 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -90,6 +90,7 @@ class EstadoAlarmas extends ChangeNotifier { ); await _sincronizarTodas(); await cargarDiagnostico(); + await cargarFallosNativos(); _activarRefresco(); } catch (e) { _error = 'No se pudieron cargar las alarmas: $e'; @@ -228,10 +229,22 @@ class EstadoAlarmas extends ChangeNotifier { /// Clears a previously recorded scheduling failure once a later attempt /// for the same alarm succeeds (D5-style recovery, mirroring how [_error] - /// itself already clears on a successful retry). + /// itself already clears on a successful retry). Type-scoped: a + /// successful `android.programar` call only proves the MAIN alarm + /// registration (and, transitively, that any stale post-boot reschedule + /// failure no longer applies) -- it says nothing about the pre-notice or + /// foreground-service subsystems, so those are left untouched here. Future _limpiarFalloProgramacion(String alarmaId) async { try { - final config = await servicio.limpiarFalloProgramacion(alarmaId); + var config = await servicio.limpiarFalloProgramacion( + alarmaId, + ExcepcionAlarma.tipoFalloProgramacion, + ); + _aplicar(config); + config = await servicio.limpiarFalloProgramacion( + alarmaId, + ExcepcionAlarma.tipoFalloReprogramacionArranque, + ); _aplicar(config); } catch (e) { debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e'); @@ -525,6 +538,39 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } + /// Drains the failures the NATIVE side recorded on its own and turns each + /// into a per-alarm exception, so the card can mark it. + /// + /// These three paths used to log to logcat and stop there: a pre-notice + /// that could not be armed, a refused foreground-service start when the + /// alarm should have rung, and a per-alarm reschedule that failed after a + /// reboot. None of them run inside a Dart call, so nothing on this side + /// ever learned they happened — an alarm could sit switched on in the + /// list having never reached the OS. Reading them at startup is what + /// makes the reported "as if there were no alarm" visible. + /// + /// Deliberately tolerant: a failed read is logged and swallowed, never + /// surfaced as an alarm error, because a diagnostics gap must not look + /// like a scheduling problem. + Future cargarFallosNativos() async { + try { + final fallos = await android.fallosNativosProgramacion(); + for (final fallo in fallos) { + final alarmaId = fallo['alarmaId'] as String?; + final tipo = fallo['tipo'] as String?; + if (alarmaId == null || tipo == null) continue; + await _registrarFalloProgramacion(alarmaId, tipo: tipo); + } + if (fallos.isNotEmpty) { + debugPrint( + '[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}', + ); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e'); + } + } + /// Records a snooze the native layer performed by itself (Decision 2.1). /// The native scheduler already re-registered setAlarmClock, so this only /// persists the canonical state — it MUST NOT call android.programar again. @@ -624,6 +670,60 @@ class EstadoAlarmas extends ChangeNotifier { debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e'); } await _importarSnoozesNativosActivos(); + await _importarFallosProgramacionNativos(); + } + + /// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports + /// scheduling-reliability failures the NATIVE side recorded on its own -- + /// a pre-notice `SecurityException`, a refused foreground-service start, + /// or a per-alarm reschedule failure after boot/unlock -- none of which + /// ever go through a Dart method-channel call that could throw. Without + /// this sync, these three failures stayed invisible forever (only + /// logcat), even after this app-launch fix reads them. + Future _importarFallosProgramacionNativos() async { + try { + final fallos = await android.obtenerFallosProgramacionNativos(); + final reportadoPorAlarma = { + for (final fallo in fallos) fallo.alarmaId: fallo, + }; + // Reconcile stale copies: the native side clears its OWN record the + // next time that specific subsystem succeeds (pre-notice/foreground- + // service), so an alarm previously imported with one of those tipos + // that is no longer reported here means it already recovered -- + // without this, the card would keep showing a problem that fixed + // itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque` + // are NOT reconciled here -- those already clear on the Dart side's + // own successful `android.programar` calls. + for (final alarma in _alarmas) { + final actual = ultimaExcepcionPara(alarma.id); + final esTipoReconciliable = + actual != null && + (actual.tipo == ExcepcionAlarma.tipoFalloPreaviso || + actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido); + if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) { + final config = await servicio.limpiarFalloProgramacion( + alarma.id, + actual.tipo, + ); + _aplicar(config); + } + } + for (final fallo in fallos) { + final config = await servicio.registrarFalloProgramacion( + fallo.alarmaId, + fallo.ocurridoEn, + fallo.tipo, + ); + _aplicar(config); + } + if (fallos.isNotEmpty) { + debugPrint( + '[PluriWave][alarmas] fallos nativos importados count=${fallos.length}', + ); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e'); + } } /// Cold-start half of Decision 2.1: imports snoozes the native scheduler diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 97c7cc9..8bcfc5d 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -378,18 +378,28 @@ class ServicioAlarmas { return nuevo; }); - /// Clears any outstanding failure record for [alarmaId] (a subsequent - /// scheduling attempt succeeded). No-op when there is nothing to clear. + /// Clears the outstanding failure record for [alarmaId] ONLY when its + /// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind + /// succeeded). Type-scoped on purpose: a successful main-alarm schedule + /// call proves nothing about the pre-notice or foreground-service + /// subsystems, so it must never clear a failure recorded for those. No-op + /// when there is nothing to clear or the recorded tipo does not match. Future limpiarFalloProgramacion( String alarmaId, + String tipo, ) => _enCola(() async { final config = await _configActual(); - final sinFallo = _sinFalloPrevio(config.excepciones, alarmaId); - if (sinFallo.length == config.excepciones.length) return config; + final actual = config.excepciones.where((e) => e.alarmaId == alarmaId); + final tieneEseTipo = actual.any((e) => e.tipo == tipo); + if (!tieneEseTipo) return config; + final excepciones = + config.excepciones + .where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo)) + .toList(); final nuevo = ConfiguracionAlarmas( alarmas: config.alarmas, vacaciones: config.vacaciones, - excepciones: sinFallo, + excepciones: excepciones, ); await _guardar(nuevo); return nuevo; @@ -398,13 +408,14 @@ class ServicioAlarmas { List _sinFalloPrevio( List excepciones, String alarmaId, - ) => excepciones - .where( - (e) => - !(e.alarmaId == alarmaId && - ExcepcionAlarma.tiposFallo.contains(e.tipo)), - ) - .toList(); + ) => + excepciones + .where( + (e) => + !(e.alarmaId == alarmaId && + ExcepcionAlarma.tiposFallo.contains(e.tipo)), + ) + .toList(); Future posponerEjecucion( String alarmaId, diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 8c8b0cf..5cf4f32 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -161,6 +161,35 @@ class EjecucionAlarmaNativa { } } +/// A scheduling-reliability failure the NATIVE side recorded on its own +/// (fix/alarmas-fallos-silenciosos, item 2): the pre-notice reminder, the +/// ringing foreground service, and a post-boot/unlock reschedule can each +/// fail without ever going through a Dart method-channel call that could +/// throw -- the native scheduler persists these instead (mirroring how +/// handled occurrences and snooze state already survive a killed engine), +/// and this is the cold-start sync so the Dart side finds out at all. +class FalloProgramacionNativo { + const FalloProgramacionNativo({ + required this.alarmaId, + required this.tipo, + required this.ocurridoEn, + }); + + final String alarmaId; + final String tipo; + final DateTime ocurridoEn; + + factory FalloProgramacionNativo.fromMap(Map map) { + return FalloProgramacionNativo( + alarmaId: map['alarmId'] as String? ?? '', + tipo: map['type'] as String? ?? '', + ocurridoEn: DateTime.fromMillisecondsSinceEpoch( + (map['atMillis'] as num?)?.toInt() ?? 0, + ), + ); + } +} + abstract class PuertoAlarmasAndroid { Stream get eventosAlarma; @@ -170,6 +199,17 @@ abstract class PuertoAlarmasAndroid { Future programar(AlarmaMusical alarma); Future cancelar(String alarmaId); + + /// Failures the NATIVE side recorded on its own, outside any Dart call: + /// a pre-notice that could not be armed, a refused foreground-service + /// start when the alarm should have rung, and a per-alarm reschedule that + /// failed after a reboot. Each entry carries the alarm id and one of + /// [ExcepcionAlarma]'s `tipoFallo*` constants. + /// + /// Before this existed every one of those paths logged to logcat and + /// stopped there, so an alarm could sit switched on in the list having + /// never reached the OS at all — the user's "as if there were no alarm". + Future>> fallosNativosProgramacion(); Future ocultarNotificacionAlarma(String alarmaId); /// Notification-only dismissal (RES-1): hides the fire notification for @@ -203,6 +243,11 @@ abstract class PuertoAlarmasAndroid { Future obtenerEventoInicial(); Future> obtenerEjecucionesNativasGestionadas(); Future> obtenerEstadoSnoozeNativo(); + + /// Scheduling-reliability failures the native side recorded on its own + /// (pre-notice, foreground-service start, or post-boot reschedule) since + /// the last sync. + Future> obtenerFallosProgramacionNativos(); } class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { @@ -393,6 +438,25 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { } } + @override + Future>> fallosNativosProgramacion() async { + try { + final raw = await _channel.invokeMethod>( + 'getNativeSchedulingFailures', + ); + if (raw == null) return const []; + return raw + .whereType>() + .map((m) => m.map((k, v) => MapEntry(k.toString(), v))) + .toList(); + } catch (e) { + // Never let a diagnostics read break alarm handling: an older build + // of the native side simply has no such channel method. + debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e'); + return const []; + } + } + @override Future solicitarPermisoAlarmasExactas() async { final abierto = await _channel.invokeMethod( @@ -494,6 +558,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { .toList(); } + @override + Future> + obtenerFallosProgramacionNativos() async { + final raw = await _channel.invokeMethod>( + 'getNativeSchedulingFailures', + ); + if (raw == null || raw.isEmpty) return const []; + return raw + .whereType>() + .map(FalloProgramacionNativo.fromMap) + .where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty) + .toList(); + } + Future _logAndInvokeVoid(String method, Map args) { debugPrint('[PluriWave][alarmas] $method $args'); return _channel.invokeMethod(method, args); diff --git a/test/estado/estado_alarmas_fallos_nativos_test.dart b/test/estado/estado_alarmas_fallos_nativos_test.dart new file mode 100644 index 0000000..6e5df37 --- /dev/null +++ b/test/estado/estado_alarmas_fallos_nativos_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes_alarmas.dart'; + +/// The native scheduler records three failures entirely on its own, outside +/// any Dart call: a pre-notice that could not be armed, a refused +/// foreground-service start when the alarm should have rung, and a per-alarm +/// reschedule that failed after a reboot. +/// +/// Before this wiring existed, all three logged to logcat and stopped there. +/// The alarm stayed switched on in the list, drawn exactly as if scheduling +/// had succeeded, and simply never fired — the reported "as if there were no +/// alarm at all". These tests pin the drain path that makes them visible. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakePuertoAlarmasAndroid android; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + android = FakePuertoAlarmasAndroid(); + }); + + EstadoAlarmas crearEstado() => + EstadoAlarmas(android: android, iniciarAutomaticamente: false); + + test('a native pre-notice failure becomes a per-alarm exception', () async { + android.fallosNativos = [ + {'alarmaId': 'a1', 'tipo': ExcepcionAlarma.tipoFalloPreaviso}, + ]; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + final fallo = estado.ultimaExcepcionPara('a1'); + expect(fallo, isNotNull); + expect(fallo!.tipo, ExcepcionAlarma.tipoFalloPreaviso); + }); + + test( + 'a refused foreground-service start becomes a per-alarm exception', + () async { + android.fallosNativos = [ + {'alarmaId': 'a2', 'tipo': ExcepcionAlarma.tipoFalloServicioSonido}, + ]; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + expect( + estado.ultimaExcepcionPara('a2')?.tipo, + ExcepcionAlarma.tipoFalloServicioSonido, + ); + }, + ); + + test('only the alarm whose reschedule failed is marked', () async { + android.fallosNativos = [ + { + 'alarmaId': 'a3', + 'tipo': ExcepcionAlarma.tipoFalloReprogramacionArranque, + }, + ]; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + expect(estado.ultimaExcepcionPara('a3'), isNotNull); + expect( + estado.ultimaExcepcionPara('a4'), + isNull, + reason: 'a sibling alarm that scheduled fine must stay unmarked', + ); + }); + + test('malformed native entries are skipped without throwing', () async { + android.fallosNativos = [ + {'tipo': ExcepcionAlarma.tipoFalloPreaviso}, // no alarmaId + {'alarmaId': 'a5'}, // no tipo + ]; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + expect(estado.ultimaExcepcionPara('a5'), isNull); + }); + + test('a failing native read never surfaces as an alarm error', () async { + // A diagnostics gap must not look like a scheduling problem: an older + // native build simply has no such channel method. + android.fallaLecturaFallosNativos = true; + final estado = crearEstado(); + addTearDown(estado.dispose); + + await estado.cargarFallosNativos(); + + expect(estado.error, isNull); + }); +} diff --git a/test/estado/estado_alarmas_verificacion_registro_test.dart b/test/estado/estado_alarmas_verificacion_registro_test.dart index 93ca350..389eaae 100644 --- a/test/estado/estado_alarmas_verificacion_registro_test.dart +++ b/test/estado/estado_alarmas_verificacion_registro_test.dart @@ -2,6 +2,7 @@ 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:pluriwave/servicios/servicio_alarmas_android.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fakes_alarmas.dart'; @@ -73,4 +74,66 @@ void main() { expect(estado.ultimaExcepcionPara('sana1'), isNull); expect(estado.error, isNull); }); + + test( + 'inicializar importa los fallos nativos (pre-aviso, servicio en primer ' + 'plano, reprogramacion tras arranque) como excepciones por alarma', + () async { + final android = + FakePuertoAlarmasAndroid() + ..fallosProgramacionNativos.addAll([ + FalloProgramacionNativo( + alarmaId: 'con-preaviso-roto', + tipo: ExcepcionAlarma.tipoFalloPreaviso, + ocurridoEn: DateTime(2026, 5, 25, 7), + ), + FalloProgramacionNativo( + alarmaId: 'servicio-rechazado', + tipo: ExcepcionAlarma.tipoFalloServicioSonido, + ocurridoEn: DateTime(2026, 5, 25, 7), + ), + ]); + final servicio = ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 7, 30), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'con-preaviso-roto', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'servicio-rechazado', + nombre: 'Diaria', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final estado = EstadoAlarmas( + servicio: servicio, + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.inicializar(); + + final falloPreaviso = estado.ultimaExcepcionPara('con-preaviso-roto'); + expect(falloPreaviso, isNotNull); + expect(falloPreaviso!.tipo, ExcepcionAlarma.tipoFalloPreaviso); + + final falloServicio = estado.ultimaExcepcionPara('servicio-rechazado'); + expect(falloServicio, isNotNull); + expect(falloServicio!.tipo, ExcepcionAlarma.tipoFalloServicioSonido); + }, + ); } diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index 2d864d7..3ec140e 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -14,6 +14,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { final soloOcultadas = []; final ejecucionesNativas = []; final snoozesNativos = []; + final fallosProgramacionNativos = []; final _eventos = StreamController.broadcast(); bool ignoraOptimizacionBateria = true; int solicitudesExencionBateria = 0; @@ -186,9 +187,33 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { Future> obtenerEstadoSnoozeNativo() async => List.of(snoozesNativos); + @override + Future> + obtenerFallosProgramacionNativos() async => + List.of(fallosProgramacionNativos); + int solicitudesPermisoAlarmasExactas = 0; int solicitudesPermisoPantallaCompleta = 0; + /// Native-recorded failures the next read should return. Tests seed this + /// to simulate a pre-notice that never armed, a refused foreground-service + /// start, or a per-alarm reschedule that failed after a reboot. + List> fallosNativos = const []; + + int lecturasFallosNativos = 0; + + /// Simulates an older native build with no such channel method. + bool fallaLecturaFallosNativos = false; + + @override + Future>> fallosNativosProgramacion() async { + lecturasFallosNativos++; + if (fallaLecturaFallosNativos) { + throw StateError('canal no disponible'); + } + return fallosNativos; + } + @override Future solicitarPermisoAlarmasExactas() async { solicitudesPermisoAlarmasExactas++; diff --git a/test/servicios/servicio_alarmas_fallo_programacion_test.dart b/test/servicios/servicio_alarmas_fallo_programacion_test.dart index 72b0370..c9457f4 100644 --- a/test/servicios/servicio_alarmas_fallo_programacion_test.dart +++ b/test/servicios/servicio_alarmas_fallo_programacion_test.dart @@ -124,11 +124,42 @@ void main() { ExcepcionAlarma.tipoFalloProgramacion, ); - final config = await servicio.limpiarFalloProgramacion('a1'); + final config = await servicio.limpiarFalloProgramacion( + 'a1', + ExcepcionAlarma.tipoFalloProgramacion, + ); expect(config.excepciones, isEmpty); }); + test('limpiarFalloProgramacion NO limpia un fallo de un tipo distinto ' + '(cada subsistema se limpia por su cuenta)', () async { + final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloPreaviso, + ); + + final config = await servicio.limpiarFalloProgramacion( + 'a1', + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(1)); + expect(config.excepciones.single.tipo, ExcepcionAlarma.tipoFalloPreaviso); + }); + test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste ' 'cambios', () async { final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)); @@ -143,7 +174,10 @@ void main() { ), ); - final config = await servicio.limpiarFalloProgramacion('a1'); + final config = await servicio.limpiarFalloProgramacion( + 'a1', + ExcepcionAlarma.tipoFalloProgramacion, + ); expect(config.excepciones, isEmpty); });