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 8cdb7c3..77937d0 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'; @@ -117,8 +118,11 @@ class EstadoAlarmas extends ChangeNotifier { '[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}', ); await android.programar(guardada); + await _limpiarFalloProgramacion(guardada.id); + await _verificarRegistroNativo(guardada.id); } catch (e) { _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -198,6 +202,89 @@ class EstadoAlarmas extends ChangeNotifier { } } + /// Records a main-alarm scheduling failure per-alarm (fix/alarmas-fallos- + /// silenciosos): before this, a failed `android.programar` call only set + /// the transient, alarm-agnostic [_error] string — the alarms list had no + /// way to mark the SPECIFIC card affected, so a failed alarm rendered + /// exactly like a working one. Never rethrows: a failure recording its own + /// failure must not mask the ORIGINAL scheduling error already captured in + /// [_error]. + Future _registrarFalloProgramacion( + String alarmaId, { + String tipo = ExcepcionAlarma.tipoFalloProgramacion, + }) async { + try { + final alarma = _buscarAlarma(alarmaId); + final ejecucion = alarma?.proximaProgramable ?? servicio.ahora(); + final config = await servicio.registrarFalloProgramacion( + alarmaId, + ejecucion, + tipo, + ); + _aplicar(config); + } catch (e) { + debugPrint('[PluriWave][alarmas] registrar fallo programacion ERROR $e'); + } + } + + /// 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). 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 { + 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'); + } + } + + /// Verifies the OS genuinely registered [alarmaId] after a successful + /// `android.programar` call (fix/alarmas-fallos-silenciosos, item 3): a + /// scheduling call that returns without throwing is not proof enough by + /// itself -- this cross-check against the native pending-alarm count is + /// exactly what would have caught the reported "alarm never rings, no + /// exception anywhere" case. Compares a FRESH native count against how + /// many alarms Dart believes are currently active-with-a-next-run; a + /// native count that falls short is recorded as a failure for the alarm + /// the user just interacted with. Never overrides an already-caught + /// programar() exception (this only runs on ITS success path). + Future _verificarRegistroNativo(String alarmaId) async { + try { + final alarma = _buscarAlarma(alarmaId); + if (alarma == null || + !alarma.activa || + alarma.proximaProgramable == null) { + return; + } + final diag = await android.diagnostico(); + _diagnostico = diag; + final esperadas = + _alarmas + .where((a) => a.activa && a.proximaProgramable != null) + .length; + if (diag.alarmasNativasPendientes < esperadas) { + _error = + 'Alarma guardada, pero el sistema no confirma que quedó registrada.'; + await _registrarFalloProgramacion(alarmaId); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] verificar registro nativo ERROR $e'); + } + } + Future cambiarActiva(AlarmaMusical alarma, bool activa) async { await guardarAlarma(alarma.copyWith(activa: activa)); } @@ -267,10 +354,12 @@ class EstadoAlarmas extends ChangeNotifier { if (actualizada != null) { await _solicitarPermisosNecesariosParaAlarma(); await android.programar(actualizada); + await _limpiarFalloProgramacion(alarma.id); } } catch (e) { _error = 'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -298,10 +387,12 @@ class EstadoAlarmas extends ChangeNotifier { if (actualizada != null) { await _solicitarPermisosNecesariosParaAlarma(); await android.programar(actualizada); + await _limpiarFalloProgramacion(alarma.id); } } catch (e) { _error = 'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e'; + await _registrarFalloProgramacion(alarma.id); } notifyListeners(); } @@ -447,6 +538,36 @@ 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) { + await _registrarFalloProgramacion(fallo.alarmaId, tipo: fallo.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. @@ -546,6 +667,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 @@ -622,8 +797,24 @@ class EstadoAlarmas extends ChangeNotifier { if (_alarmas.any((alarma) => alarma.activa)) { await _solicitarPermisosNecesariosParaAlarma(); } + // Per-alarm try/catch (fix/alarmas-fallos-silenciosos): before this, a + // SINGLE alarm's `programar` throw aborted the whole loop, so every + // sibling AFTER the failing one in `_alarmas` silently never reached + // `android.programar` on this pass -- on a fresh launch (`inicializar`) + // that meant some active alarms were never (re)armed with the OS at all, + // with nothing to show for it beyond a generic load error. Each alarm + // now gets its own outcome recorded, and one failure never blocks its + // siblings. for (final alarma in _alarmas) { - await android.programar(alarma); + try { + await android.programar(alarma); + await _limpiarFalloProgramacion(alarma.id); + } catch (e) { + debugPrint( + '[PluriWave][alarmas] sincronizar todas ERROR id=${alarma.id} $e', + ); + await _registrarFalloProgramacion(alarma.id); + } } } diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index b649158..ba591d7 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة", + "alarmCardSchedulingFailedMessage": "لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.", + "alarmCardPreNoticeFailedMessage": "تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.", "alarmDiagnosticsExactAlarmsTitle": "جدولة المنبه بدقة", "alarmDiagnosticsExactAlarmsHint": "يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.", "alarmDiagnosticsNotificationsTitle": "الإشعارات", diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 11615c1..66eba91 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "ছুটির কারণে বিরত", + "alarmCardSchedulingFailedMessage": "এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।", + "alarmCardPreNoticeFailedMessage": "এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।", "alarmDiagnosticsExactAlarmsTitle": "নির্ভুল অ্যালার্ম শিডিউলিং", "alarmDiagnosticsExactAlarmsHint": "ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।", "alarmDiagnosticsNotificationsTitle": "বিজ্ঞপ্তি", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d972c03..4e3b18b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Wegen Urlaub pausiert", + "alarmCardSchedulingFailedMessage": "Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.", + "alarmCardPreNoticeFailedMessage": "Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.", "alarmDiagnosticsExactAlarmsTitle": "Genaue Alarmplanung", "alarmDiagnosticsExactAlarmsHint": "Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.", "alarmDiagnosticsNotificationsTitle": "Benachrichtigungen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 097fcca..7a3e740 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Paused for vacation", + "alarmCardSchedulingFailedMessage": "This alarm could not be registered with the system, so it may not ring.", + "alarmCardPreNoticeFailedMessage": "This alarm is scheduled, but its early reminder could not be set.", "alarmDiagnosticsExactAlarmsTitle": "Exact alarm scheduling", "alarmDiagnosticsExactAlarmsHint": "Lets the alarm ring at the exact minute you set, even while the phone is asleep.", "alarmDiagnosticsNotificationsTitle": "Notifications", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2e0f8c7..696c698 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -801,6 +801,8 @@ } }, "alarmCardVacationPausedBadge": "Pausada por vacaciones", + "alarmCardSchedulingFailedMessage": "Esta alarma no se pudo registrar en el sistema, así que podría no sonar.", + "alarmCardPreNoticeFailedMessage": "Esta alarma está programada, pero no se pudo activar su aviso previo.", "alarmDiagnosticsExactAlarmsTitle": "Programación de alarma exacta", "alarmDiagnosticsExactAlarmsHint": "Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.", "alarmDiagnosticsNotificationsTitle": "Notificaciones", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9267ffb..e1abd72 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "En pause pour les vacances", + "alarmCardSchedulingFailedMessage": "Cette alarme n'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.", + "alarmCardPreNoticeFailedMessage": "Cette alarme est programmée, mais son rappel anticipé n'a pas pu être activé.", "alarmDiagnosticsExactAlarmsTitle": "Programmation d'alarme précise", "alarmDiagnosticsExactAlarmsHint": "Permet à l'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.", "alarmDiagnosticsNotificationsTitle": "Notifications", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index d08ec56..3addd01 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया", + "alarmCardSchedulingFailedMessage": "यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।", + "alarmCardPreNoticeFailedMessage": "यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।", "alarmDiagnosticsExactAlarmsTitle": "सटीक अलार्म शेड्यूलिंग", "alarmDiagnosticsExactAlarmsHint": "फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।", "alarmDiagnosticsNotificationsTitle": "सूचनाएं", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index dcb8c90..7af1ab3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Dijeda karena liburan", + "alarmCardSchedulingFailedMessage": "Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.", + "alarmCardPreNoticeFailedMessage": "Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.", "alarmDiagnosticsExactAlarmsTitle": "Penjadwalan alarm presisi", "alarmDiagnosticsExactAlarmsHint": "Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.", "alarmDiagnosticsNotificationsTitle": "Notifikasi", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index aeb9ee9..5677f83 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "In pausa per le vacanze", + "alarmCardSchedulingFailedMessage": "Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.", + "alarmCardPreNoticeFailedMessage": "Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.", "alarmDiagnosticsExactAlarmsTitle": "Programmazione sveglia esatta", "alarmDiagnosticsExactAlarmsHint": "Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.", "alarmDiagnosticsNotificationsTitle": "Notifiche", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d0151af..444554b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "休暇のため一時停止中", + "alarmCardSchedulingFailedMessage": "このアラームはシステムに登録できなかったため、鳴らない可能性があります。", + "alarmCardPreNoticeFailedMessage": "このアラームは設定されていますが、事前通知を設定できませんでした。", "alarmDiagnosticsExactAlarmsTitle": "正確なアラームのスケジュール設定", "alarmDiagnosticsExactAlarmsHint": "スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。", "alarmDiagnosticsNotificationsTitle": "通知", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b234893..2b41a67 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Pausada por férias", + "alarmCardSchedulingFailedMessage": "Este alarme não pôde ser registrado no sistema, por isso pode não tocar.", + "alarmCardPreNoticeFailedMessage": "Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.", "alarmDiagnosticsExactAlarmsTitle": "Agendamento exato do alarme", "alarmDiagnosticsExactAlarmsHint": "Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.", "alarmDiagnosticsNotificationsTitle": "Notificações", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 32e3f7b..c370a09 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "Приостановлено на время отпуска", + "alarmCardSchedulingFailedMessage": "Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.", + "alarmCardPreNoticeFailedMessage": "Этот будильник запланирован, но не удалось настроить предварительное напоминание.", "alarmDiagnosticsExactAlarmsTitle": "Точное планирование будильника", "alarmDiagnosticsExactAlarmsHint": "Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.", "alarmDiagnosticsNotificationsTitle": "Уведомления", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0a5d154..f27a9cc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -842,6 +842,8 @@ } }, "alarmCardVacationPausedBadge": "因假期已暂停", + "alarmCardSchedulingFailedMessage": "该闹钟未能在系统中注册,因此可能不会响铃。", + "alarmCardPreNoticeFailedMessage": "该闹钟已设置,但其提前提醒未能设置成功。", "alarmDiagnosticsExactAlarmsTitle": "精确闹钟排程", "alarmDiagnosticsExactAlarmsHint": "即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。", "alarmDiagnosticsNotificationsTitle": "通知", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 22bef5f..81cac10 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -3074,6 +3074,18 @@ abstract class AppLocalizations { /// **'Pausada por vacaciones'** String get alarmCardVacationPausedBadge; + /// No description provided for @alarmCardSchedulingFailedMessage. + /// + /// In es, this message translates to: + /// **'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.'** + String get alarmCardSchedulingFailedMessage; + + /// No description provided for @alarmCardPreNoticeFailedMessage. + /// + /// In es, this message translates to: + /// **'Esta alarma está programada, pero no se pudo activar su aviso previo.'** + String get alarmCardPreNoticeFailedMessage; + /// No description provided for @alarmDiagnosticsExactAlarmsTitle. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 40605e7..5726266 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1691,6 +1691,14 @@ class AppLocalizationsAr extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة'; + @override + String get alarmCardSchedulingFailedMessage => + 'لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'جدولة المنبه بدقة'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index ae3f5aa..c7939b9 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1700,6 +1700,14 @@ class AppLocalizationsBn extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত'; + @override + String get alarmCardSchedulingFailedMessage => + 'এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'নির্ভুল অ্যালার্ম শিডিউলিং'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 3be5612..a6c33af 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1713,6 +1713,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert'; + @override + String get alarmCardSchedulingFailedMessage => + 'Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Genaue Alarmplanung'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 19433cc..d73df11 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1693,6 +1693,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Paused for vacation'; + @override + String get alarmCardSchedulingFailedMessage => + 'This alarm could not be registered with the system, so it may not ring.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'This alarm is scheduled, but its early reminder could not be set.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Exact alarm scheduling'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 1a201ea..cc6fc0d 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1707,6 +1707,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Pausada por vacaciones'; + @override + String get alarmCardSchedulingFailedMessage => + 'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Esta alarma está programada, pero no se pudo activar su aviso previo.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Programación de alarma exacta'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 2c935cf..a0e0a48 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1716,6 +1716,14 @@ class AppLocalizationsFr extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'En pause pour les vacances'; + @override + String get alarmCardSchedulingFailedMessage => + 'Cette alarme n\'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Cette alarme est programmée, mais son rappel anticipé n\'a pas pu être activé.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Programmation d\'alarme précise'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index b8fd3de..c6bf8fb 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1695,6 +1695,14 @@ class AppLocalizationsHi extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया'; + @override + String get alarmCardSchedulingFailedMessage => + 'यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'सटीक अलार्म शेड्यूलिंग'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index d8b2eca..3a3c234 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1703,6 +1703,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Dijeda karena liburan'; + @override + String get alarmCardSchedulingFailedMessage => + 'Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Penjadwalan alarm presisi'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 4c8e8f8..ad1a334 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1715,6 +1715,14 @@ class AppLocalizationsIt extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'In pausa per le vacanze'; + @override + String get alarmCardSchedulingFailedMessage => + 'Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Programmazione sveglia esatta'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 76b4d03..c6f045b 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1646,6 +1646,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get alarmCardVacationPausedBadge => '休暇のため一時停止中'; + @override + String get alarmCardSchedulingFailedMessage => + 'このアラームはシステムに登録できなかったため、鳴らない可能性があります。'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'このアラームは設定されていますが、事前通知を設定できませんでした。'; + @override String get alarmDiagnosticsExactAlarmsTitle => '正確なアラームのスケジュール設定'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 9067f87..88f3343 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1703,6 +1703,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Pausada por férias'; + @override + String get alarmCardSchedulingFailedMessage => + 'Este alarme não pôde ser registrado no sistema, por isso pode não tocar.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Agendamento exato do alarme'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 9559e89..aceb85b 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1707,6 +1707,14 @@ class AppLocalizationsRu extends AppLocalizations { @override String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска'; + @override + String get alarmCardSchedulingFailedMessage => + 'Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.'; + + @override + String get alarmCardPreNoticeFailedMessage => + 'Этот будильник запланирован, но не удалось настроить предварительное напоминание.'; + @override String get alarmDiagnosticsExactAlarmsTitle => 'Точное планирование будильника'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 71638d3..c5a2dde 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1638,6 +1638,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get alarmCardVacationPausedBadge => '因假期已暂停'; + @override + String get alarmCardSchedulingFailedMessage => '该闹钟未能在系统中注册,因此可能不会响铃。'; + + @override + String get alarmCardPreNoticeFailedMessage => '该闹钟已设置,但其提前提醒未能设置成功。'; + @override String get alarmDiagnosticsExactAlarmsTitle => '精确闹钟排程'; diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart index f57cff4..9e5983f 100644 --- a/lib/modelos/alarma_musical.dart +++ b/lib/modelos/alarma_musical.dart @@ -302,6 +302,43 @@ class ExcepcionAlarma { final DateTime ejecucion; final String tipo; + /// User-requested skip of the next occurrence (the only [tipo] this model + /// originally supported). `ServicioProgramacionAlarmas._esValida` only + /// treats THIS tipo as an actual schedule skip -- every tipo below records + /// a scheduling-reliability failure and must never affect which occurrence + /// fires next. + static const tipoSaltoSiguiente = 'skipNext'; + + /// The main alarm registration with the OS failed (`android.programar` + /// threw). Recorded per-alarm so the alarms list can mark the exact card + /// affected instead of only a transient, alarm-agnostic app-wide message. + static const tipoFalloProgramacion = 'schedulingFailed'; + + /// The main alarm registered successfully but its 30-minute pre-notice + /// reminder did not (native `SecurityException` scheduling the pre-notice + /// alone) -- distinguished from [tipoFalloProgramacion] because the alarm + /// itself will still ring; only the early warning is missing. + static const tipoFalloPreaviso = 'preNoticeFailed'; + + /// The OS refused to start the foreground ringing service when the alarm + /// fired (e.g. a background-restricted app), so the alarm never actually + /// rang even though it was armed. + static const tipoFalloServicioSonido = 'foregroundServiceFailed'; + + /// A per-alarm reschedule after boot/unlock failed while sibling alarms + /// succeeded, leaving this one specific alarm unscheduled. + static const tipoFalloReprogramacionArranque = 'rescheduleAfterBootFailed'; + + /// Every tipo above that represents a reliability FAILURE rather than a + /// deliberate user action -- used by the UI to decide whether to mark a + /// card, and by [ServicioAlarmas] to know which prior record to replace. + static const tiposFallo = { + tipoFalloProgramacion, + tipoFalloPreaviso, + tipoFalloServicioSonido, + tipoFalloReprogramacionArranque, + }; + Map toJson() => { 'alarmaId': alarmaId, 'ejecucion': ejecucion.toIso8601String(), diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 79edcc1..15da5bd 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -286,6 +286,20 @@ class _TarjetaAlarma extends StatelessWidget { if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge, ]; + // fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was + // never read from any screen, so a failed native scheduling attempt (main + // alarm, pre-notice, foreground service, or a post-boot reschedule) + // rendered exactly like a healthy alarm -- switched on, no visible sign + // anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente` + // as a real skip, so any OTHER tipo found here is a reliability failure, + // never a deliberate user action. + final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id); + final fallo = + ultimaExcepcion != null && + ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo) + ? ultimaExcepcion + : null; + return Dismissible( key: ValueKey('tarjeta-alarma-${alarma.id}'), direction: DismissDirection.horizontal, @@ -417,6 +431,14 @@ class _TarjetaAlarma extends StatelessWidget { ), ), ], + if (fallo != null) ...[ + const SizedBox(height: 6), + _AvisoFalloProgramacion( + alarmaId: alarma.id, + esSoloPreaviso: + fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso, + ), + ], ], ), ), @@ -470,6 +492,82 @@ class _TarjetaAlarma extends StatelessWidget { } } +/// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos): +/// renders INSIDE the card's own content, in its own small tap target -- +/// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe = +/// delete) are untouched; this inner `InkWell` only claims its own region +/// and pushes the diagnostics screen instead of opening the editor. +class _AvisoFalloProgramacion extends StatelessWidget { + const _AvisoFalloProgramacion({ + required this.alarmaId, + required this.esSoloPreaviso, + }); + + final String alarmaId; + + /// True when only the pre-notice reminder failed (the alarm itself is + /// still scheduled) -- the user's reported symptom explicitly called out + /// a missing pre-notice as distinct from the alarm never ringing at all, + /// so the message must not conflate the two. + final bool esSoloPreaviso; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final color = Theme.of(context).colorScheme.error; + return Material( + type: MaterialType.transparency, + child: InkWell( + key: ValueKey('tarjeta-alarma-fallo-$alarmaId'), + borderRadius: BorderRadius.circular(8), + onTap: () => _abrirDiagnostico(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, size: 15, color: color), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + esSoloPreaviso + ? l10n.alarmCardPreNoticeFailedMessage + : l10n.alarmCardSchedulingFailedMessage, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + l10n.androidReliabilityReview, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: color, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + void _abrirDiagnostico(BuildContext context) { + PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas()); + } +} + /// Swipe-to-delete reveal, shown on both sides so either swipe direction /// works regardless of locale text direction. class _FondoSwipeEliminarAlarma extends StatelessWidget { diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index adadf0b..8bcfc5d 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -350,6 +350,73 @@ class ServicioAlarmas { return nuevo; }); + /// Records a scheduling-reliability failure for [alarmaId] (fix/alarmas- + /// fallos-silenciosos): reuses the SAME `ExcepcionAlarma` model + /// `saltarProxima` already persists, so `EstadoAlarmas.ultimaExcepcionPara` + /// surfaces it on the exact alarm card affected instead of only a + /// transient, alarm-agnostic message. A previous FAILURE record for the + /// same alarm is replaced (only the latest attempt's outcome matters) -- + /// any `skipNext` exception for this or other alarms is left untouched. + /// Never affects scheduling: `ServicioProgramacionAlarmas._esValida` only + /// treats `tipoSaltoSiguiente` as an actual skip. + Future registrarFalloProgramacion( + String alarmaId, + DateTime ejecucion, + String tipo, + ) => _enCola(() async { + final config = await _configActual(); + final excepciones = [ + ..._sinFalloPrevio(config.excepciones, alarmaId), + ExcepcionAlarma(alarmaId: alarmaId, ejecucion: ejecucion, tipo: tipo), + ]; + final nuevo = ConfiguracionAlarmas( + alarmas: config.alarmas, + vacaciones: config.vacaciones, + excepciones: excepciones, + ); + await _guardar(nuevo); + return nuevo; + }); + + /// 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 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: excepciones, + ); + await _guardar(nuevo); + return nuevo; + }); + + List _sinFalloPrevio( + List excepciones, + String alarmaId, + ) => + excepciones + .where( + (e) => + !(e.alarmaId == alarmaId && + ExcepcionAlarma.tiposFallo.contains(e.tipo)), + ) + .toList(); + Future posponerEjecucion( String alarmaId, DateTime ejecucion, diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 8c8b0cf..d854ee3 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,24 @@ 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". + /// + /// Returns the typed model rather than raw maps ON PURPOSE: + /// [FalloProgramacionNativo.fromMap] is the single place the native key + /// names (`alarmId`/`type`/`atMillis`) appear. Consuming raw maps here + /// once silently dropped every entry, because the caller guessed Spanish + /// key names and the fake was seeded with the same guess — the test + /// confirmed the mistake instead of catching it. + Future> fallosNativosProgramacion(); Future ocultarNotificacionAlarma(String alarmaId); /// Notification-only dismissal (RES-1): hides the fire notification for @@ -203,6 +250,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 +445,26 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { } } + @override + Future> fallosNativosProgramacion() async { + try { + final raw = await _channel.invokeMethod>( + 'getNativeSchedulingFailures', + ); + if (raw == null) return const []; + return raw + .whereType>() + .map(FalloProgramacionNativo.fromMap) + .where((f) => f.alarmaId.isNotEmpty && f.tipo.isNotEmpty) + .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 +566,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/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart index fc01b8a..9b7f7cc 100644 --- a/lib/servicios/servicio_programacion_alarmas.dart +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -150,9 +150,15 @@ class ServicioProgramacionAlarmas { if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) { return false; } + // Only a deliberate user skip ever removes a candidate occurrence. + // Reliability-failure records share this same list/model (so the alarms + // list can surface them per-alarm via `ultimaExcepcionPara`), but they + // must never be mistaken for a skip — that would silently jump the + // alarm to its NEXT occurrence instead of just flagging the failed one. return !excepciones.any( (excepcion) => excepcion.alarmaId == alarma.id && + excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente && _mismaEjecucion(excepcion.ejecucion, candidato), ); } 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..53567e3 --- /dev/null +++ b/test/estado/estado_alarmas_fallos_nativos_test.dart @@ -0,0 +1,112 @@ +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_android.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. +/// +/// They deliberately build fixtures through [FalloProgramacionNativo.fromMap] +/// using the REAL native key names (`alarmId`/`type`/`atMillis`, see +/// `AlarmScheduler.kt:1389-1391`). An earlier draft seeded the fake with +/// guessed Spanish keys and consumed the same guess, so every entry would +/// have been silently dropped in production while the tests stayed green. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakePuertoAlarmasAndroid android; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + android = FakePuertoAlarmasAndroid(); + }); + + EstadoAlarmas crearEstado() => + EstadoAlarmas(android: android, iniciarAutomaticamente: false); + + /// Mirrors exactly what the native side puts on the channel. + FalloProgramacionNativo falloNativo(String alarmaId, String tipo) => + FalloProgramacionNativo.fromMap({ + 'alarmId': alarmaId, + 'type': tipo, + 'atMillis': 1700000000000, + }); + + test('the fixture helper decodes the real native key names', () { + final fallo = falloNativo('a0', ExcepcionAlarma.tipoFalloPreaviso); + + expect(fallo.alarmaId, 'a0'); + expect(fallo.tipo, ExcepcionAlarma.tipoFalloPreaviso); + }); + + test('a native pre-notice failure becomes a per-alarm exception', () async { + android.fallosNativos = [ + falloNativo('a1', 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 = [ + falloNativo('a2', 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 = [ + falloNativo('a3', 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('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_test.dart b/test/estado/estado_alarmas_test.dart index b320f24..417c9c7 100644 --- a/test/estado/estado_alarmas_test.dart +++ b/test/estado/estado_alarmas_test.dart @@ -611,6 +611,145 @@ void main() { }, ); + test( + 'guardarAlarma: cuando android.programar falla, marca la alarma con una ' + 'excepcion de fallo visible via ultimaExcepcionPara', + () async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'fallo1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + final excepcion = estado.ultimaExcepcionPara('fallo1'); + expect(excepcion, isNotNull); + expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion); + expect(estado.error, isNotNull); + }, + ); + + test( + 'guardarAlarma: un reintento exitoso limpia la excepcion de fallo previa', + () async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + final alarma = const AlarmaMusical( + id: 'fallo2', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ); + await estado.guardarAlarma(alarma); + expect(estado.ultimaExcepcionPara('fallo2'), isNotNull); + + android.fallaProgramar = false; + await estado.guardarAlarma(estado.alarmas.single); + + expect(estado.ultimaExcepcionPara('fallo2'), isNull); + }, + ); + + test( + 'guardarAlarma en el camino feliz nunca registra una excepcion de fallo', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ok1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(estado.ultimaExcepcionPara('ok1'), isNull); + expect(estado.error, isNull); + }, + ); + + test( + 'inicializar: un fallo de programacion en UNA alarma no aborta la ' + 'sincronizacion de las demas (S-sincronizarTodas continua tras error)', + () async { + final android = FakePuertoAlarmasAndroid() + ..idsFallanProgramar.add('rota'); + final servicio = ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 6, 0), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'rota', + nombre: 'Rota', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'sana', + nombre: 'Sana', + 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(); + + expect( + android.programadas.map((a) => a.id), + contains('sana'), + reason: + 'la alarma sana debe seguir programandose aunque la rota falle', + ); + expect(estado.ultimaExcepcionPara('rota'), isNotNull); + expect(estado.ultimaExcepcionPara('sana'), isNull); + }, + ); + group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () { test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye ' '"ahora" (dias restantes derivables de finDia), o null si ninguno ' diff --git a/test/estado/estado_alarmas_verificacion_registro_test.dart b/test/estado/estado_alarmas_verificacion_registro_test.dart new file mode 100644 index 0000000..389eaae --- /dev/null +++ b/test/estado/estado_alarmas_verificacion_registro_test.dart @@ -0,0 +1,139 @@ +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'; + +/// fix/alarmas-fallos-silenciosos, item 3: "verify the alarm is actually +/// registered, and say so if it is not". `android.programar` returning +/// without throwing is not proof enough by itself -- this is the check that +/// would have caught the reported case immediately (the native side can +/// silently fail to persist the registration even when the channel call +/// itself reports success). +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('guardarAlarma detecta que el conteo nativo no refleja la alarma ' + 'guardada, aunque android.programar no haya lanzado', () async { + // Fixed at 0 regardless of what programar() does internally -- + // simulates the native side accepting the channel call but never + // actually persisting the registration. + final android = FakePuertoAlarmasAndroid()..alarmasNativasPendientes = 0; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'silenciosa1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + final excepcion = estado.ultimaExcepcionPara('silenciosa1'); + expect(excepcion, isNotNull); + expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion); + expect(estado.error, isNotNull); + }); + + test('guardarAlarma en el camino feliz (conteo nativo coincide) no registra ' + 'fallo alguno', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'sana1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + 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 0295138..b9a72d2 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; @@ -26,10 +27,27 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { bool puedeProgramarExactas = true; bool notificacionesPermitidas = true; bool puedeUsarPantallaCompleta = true; - int alarmasNativasPendientes = 0; String fabricante = 'test'; int versionSdk = 35; + /// Ids [programar] most recently scheduled as active-with-a-next-run (kept + /// in sync with [cancelar] too), mirroring the real native scheduler's own + /// pending-alarm registry (fix/alarmas-fallos-silenciosos, item 3: "verify + /// the alarm is actually registered"). Backs [alarmasNativasPendientes]'s + /// DEFAULT so a test that never touches that field gets a value that + /// tracks reality instead of a frozen `0` -- a test that explicitly + /// assigns the field (many `pantalla_diagnostico_alarmas_test.dart` cases + /// do, to model a stale/corrupt native count on purpose) keeps getting + /// EXACTLY that value regardless of what programar/cancelar do afterward. + final _idsRegistradosNativamente = {}; + int? _alarmasNativasPendientesFijado; + + int get alarmasNativasPendientes => + _alarmasNativasPendientesFijado ?? _idsRegistradosNativamente.length; + + set alarmasNativasPendientes(int valor) => + _alarmasNativasPendientesFijado = valor; + /// Test-only failure switch (diagnostics screen, "intent not resolving" /// coverage): when true, every `abrir*`/`solicitar*` system-screen action /// below reports failure (as a real device does when a ROM lacks that @@ -41,6 +59,12 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { /// could not otherwise produce. bool fallaProgramar = false; + /// Test-only PER-ALARM failure switch (fix/alarmas-fallos-silenciosos): + /// [programar] throws only for ids in this set, letting a test simulate + /// one alarm failing to schedule while its siblings succeed -- the global + /// [fallaProgramar] switch cannot express that (it fails everything). + final Set idsFallanProgramar = {}; + /// Test-only failure switch: when true, [detenerSonidoActivo] reports an /// unconfirmed/failed stop instead of a confirmed one. bool fallaDetener = false; @@ -73,15 +97,21 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { @override Future programar(AlarmaMusical alarma) async { - if (fallaProgramar) { + if (fallaProgramar || idsFallanProgramar.contains(alarma.id)) { throw StateError('fake programar failure'); } programadas.add(alarma); + if (alarma.activa && alarma.proximaProgramable != null) { + _idsRegistradosNativamente.add(alarma.id); + } else { + _idsRegistradosNativamente.remove(alarma.id); + } } @override Future cancelar(String alarmaId) async { canceladas.add(alarmaId); + _idsRegistradosNativamente.remove(alarmaId); } @override @@ -157,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/pantallas/pantalla_alarmas_fallo_programacion_test.dart b/test/pantallas/pantalla_alarmas_fallo_programacion_test.dart new file mode 100644 index 0000000..f3d110c --- /dev/null +++ b/test/pantallas/pantalla_alarmas_fallo_programacion_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; +import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +/// Card-level visibility for a failed scheduling attempt (fix/alarmas- +/// fallos-silenciosos): before this, `ultimaExcepcionPara` existed but was +/// never read from any screen, so a failed alarm rendered exactly like a +/// working one -- switched on, no visible sign anything was wrong. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future montarPantalla( + WidgetTester tester, + EstadoAlarmas estadoAlarmas, + ) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final radio = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider.value(value: estadoAlarmas), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: PantallaAlarmas()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + testWidgets( + 'una alarma cuya programacion fallo muestra un aviso en su tarjeta', + (tester) async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'r1', + nombre: 'Rota', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + await montarPantalla(tester, estadoAlarmas); + + expect( + find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'una alarma programada correctamente NO muestra el aviso de fallo', + (tester) async { + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'ok1', + nombre: 'Sana', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + await montarPantalla(tester, estadoAlarmas); + + expect( + find.byKey(const ValueKey('tarjeta-alarma-fallo-ok1')), + findsNothing, + ); + }, + ); + + testWidgets('el aviso de fallo abre la pantalla de diagnostico al tocarlo', ( + tester, + ) async { + final android = FakePuertoAlarmasAndroid()..fallaProgramar = true; + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'r1', + nombre: 'Rota', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + await montarPantalla(tester, estadoAlarmas); + + await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-fallo-r1'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(PantallaDiagnosticoAlarmas), findsOneWidget); + }); +} diff --git a/test/servicios/servicio_alarmas_fallo_programacion_test.dart b/test/servicios/servicio_alarmas_fallo_programacion_test.dart new file mode 100644 index 0000000..c9457f4 --- /dev/null +++ b/test/servicios/servicio_alarmas_fallo_programacion_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Coverage for the scheduling-reliability failure records (fix/alarmas- +/// fallos-silenciosos): a failed `android.programar` call is no longer only +/// a transient, alarm-agnostic `EstadoAlarmas.error` string -- it is also +/// recorded per-alarm through the SAME `ExcepcionAlarma` model `saltarProxima` +/// already uses, so `EstadoAlarmas.ultimaExcepcionPara` can surface it on the +/// exact card affected. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('registrarFalloProgramacion agrega una excepcion de fallo para la ' + 'alarma indicada', () 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 [], + ), + ); + + final config = await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 25, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(1)); + expect(config.excepciones.single.alarmaId, 'a1'); + expect( + config.excepciones.single.tipo, + ExcepcionAlarma.tipoFalloProgramacion, + ); + }); + + test('registrarFalloProgramacion reemplaza un fallo previo de la MISMA ' + 'alarma en vez de acumular', () 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.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 26, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(1)); + expect( + config.excepciones.single.tipo, + ExcepcionAlarma.tipoFalloProgramacion, + ); + }); + + test('registrarFalloProgramacion NUNCA toca las excepciones skipNext de ' + 'otras alarmas ni de la misma', () 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.saltarProxima('a1'); + + final config = await servicio.registrarFalloProgramacion( + 'a1', + DateTime(2026, 5, 26, 7, 30), + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, hasLength(2)); + expect( + config.excepciones.map((e) => e.tipo), + containsAll([ + ExcepcionAlarma.tipoSaltoSiguiente, + ExcepcionAlarma.tipoFalloProgramacion, + ]), + ); + }); + + test('limpiarFalloProgramacion elimina el fallo registrado para esa ' + 'alarma', () 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.tipoFalloProgramacion, + ); + + 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)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'a1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final config = await servicio.limpiarFalloProgramacion( + 'a1', + ExcepcionAlarma.tipoFalloProgramacion, + ); + + expect(config.excepciones, isEmpty); + }); +} diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart index eb96415..015e4c8 100644 --- a/test/servicios/servicio_programacion_alarmas_test.dart +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -75,6 +75,42 @@ void main() { expect(proxima, DateTime(2026, 5, 23, 9)); }); + test( + 'un registro de fallo de programacion NO omite esa ejecucion (solo ' + 'skipNext debe hacerlo)', + () { + final alarma = AlarmaMusical( + id: 'a4', + nombre: 'Diaria', + hora: 9, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + final ocurrencia = DateTime(2026, 5, 22, 9); + + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 5, 22, 8), + excepciones: [ + ExcepcionAlarma( + alarmaId: 'a4', + ejecucion: ocurrencia, + tipo: ExcepcionAlarma.tipoFalloProgramacion, + ), + ], + ); + + expect( + proxima, + ocurrencia, + reason: + 'un fallo de programacion registrado no debe comportarse ' + 'como un salto de usuario', + ); + }, + ); + test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () { expect( servicio.calcularSnooze(DateTime(2026, 5, 21, 7), 10),