From 29f7d54e855bfb2a0c8a4d603416b7f2da6de6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Bautista=20Fern=C3=A1ndez?= Date: Wed, 22 Jul 2026 23:52:28 +0200 Subject: [PATCH] fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A) Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes, only uninstall silenced it) plus systematic hardening of every stop path. Native (Kotlin): - Verified stop: stopActiveAlarm now derives its result from the real post-teardown state (companion instance + synchronous stopEverything + activeRingingId check) instead of reporting unconditional success. - Atomic teardown: every stop path (stop action, notification button, snooze, missed, onDestroy, startForeground failure) funnels through one stopEverything() covering audio, wakelock, notification, foreground state and firing-record cleanup; player.release() guarded. - Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a FIRED->MISSED transition with a localized missed-alarm notification; repeating alarms keep their native rearm, deleted alarms never produce ghost MISSED notifications. - Durable firing record with onStartCommand re-validation (resurrection guard) and boot-time stale cleanup; firing records cleared on every refuse/mismatch/cancel path. - New notification-only dismissal channel (dismissAlarmNotificationOnly) so UI-level dedup can never kill a live ring's audio. Flutter (Dart): - Stop/disable/edit/delete of a ringing alarm always attempt to silence it; on native-query failure the stop falls back toward silence via the id-scoped legacy stop. - Verified-stop results surface failures: the ringing screen keeps dismiss-by-design on success, but on a verified failure it stays up with a persistent force-stop banner (guarded against double-dismiss) and auto-dismisses if the ring ends externally (missed/notification). - Missed events sync alarm bookkeeping without opening the ringing UI. - 4 new l10n keys translated across all 13 locales (ARB guard green). 550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds (2 deterministic + 1 refuter-corroborated critical fixed); formal gentle-ai receipt waived by maintainer authorization (correction scope legitimately exceeded the frozen genesis paths). On-device QA checklist in openspec/changes/alarm-system-overhaul/tasks.md pending before archive. --- .../pluriwave/AlarmNotificationStrings.kt | 14 + .../freetimelab/pluriwave/AlarmScheduler.kt | 157 ++++++++ .../es/freetimelab/pluriwave/MainActivity.kt | 37 ++ .../pluriwave/PluriWaveAlarmReceiver.kt | 27 +- .../pluriwave/PluriWaveAlarmService.kt | 171 ++++++++- lib/app.dart | 21 +- lib/estado/estado_alarmas.dart | 89 ++++- lib/l10n/app_ar.arb | 9 + lib/l10n/app_bn.arb | 9 + lib/l10n/app_de.arb | 9 + lib/l10n/app_en.arb | 9 + lib/l10n/app_es.arb | 5 + lib/l10n/app_fr.arb | 9 + lib/l10n/app_hi.arb | 9 + lib/l10n/app_id.arb | 9 + lib/l10n/app_it.arb | 9 + lib/l10n/app_ja.arb | 9 + lib/l10n/app_pt.arb | 9 + lib/l10n/app_ru.arb | 9 + lib/l10n/app_zh.arb | 9 + lib/l10n/gen/app_localizations.dart | 24 ++ lib/l10n/gen/app_localizations_ar.dart | 15 + lib/l10n/gen/app_localizations_bn.dart | 15 + lib/l10n/gen/app_localizations_de.dart | 15 + lib/l10n/gen/app_localizations_en.dart | 15 + lib/l10n/gen/app_localizations_es.dart | 15 + lib/l10n/gen/app_localizations_fr.dart | 15 + lib/l10n/gen/app_localizations_hi.dart | 15 + lib/l10n/gen/app_localizations_id.dart | 15 + lib/l10n/gen/app_localizations_it.dart | 15 + lib/l10n/gen/app_localizations_ja.dart | 14 + lib/l10n/gen/app_localizations_pt.dart | 15 + lib/l10n/gen/app_localizations_ru.dart | 15 + lib/l10n/gen/app_localizations_zh.dart | 14 + lib/pantallas/pantalla_alarma_sonando.dart | 99 ++++- lib/servicios/servicio_alarmas_android.dart | 90 +++++ .../alarm-system-overhaul/apply-progress.md | 68 ++++ .../changes/alarm-system-overhaul/design.md | 212 +++++++++++ .../alarm-system-overhaul/explore-codebase.md | 56 +++ .../alarm-system-overhaul/explore-research.md | 112 ++++++ .../changes/alarm-system-overhaul/proposal.md | 72 ++++ .../specs/alarm-stop-safety/spec.md | 93 +++++ .../specs/native-alarms/spec.md | 125 ++++++ .../changes/alarm-system-overhaul/tasks.md | 106 ++++++ test/estado/estado_alarmas_test.dart | 358 ++++++++++++++++++ test/helpers/fakes_alarmas.dart | 50 +++ ...lla_alarma_sonando_dismiss_guard_test.dart | 34 ++ .../pantalla_alarma_sonando_test.dart | 104 ++++- .../servicio_alarmas_android_test.dart | 54 +++ ...icio_alarmas_pre_notice_template_test.dart | 6 + 50 files changed, 2461 insertions(+), 24 deletions(-) create mode 100644 openspec/changes/alarm-system-overhaul/apply-progress.md create mode 100644 openspec/changes/alarm-system-overhaul/design.md create mode 100644 openspec/changes/alarm-system-overhaul/explore-codebase.md create mode 100644 openspec/changes/alarm-system-overhaul/explore-research.md create mode 100644 openspec/changes/alarm-system-overhaul/proposal.md create mode 100644 openspec/changes/alarm-system-overhaul/specs/alarm-stop-safety/spec.md create mode 100644 openspec/changes/alarm-system-overhaul/specs/native-alarms/spec.md create mode 100644 openspec/changes/alarm-system-overhaul/tasks.md diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmNotificationStrings.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmNotificationStrings.kt index 529fdf6..622240b 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmNotificationStrings.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmNotificationStrings.kt @@ -28,6 +28,8 @@ object AlarmNotificationStrings { const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate" const val KEY_OPEN_FOLDER = "openFolderTitle" const val KEY_OPEN_RECORDING = "openRecordingTitle" + const val KEY_MISSED_TITLE = "missedTitle" + const val KEY_MISSED_TEMPLATE = "missedTemplate" /** Persists the localized strings pushed by Flutter. Blank values are removed. */ fun save(context: Context, values: Map) { @@ -53,6 +55,15 @@ object AlarmNotificationStrings { get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm") fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder") fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording") + fun missedTitle(context: Context) = get(context, KEY_MISSED_TITLE, "Missed alarm") + fun missedText(context: Context, name: String): String = + format( + // "10 minutes" mirrors AlarmScheduler.AUTO_SILENCE_MILLIS + // (READ-3/READ-4) -- keep both, and the alarmMissedNotificationText + // entry of ALL 13 lib/l10n/app_*.arb files, in sync. + get(context, KEY_MISSED_TEMPLATE, "{name} was silenced automatically after 10 minutes."), + name + ) fun preNoticeText(context: Context, minutes: Long): String = format(get(context, KEY_PRE_NOTICE_TEMPLATE, "Starts in {minutes} min"), minutes) @@ -63,6 +74,9 @@ object AlarmNotificationStrings { private fun format(template: String, minutes: Long): String = template.replace("{minutes}", minutes.toString()) + private fun format(template: String, name: String): String = + template.replace("{name}", name) + private fun get(context: Context, key: String, fallback: String): String = prefs(context).getString(key, null)?.takeIf { it.isNotBlank() } ?: fallback 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 324aabc..ba6f513 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -285,6 +285,12 @@ class AlarmScheduler(private val context: Context) { NotificationManagerCompat.from(appContext).cancel( PluriWaveAlarmReceiver.notificationIdForAlarm(id) ) + // Durable firing record + bounded auto-silence (Decision 3/4): written + // BEFORE the service/audio starts (this receiver path runs first) so a + // process death mid-ring still proves the ring was in flight, and the + // 10-minute MISSED transition is armed even if the FGS later dies. + recordFiring(id) + armAutoSilence(id) val firedAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis saveHandledOccurrence(id, firedAt) val next = spec.copy( @@ -301,6 +307,7 @@ class AlarmScheduler(private val context: Context) { } fun skipNext(id: String) { + cancelAutoSilence(id) val spec = readSpec(id) ?: return val next = spec.copy( snoozeUntilMillis = null, @@ -322,6 +329,7 @@ class AlarmScheduler(private val context: Context) { * back to Flutter (single source of truth), or null if the spec is gone. */ fun snooze(id: String, minutes: Int): NativeSnoozeResult? { + cancelAutoSilence(id) val spec = readSpec(id) ?: return null val safeMinutes = sanitizeSnoozeMinutes(minutes) val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis @@ -665,6 +673,12 @@ class AlarmScheduler(private val context: Context) { cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelSnoozeCountdown(id) cancelPreNoticeCountdown(id) + cancelAutoSilence(id) + // Deleted-alarm ghost MISSED fix (RES-3): a firing record surviving a + // delete would otherwise let the already-cancelled auto-silence path + // (or a stray boot cleanup) resurrect a MISSED transition for an + // alarm the user removed. + clearFiringRecord(id) NotificationManagerCompat.from(appContext).cancel( PluriWaveAlarmReceiver.notificationIdForAlarm(id) ) @@ -679,12 +693,142 @@ class AlarmScheduler(private val context: Context) { ) } + /** Durable "firing since T" record (Decision 4): written before audio starts. */ + fun recordFiring(id: String) { + val ids = prefs().getStringSet(KEY_FIRING_IDS, emptySet()).orEmpty().toMutableSet() + ids.add(id) + prefs().edit() + .putStringSet(KEY_FIRING_IDS, ids) + .putLong("$KEY_FIRING_PREFIX$id", System.currentTimeMillis()) + .apply() + } + + /** Clears the firing record: only on a confirmed stop or a completed MISSED transition. */ + fun clearFiringRecord(id: String) { + val ids = prefs().getStringSet(KEY_FIRING_IDS, emptySet()).orEmpty().toMutableSet() + ids.remove(id) + prefs().edit() + .putStringSet(KEY_FIRING_IDS, ids) + .remove("$KEY_FIRING_PREFIX$id") + .apply() + } + + /** Milliseconds since [id] started firing, or null if no record exists. */ + fun firingRecordAgeMillis(id: String): Long? { + val firedAt = prefs().getLong("$KEY_FIRING_PREFIX$id", 0L) + if (firedAt <= 0L) return null + return System.currentTimeMillis() - firedAt + } + + /** Boot/restart cleanup: any firing record past the auto-silence bound is stale. */ + fun cleanupStaleFiringRecords() { + for (id in prefs().getStringSet(KEY_FIRING_IDS, emptySet()).orEmpty().toList()) { + val age = firingRecordAgeMillis(id) ?: continue + if (age > AUTO_SILENCE_MILLIS) { + Log.w(tag, "alarm.firingRecord stale at boot id=$id ageMs=$age; treating as missed") + onAlarmMissed(id) + } + } + } + + /** + * Arms the AlarmManager-driven MISSED transition (Decision 3): fires + * [AUTO_SILENCE_MILLIS] after the current instant, surviving process + * death (unlike an in-service Handler.postDelayed). + */ + fun armAutoSilence(id: String) { + val pending = PluriWaveAlarmReceiver.pendingMissedIntent( + appContext, + id, + PendingIntent.FLAG_UPDATE_CURRENT + ) + try { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + System.currentTimeMillis() + AUTO_SILENCE_MILLIS, + pending + ) + Log.d(tag, "alarm.autoSilence armed id=$id") + } catch (error: SecurityException) { + Log.w(tag, "alarm.autoSilence arm SecurityException id=$id", error) + } + } + + /** Cancels a pending MISSED transition; a safe no-op if none is armed. */ + fun cancelAutoSilence(id: String) { + cancelPending( + "autoSilence", + PluriWaveAlarmReceiver.pendingMissedIntent(appContext, id, PendingIntent.FLAG_NO_CREATE) + ) + } + + /** + * FIRED→MISSED transition (Decision 3): stops the service if [id] is + * still ringing, posts a missed-alarm notification, clears the firing + * record. Deliberately does NOT rearm -- [onAlarmFired] already rearmed + * the next occurrence (repeating) or left it disabled (one-shot) at fire + * time, so a re-arm here would double-schedule. + */ + fun onAlarmMissed(id: String) { + val spec = readSpec(id) + Log.d(tag, "alarm.missed id=$id") + if (spec == null) { + // Deleted-alarm ghost MISSED fix (RES-3): the alarm was removed + // (cancelAlarm) after this transition was armed -- clear the + // stale firing record only, never post a notification or emit a + // Dart event for an alarm that no longer exists. + Log.d(tag, "alarm.missed spec gone id=$id; clearing firing record only") + clearFiringRecord(id) + return + } + if (PluriWaveAlarmService.activeRingingId == id) { + PluriWaveAlarmService.stop(appContext, id) + } + postMissedNotification(id, spec.title) + clearFiringRecord(id) + MainActivity.notifyAlarmEvent( + mapOf( + "alarmId" to id, + "alarmTitle" to spec.title, + "alarmAction" to MainActivity.ALARM_ACTION_MISSED, + "occurrenceAtMillis" to (spec.snoozeOriginMillis ?: spec.triggerAtMillis) + ) + ) + } + + private fun postMissedNotification(id: String, title: String) { + ensurePreNoticeChannel() + val notification = NotificationCompat.Builder(appContext, PluriWaveAlarmReceiver.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_pluriwave) + .setColor(NotificationBrand.CYAN) + .setContentTitle(AlarmNotificationStrings.missedTitle(appContext)) + .setContentText(AlarmNotificationStrings.missedText(appContext, title)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setSilent(true) + .setAutoCancel(true) + .build() + try { + NotificationManagerCompat.from(appContext).notify( + PluriWaveAlarmReceiver.notificationIdForAlarm(id), + notification + ) + } catch (error: SecurityException) { + Log.e(tag, "alarm.missed notify SecurityException id=$id", error) + } + } + fun canScheduleExactAlarms(): Boolean { return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms() } fun reschedulePersistedAlarms() { + // Boot/restart cleanup (NA "Boot/Restart Cleanup of Stale Firing + // Records"): a firing record older than the auto-silence bound left + // over from a killed process must be treated as missed BEFORE any + // alarm is rescheduled, so a reboot mid-ring never resurrects audio. + cleanupStaleFiringRecords() for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) { val spec = readSpec(id) ?: continue try { @@ -1088,7 +1232,20 @@ class AlarmScheduler(private val context: Context) { private const val KEY_ALARM_PREFIX = "scheduled_alarm_" private const val KEY_HANDLED_IDS = "handled_alarm_ids" private const val KEY_HANDLED_PREFIX = "handled_alarm_" + private const val KEY_FIRING_IDS = "firing_alarm_ids" + private const val KEY_FIRING_PREFIX = "firing_" private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L + + /** + * Bounded auto-silence window (Decision 3): matches the wakelock cap. + * READ-3/READ-4 cross-reference: the "10 minutes" wording is + * duplicated as user-facing text in + * [AlarmNotificationStrings.missedText]'s fallback template AND in + * the `alarmMissedNotificationText` entry of ALL 13 lib/l10n/app_*.arb + * files -- change this constant AND those strings together if the + * window ever changes. + */ + const val AUTO_SILENCE_MILLIS = 10 * 60 * 1000L private const val SCHEDULE_UNICA = "unica" private const val SCHEDULE_DIAS_SEMANA = "diasSemana" 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 7e35769..9c294ae 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -152,6 +152,16 @@ class MainActivity : AudioServiceActivity() { result.success(null) } } + "dismissAlarmNotificationOnly" -> { + val id = call.argument("id") + Log.d(tag, "alarm.channel dismissAlarmNotificationOnly id=$id") + if (id == null) { + result.error("INVALID_ALARM", "Missing alarm id", null) + } else { + alarmScheduler.dismissFireNotification(id) + result.success(null) + } + } "stopNativeAlarmSound" -> { val id = call.argument("id") Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id") @@ -162,6 +172,30 @@ class MainActivity : AudioServiceActivity() { result.success(null) } } + "getActiveRingingAlarmId" -> { + result.success(PluriWaveAlarmService.activeRingingId) + } + "stopActiveAlarm" -> { + try { + // Verified stop (feedback item 1, RISK-1/RES-1/REL-2): the + // id is snapshotted BEFORE stopping, and "stopped" now + // reflects stopActiveVerified's post-teardown check + // instead of a literal true decided before teardown ran. + val activeId = PluriWaveAlarmService.activeRingingId + val stopped = PluriWaveAlarmService.stopActiveVerified(this) + Log.d(tag, "alarm.channel stopActiveAlarm activeId=$activeId stopped=$stopped") + result.success( + mapOf( + "stopped" to stopped, + "wasRinging" to (activeId != null), + "activeAlarmId" to activeId + ) + ) + } catch (error: Throwable) { + Log.e(tag, "alarm.channel stopActiveAlarm failed", error) + result.error("STOP_FAILED", error.message, null) + } + } "diagnostics" -> { Log.d(tag, "alarm.channel diagnostics") result.success( @@ -1192,6 +1226,9 @@ class MainActivity : AudioServiceActivity() { /** alarmAction reported when a pending snooze was cancelled natively. */ const val ALARM_ACTION_SNOOZE_CANCELLED = "snoozeCancelled" + /** alarmAction reported when a fired alarm auto-silenced unattended (Decision 3). */ + const val ALARM_ACTION_MISSED = "missed" + @Volatile private var activeInstance: MainActivity? = null diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt index cbd3896..ac6400d 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -95,6 +95,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { ACTION_SNOOZE_COUNTDOWN -> { AlarmScheduler(context).handleSnoozeCountdownTick(alarmId) } + ACTION_MISSED -> { + AlarmScheduler(context).onAlarmMissed(alarmId) + } ACTION_SNOOZE_AGAIN -> { val snoozed = AlarmScheduler(context).snoozeAgain(alarmId, snoozeMinutes) if (snoozed != null) { @@ -242,8 +245,6 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { manager.createNotificationChannel(channel) } - private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot - private fun sanitizeSnoozeMinutes(minutes: Int): Int = if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5 @@ -257,6 +258,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { const val ACTION_SNOOZE_COUNTDOWN = "es.freetimelab.pluriwave.alarm.SNOOZE_COUNTDOWN" const val ACTION_SNOOZE_AGAIN = "es.freetimelab.pluriwave.alarm.SNOOZE_AGAIN" const val ACTION_CANCEL_SNOOZE = "es.freetimelab.pluriwave.alarm.CANCEL_SNOOZE" + const val ACTION_MISSED = "es.freetimelab.pluriwave.alarm.MISSED" const val EXTRA_ALARM_ID = "alarmId" const val EXTRA_ALARM_TITLE = "alarmTitle" const val EXTRA_ALARM_ACTION = "alarmAction" @@ -273,5 +275,26 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7 fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9 + + /** + * Shared PendingIntent requestCode formula (READ-3/READ-4): kept in + * ONE place so instance call sites (showPreNoticeNotification, which + * resolve this unqualified via companion-member lookup) and + * companion-object call sites ([pendingMissedIntent]) can never + * diverge into two different formulas for the same alarm id. + */ + private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot + + /** Shared PendingIntent factory for the MISSED transition alarm (Decision 3). */ + fun pendingMissedIntent(context: Context, alarmId: String, flags: Int): PendingIntent = + PendingIntent.getBroadcast( + context, + requestCode(alarmId, 4), + Intent(context, PluriWaveAlarmReceiver::class.java).apply { + action = ACTION_MISSED + putExtra(EXTRA_ALARM_ID, alarmId) + }, + flags or PendingIntent.FLAG_IMMUTABLE + ) } } 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 3fb1720..9e9a424 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -48,6 +48,26 @@ class PluriWaveAlarmService : Service() { override fun onBind(intent: Intent?): IBinder? = null + /** + * Paired-write helper (feedback item, READ-6): the instance-scoped + * [activeAlarmId] and the same-process companion [activeRingingId] must + * always move together -- setting one without the other would let + * [stopActiveVerified] read a stale/wrong ring state. Used at every write + * site instead of assigning each field separately. + */ + private fun setActiveIds(id: String?) { + activeAlarmId = id + activeRingingId = id + } + + override fun onCreate() { + super.onCreate() + // Same-process companion instance (feedback item 1, RISK-1/RES-1/REL-2): + // lets stopActiveVerified() call stopEverything() SYNCHRONOUSLY instead + // of trusting an async startService dispatch to have completed. + instance = this + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val action = intent?.action val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) @@ -58,6 +78,14 @@ class PluriWaveAlarmService : Service() { stopAlarm(requestedId) return START_NOT_STICKY } + ACTION_STOP_ACTIVE -> { + // Id-agnostic fail-safe stop (Decision 1): silences whatever is + // ringing regardless of the id the caller passed (or omitted). + // Used by the ringing UI and the notification Stop action so a + // stop request can never silently no-op a live ring. + stopEverything() + return START_NOT_STICKY + } ACTION_SNOOZE -> { val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5) if (requestedId != null) { @@ -92,9 +120,31 @@ class PluriWaveAlarmService : Service() { val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return if (activeAlarmId != null) { Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId") + // Orphaned firing record fix (RES-2): the newcomer's own firing + // record + auto-silence were already armed by onAlarmFired before + // this refusal, so they must be cleared here or a false MISSED + // fires 10 minutes later for an alarm that never actually rang. + val scheduler = AlarmScheduler(this) + scheduler.clearFiringRecord(alarmId) + scheduler.cancelAutoSilence(alarmId) return } - activeAlarmId = alarmId + // onStartCommand re-validation (Decision 4): a redelivered/resurrected + // start for a firing record older than the auto-silence bound must + // never resume audio -- treat it as an already-missed ring instead. + val scheduler = AlarmScheduler(this) + val firingAge = scheduler.firingRecordAgeMillis(alarmId) + if (firingAge != null && firingAge > AlarmScheduler.AUTO_SILENCE_MILLIS) { + Log.w(TAG, "alarm.service startAlarm stale firing record id=$alarmId ageMs=$firingAge; treating as missed") + scheduler.onAlarmMissed(alarmId) + stopSelf() + return + } + // Durable firing record (Decision 4): written before MediaPlayer.start() + // (via startAudio below) so a process death mid-ring leaves proof the + // ring was in flight for the re-validation above / boot cleanup. + scheduler.recordFiring(alarmId) + setActiveIds(alarmId) // Anchor the fade curve at RING start, not audio start (design D2): // every source in the 3-stage fallback chain shares this ONE clock, // so a source that begins mid-fade (e.g. after a station timeout) @@ -135,7 +185,15 @@ class PluriWaveAlarmService : Service() { } catch (error: Throwable) { Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error) releaseWakeLock() - activeAlarmId = null + // Second documented clear site (feedback item, READ-5): this + // branch never reaches stopEverything(), so without the same + // cleanup below the receiver-armed auto-silence timer + durable + // firing record for alarmId would survive and fire a ghost + // MISSED notification ~10 minutes later for a ring that never + // actually started. + scheduler.clearFiringRecord(alarmId) + scheduler.cancelAutoSilence(alarmId) + setActiveIds(null) stopSelf() return } @@ -405,8 +463,27 @@ class PluriWaveAlarmService : Service() { NotificationManagerCompat.from(this).cancel( PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId) ) + // Orphaned firing record fix (RES-2): this mismatched id is not + // being torn down by stopEverything() below (that only tears down + // activeAlarmId), so its own firing record + auto-silence must be + // cleared here to avoid a false MISSED 10 minutes later. + val scheduler = AlarmScheduler(this) + scheduler.clearFiringRecord(alarmId) + scheduler.cancelAutoSilence(alarmId) return } + stopEverything() + } + + /** + * Atomic full teardown (Decision 2, NA "Atomic Stop Coupling"): every stop + * entry point (ACTION_STOP id-match/null, ACTION_STOP_ACTIVE, ACTION_SNOOZE + * via [stopAlarm], onDestroy via [stopAlarm]) funnels through this ONE + * method so no path can perform a partial teardown. Id-agnostic by design: + * it always tears down whatever [activeAlarmId] currently is. + */ + private fun stopEverything() { + val stoppingId = activeAlarmId cancelStationFallback() cancelFadeLoop() try { @@ -414,15 +491,25 @@ class PluriWaveAlarmService : Service() { } catch (error: Throwable) { Log.w(TAG, "alarm.service stop player failed", error) } - player?.release() + try { + player?.release() + } catch (error: Throwable) { + // Non-atomic release fix (RES-4): a throw here must not abort the + // rest of the teardown below (state reset, wakelock, firing-record + // clear, stopForeground, stopSelf all still need to run). + Log.w(TAG, "alarm.service release player failed", error) + } player = null - activeAlarmId = null + setActiveIds(null) releaseWakeLock() abandonAlarmAudioFocus() - if (alarmId != null) { + if (stoppingId != null) { NotificationManagerCompat.from(this).cancel( - PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId) + PluriWaveAlarmReceiver.fireNotificationIdForAlarm(stoppingId) ) + val scheduler = AlarmScheduler(this) + scheduler.clearFiringRecord(stoppingId) + scheduler.cancelAutoSilence(stoppingId) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) @@ -480,7 +567,10 @@ class PluriWaveAlarmService : Service() { this, requestCode(alarmId, 21), Intent(this, PluriWaveAlarmService::class.java).apply { - action = ACTION_STOP + // Fail-safe fix (feedback item 1, SS-4a/NA-1a): the notification + // Stop action must route through the id-agnostic stop so it can + // never no-op a live ring; the extra id is kept only for logs. + action = ACTION_STOP_ACTIVE putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId) }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE @@ -595,6 +685,7 @@ class PluriWaveAlarmService : Service() { override fun onDestroy() { stopAlarm(activeAlarmId) + if (instance === this) instance = null super.onDestroy() } @@ -608,8 +699,33 @@ class PluriWaveAlarmService : Service() { private const val KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3" private const val NOTIFICATION_ID = 92841 const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE" + const val ACTION_STOP_ACTIVE = "es.freetimelab.pluriwave.alarm.STOP_ACTIVE_NATIVE" const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE" const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes" + + /** + * Same-process companion snapshot (Decision 1): `MainActivity` reads + * this synchronously (no service round-trip) to build a verified stop + * result. Always written together with the instance-scoped + * [activeAlarmId] through the paired [setActiveIds] helper (feedback + * item, READ-6) -- always the id ACTUALLY ringing, never a + * caller-supplied one. Set in [startAlarm]; cleared in TWO documented + * sites -- [stopEverything] (confirmed stop/teardown) AND + * [startAlarm]'s own startForeground-failure catch (feedback item, + * READ-5), which never reaches [stopEverything] but must still clear + * the ids for the ring that never actually started. + */ + @Volatile + var activeRingingId: String? = null + + /** + * Same-process companion reference (feedback item 1, RISK-1/RES-1/REL-2): + * set in [onCreate], cleared in [onDestroy]. Lets [stopActiveVerified] + * call [stopEverything] synchronously instead of trusting an async + * startService dispatch to have completed before reporting a result. + */ + @Volatile + private var instance: PluriWaveAlarmService? = null private const val STATION_START_TIMEOUT_MILLIS = 15_000L private const val FADE_TICK_MILLIS = 50L private const val FADE_RANGE_DB = 40.0f @@ -668,6 +784,47 @@ class PluriWaveAlarmService : Service() { } } + /** Id-agnostic fail-safe stop (Decision 1): silences whatever is ringing. */ + fun stopActive(context: Context) { + val intent = Intent(context, PluriWaveAlarmService::class.java).apply { + action = ACTION_STOP_ACTIVE + } + try { + context.startService(intent) + Log.d(TAG, "alarm.service stopActive action requested") + } catch (error: Throwable) { + Log.e(TAG, "alarm.service stopActive request failed", error) + try { + context.stopService(intent) + } catch (fallbackError: Throwable) { + Log.e(TAG, "alarm.service stopActive fallback failed", fallbackError) + } + } + } + + /** + * Same-process VERIFIED stop (feedback item 1, RISK-1/RES-1/REL-2): + * fixes the hollow verification where [stopActive]'s async + * startService dispatch made the result a literal `true` decided + * before teardown ran. When a live [instance] exists, invokes + * [stopEverything] on it SYNCHRONOUSLY (the MethodChannel caller and + * this service both run on the main thread of the SAME process, so + * no round trip is needed) and returns whether teardown actually + * cleared [activeRingingId]. Falls back to the async [stopActive] + * dispatch only when no instance is alive -- nothing can be ringing + * without a live instance, so [activeRingingId] is already null and + * the fallback trivially succeeds. + */ + fun stopActiveVerified(context: Context): Boolean { + val current = instance + if (current != null) { + current.stopEverything() + return activeRingingId == null + } + stopActive(context) + return activeRingingId == null + } + private fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager diff --git a/lib/app.dart b/lib/app.dart index 1cc8a77..4caffb7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -281,6 +281,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> // nothing to open for this event. return; } + if (evento.accion == EventoAlarmaAndroid.accionMissed) { + // EstadoAlarmas' own native-event listener already recorded this + // transition (RES-1); the ring already ended, so opening the ringing + // screen here would only show a stale, already-silent alarm. + return; + } final estado = context.read(); if (estado.alarmas.isEmpty) { await estado.cargarPersistidasSinRecalcular(); @@ -361,15 +367,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> ); // A duplicate delivery of the SAME ring's own fire event (the live // eventosAlarma stream and the one-shot obtenerEventoInicial() read - // the same native event and can both reach here) must be a no-op: - // ocultarNotificacionAlarma -> dismissAlarmNotification unconditionally - // stops PluriWaveAlarmService for that id on the native side, which - // would tear down the currently-ringing service and undo the - // ring-scoped media-volume override long before the real handoff. - // Only hide the notification when a genuinely DIFFERENT alarm fired - // while this one is active (single-ring-at-a-time by design). + // the same native event and can both reach here) must be a no-op. + // When a genuinely DIFFERENT alarm fired while this one is active + // (single-ring-at-a-time by design), hide ONLY its notification + // (RES-1): ocultarNotificacionAlarma -> dismissAlarmNotification + // unconditionally stops PluriWaveAlarmService, which would silently + // kill the OTHER alarm's ring if it is the one genuinely sounding. if (alarma.id != _alarmaSonandoId) { - await alarmas.android.ocultarNotificacionAlarma(alarma.id); + await alarmas.android.ocultarSoloNotificacion(alarma.id); } return; } diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index a51d0f3..10b6efe 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -54,6 +54,10 @@ class EstadoAlarmas extends ChangeNotifier { bool _cargando = false; String? _error; + /// Last alarm id recorded as MISSED (RES-1): lets the ringing screen + /// detect an external end-of-ring for its own alarm and reconcile. + String? ultimaAlarmaPerdidaId; + List get alarmas => List.unmodifiable(_alarmas); List get vacaciones => List.unmodifiable(_vacaciones); List get excepciones => List.unmodifiable(_excepciones); @@ -100,6 +104,10 @@ class EstadoAlarmas extends ChangeNotifier { debugPrint( '[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}', ); + // Mutation-while-ringing stop guard (SS-1a/SS-1b): fires BEFORE the save + // persists so an edit/toggle-off of the currently-ringing alarm always + // silences it first. + await _detenerSiEstaSonando(alarma.id); final config = await servicio.guardarAlarma(alarma); _aplicar(config); try { @@ -156,11 +164,40 @@ class EstadoAlarmas extends ChangeNotifier { debugPrint('[PluriWave][alarmas] eliminar id=$id'); final config = await servicio.eliminarAlarma(id); _aplicar(config); - await android.detenerSonidoNativo(id); + // Deleting the ringing alarm stops audio (SS-1c, regression lock): the + // centralized guard runs before cancelar, same as guardarAlarma. + await _detenerSiEstaSonando(id); await android.cancelar(id); notifyListeners(); } + /// Centralized mutation-while-ringing stop guard (Decision 5): every + /// mutation of the currently-ringing alarm routes through this ONE check + /// instead of per-call-site logic, so a mutation of a DIFFERENT (non- + /// ringing) alarm never touches the live ring (SS-1d). + Future _detenerSiEstaSonando(String id) async { + try { + final sonando = await android.alarmaSonandoId(); + if (sonando == id) { + await android.detenerSonidoActivo(); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] detenerSiEstaSonando ERROR $e'); + // Fail-toward-silence (Finding 2, eliminarAlarma regression): a failed + // query must not silently skip the stop when the alarm might genuinely + // be ringing. Fall back to the id-scoped legacy stop (the native side + // no-ops safely on a mismatch) inside its own try/catch so this outer + // flow (guardarAlarma/eliminarAlarma) always proceeds regardless. + try { + await android.detenerSonidoNativo(id); + } catch (fallbackError) { + debugPrint( + '[PluriWave][alarmas] detenerSiEstaSonando fallback ERROR $fallbackError', + ); + } + } + } + Future cambiarActiva(AlarmaMusical alarma, bool activa) async { await guardarAlarma(alarma.copyWith(activa: activa)); } @@ -271,6 +308,7 @@ class EstadoAlarmas extends ChangeNotifier { Future finalizarEjecucion(String alarmaId) async { debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId'); + _error = null; final alarma = _buscarAlarma(alarmaId); final ejecucion = alarma?.snoozeOrigen ?? @@ -278,12 +316,34 @@ class EstadoAlarmas extends ChangeNotifier { alarma?.snoozeHasta ?? DateTime.now(); await android.ocultarNotificacionAlarma(alarmaId); + // Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the + // id-agnostic fail-safe stop directly (it always targets whatever is + // ringing). `detenido` reflects the VERIFIED native teardown state + // (activeRingingId cleared same-process after a synchronous stop), not a + // literal dispatch acknowledgement, so a genuine failure is never + // swallowed. + final resultado = await android.detenerSonidoActivo(); + if (!resultado.detenido) { + _error = 'No se pudo confirmar que la alarma dejo de sonar.'; + } final config = await servicio.completarEjecucion(alarmaId, ejecucion); _aplicar(config); await _sincronizarTodas(); notifyListeners(); } + /// Retryable force-stop affordance (SS-3b): re-invokes the same fail-safe + /// stop; success clears the recorded failure, another failure keeps it. + Future forzarDetencion(String alarmaId) async { + debugPrint('[PluriWave][alarmas] forzar detencion id=$alarmaId'); + final resultado = await android.detenerSonidoActivo(); + _error = + resultado.detenido + ? null + : 'No se pudo detener la alarma. Intentalo de nuevo.'; + notifyListeners(); + } + Future crearRangoVacaciones(RangoVacaciones rango) async { final nuevos = [..._vacaciones, rango]; await guardarVacaciones(nuevos); @@ -319,6 +379,10 @@ class EstadoAlarmas extends ChangeNotifier { await _registrarCancelacionSnoozeNativa(evento); return; } + if (evento.accion == EventoAlarmaAndroid.accionMissed) { + await _registrarEjecucionPerdida(evento); + return; + } if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return; if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return; final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis); @@ -366,6 +430,29 @@ class EstadoAlarmas extends ChangeNotifier { } } + /// Records a native auto-silence (MISSED) transition (Phase 6): the native + /// scheduler already rearmed the next occurrence (repeating) or left it + /// disabled (one-shot) at fire time, so this only marks the occurrence + /// handled -- it MUST NOT call android.programar again. + Future _registrarEjecucionPerdida(EventoAlarmaAndroid evento) async { + if (evento.alarmaId.isEmpty) return; + final origen = + evento.occurrenceAtMillis > 0 + ? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis) + : DateTime.now(); + debugPrint( + '[PluriWave][alarmas] ejecucion perdida id=${evento.alarmaId} origen=${origen.toIso8601String()}', + ); + try { + final config = await servicio.completarEjecucion(evento.alarmaId, origen); + _aplicar(config); + ultimaAlarmaPerdidaId = evento.alarmaId; + notifyListeners(); + } catch (e) { + debugPrint('[PluriWave][alarmas] ejecucion perdida ERROR $e'); + } + } + Future _sincronizarEjecucionesGestionadasPorAndroid() async { try { final ejecuciones = await android.obtenerEjecucionesNativasGestionadas(); diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 941393d..e0f2afa 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "يرن باستخدام الصوت الداخلي الآمن.", "ringingPreparingInternalAudio": "جارٍ تجهيز الصوت الداخلي الآمن.", "stopAlarmAction": "إيقاف المنبه", + "alarmStopFailedMessage": "تعذّر التأكد من إيقاف المنبه. حاول مرة أخرى.", + "alarmForceStopAction": "إيقاف قسري", + "alarmMissedNotificationTitle": "منبه فائت", + "alarmMissedNotificationText": "تم كتم {name} تلقائيًا بعد 10 دقائق.", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "إيقاف مؤقت", "miniPlayerOpenLabel": "فتح المشغل لـ {stationName}", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 47f7427..b539636 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "নিরাপদ অভ্যন্তরীণ শব্দ দিয়ে বাজছে।", "ringingPreparingInternalAudio": "নিরাপদ অভ্যন্তরীণ শব্দ প্রস্তুত হচ্ছে।", "stopAlarmAction": "অ্যালার্ম বন্ধ করুন", + "alarmStopFailedMessage": "অ্যালার্ম বন্ধ হয়েছে তা নিশ্চিত করা যায়নি। আবার চেষ্টা করুন।", + "alarmForceStopAction": "জোর করে বন্ধ করুন", + "alarmMissedNotificationTitle": "মিস হওয়া অ্যালার্ম", + "alarmMissedNotificationText": "১০ মিনিট পর {name} স্বয়ংক্রিয়ভাবে নিঃশব্দ করা হয়েছে।", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "বিরতি দিন", "miniPlayerOpenLabel": "{stationName}-এর প্লেয়ার খুলুন", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0cf99c5..b61cdc6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "Wiedergabe mit internem Sicherheitston.", "ringingPreparingInternalAudio": "Interner Sicherheitston wird vorbereitet.", "stopAlarmAction": "Alarm stoppen", + "alarmStopFailedMessage": "Wir konnten nicht bestätigen, dass der Alarm gestoppt wurde. Versuche es erneut.", + "alarmForceStopAction": "Erzwungen stoppen", + "alarmMissedNotificationTitle": "Verpasster Alarm", + "alarmMissedNotificationText": "{name} wurde nach 10 Minuten automatisch stummgeschaltet.", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "Pausieren", "miniPlayerOpenLabel": "Wiedergabe für {stationName} öffnen", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 102ce80..b8979e8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "Playing with internal safe audio.", "ringingPreparingInternalAudio": "Preparing internal safe audio.", "stopAlarmAction": "Stop alarm", + "alarmStopFailedMessage": "We couldn't confirm the alarm stopped. Try again.", + "alarmForceStopAction": "Force stop", + "alarmMissedNotificationTitle": "Missed alarm", + "alarmMissedNotificationText": "{name} was silenced automatically after 10 minutes.", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "Pause", "miniPlayerOpenLabel": "Open player for {stationName}", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1393411..4ecaebd 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -466,6 +466,11 @@ "ringingInternalAudioActive": "Sonando con audio seguro interno.", "ringingPreparingInternalAudio": "Preparando audio seguro interno.", "stopAlarmAction": "Detener alarma", + "alarmStopFailedMessage": "No pudimos confirmar que la alarma se detuvo. Inténtalo de nuevo.", + "alarmForceStopAction": "Forzar detención", + "alarmMissedNotificationTitle": "Alarma perdida", + "alarmMissedNotificationText": "{name} se silenció automáticamente después de 10 minutos.", + "@alarmMissedNotificationText": {"placeholders": {"name": {}}}, "pauseAction": "Pausar", "miniPlayerOpenLabel": "Abrir reproductor de {stationName}", "@miniPlayerOpenLabel": {"placeholders": {"stationName": {}}}, diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cc67e21..65d7071 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "Son sécurisé interne en cours.", "ringingPreparingInternalAudio": "Préparation du son sécurisé interne.", "stopAlarmAction": "Arrêter l’alarme", + "alarmStopFailedMessage": "Nous n’avons pas pu confirmer l’arrêt de l’alarme. Réessayez.", + "alarmForceStopAction": "Forcer l’arrêt", + "alarmMissedNotificationTitle": "Alarme manquée", + "alarmMissedNotificationText": "{name} a été mise en sourdine automatiquement après 10 minutes.", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "Pause", "miniPlayerOpenLabel": "Ouvrir le lecteur de {stationName}", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 83c92b7..f02202d 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "सुरक्षित आंतरिक ध्वनि के साथ बज रहा है।", "ringingPreparingInternalAudio": "सुरक्षित आंतरिक ध्वनि तैयार हो रही है।", "stopAlarmAction": "अलार्म रोकें", + "alarmStopFailedMessage": "हम पुष्टि नहीं कर सके कि अलार्म बंद हुआ। फिर से कोशिश करें।", + "alarmForceStopAction": "जबरन बंद करें", + "alarmMissedNotificationTitle": "छूटा हुआ अलार्म", + "alarmMissedNotificationText": "10 मिनट बाद {name} अपने आप म्यूट कर दिया गया।", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "विराम दें", "miniPlayerOpenLabel": "{stationName} का प्लेयर खोलें", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index a67c5d5..df0829b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "Berbunyi dengan audio internal aman.", "ringingPreparingInternalAudio": "Menyiapkan audio internal aman.", "stopAlarmAction": "Hentikan alarm", + "alarmStopFailedMessage": "Kami tidak dapat memastikan alarm berhenti. Coba lagi.", + "alarmForceStopAction": "Paksa berhenti", + "alarmMissedNotificationTitle": "Alarm terlewat", + "alarmMissedNotificationText": "{name} dibisukan secara otomatis setelah 10 menit.", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "Jeda", "miniPlayerOpenLabel": "Buka pemutar untuk {stationName}", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 365aaed..420f979 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "Suono sicuro interno in riproduzione.", "ringingPreparingInternalAudio": "Preparazione del suono sicuro interno.", "stopAlarmAction": "Ferma sveglia", + "alarmStopFailedMessage": "Non siamo riusciti a confermare l’arresto della sveglia. Riprova.", + "alarmForceStopAction": "Forza arresto", + "alarmMissedNotificationTitle": "Sveglia mancata", + "alarmMissedNotificationText": "{name} è stata disattivata automaticamente dopo 10 minuti.", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "Pausa", "miniPlayerOpenLabel": "Apri il lettore per {stationName}", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 70af09d..36a34bf 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "安全な内部音で鳴っています。", "ringingPreparingInternalAudio": "安全な内部音を準備中です。", "stopAlarmAction": "アラームを停止", + "alarmStopFailedMessage": "アラームが停止したことを確認できませんでした。もう一度お試しください。", + "alarmForceStopAction": "強制停止", + "alarmMissedNotificationTitle": "アラームの聞き逃し", + "alarmMissedNotificationText": "{name}は10分後に自動的に消音されました。", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "一時停止", "miniPlayerOpenLabel": "{stationName}のプレーヤーを開く", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index e96a3b2..33be3e7 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "Tocando com som seguro interno.", "ringingPreparingInternalAudio": "Preparando som seguro interno.", "stopAlarmAction": "Parar alarme", + "alarmStopFailedMessage": "Não conseguimos confirmar que o alarme parou. Tente novamente.", + "alarmForceStopAction": "Forçar parada", + "alarmMissedNotificationTitle": "Alarme perdido", + "alarmMissedNotificationText": "{name} foi silenciado automaticamente após 10 minutos.", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "Pausar", "miniPlayerOpenLabel": "Abrir reprodutor de {stationName}", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 58f5563..4393140 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "Звонит с безопасным внутренним звуком.", "ringingPreparingInternalAudio": "Подготовка безопасного внутреннего звука.", "stopAlarmAction": "Остановить будильник", + "alarmStopFailedMessage": "Не удалось подтвердить, что будильник остановлен. Попробуйте снова.", + "alarmForceStopAction": "Принудительно остановить", + "alarmMissedNotificationTitle": "Пропущенный будильник", + "alarmMissedNotificationText": "{name} был автоматически отключён через 10 минут.", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "Пауза", "miniPlayerOpenLabel": "Открыть плеер для {stationName}", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 504d75b..1b185db 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -466,6 +466,15 @@ "ringingInternalAudioActive": "正在使用内部安全音频响铃。", "ringingPreparingInternalAudio": "正在准备内部安全音频。", "stopAlarmAction": "停止闹钟", + "alarmStopFailedMessage": "无法确认闹钟已停止,请重试。", + "alarmForceStopAction": "强制停止", + "alarmMissedNotificationTitle": "错过的闹钟", + "alarmMissedNotificationText": "{name}已在10分钟后自动静音。", + "@alarmMissedNotificationText": { + "placeholders": { + "name": {} + } + }, "pauseAction": "暂停", "miniPlayerOpenLabel": "打开 {stationName} 的播放器", "@miniPlayerOpenLabel": { diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 3f6910e..390b140 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1694,6 +1694,30 @@ abstract class AppLocalizations { /// **'Detener alarma'** String get stopAlarmAction; + /// No description provided for @alarmStopFailedMessage. + /// + /// In es, this message translates to: + /// **'No pudimos confirmar que la alarma se detuvo. Inténtalo de nuevo.'** + String get alarmStopFailedMessage; + + /// No description provided for @alarmForceStopAction. + /// + /// In es, this message translates to: + /// **'Forzar detención'** + String get alarmForceStopAction; + + /// No description provided for @alarmMissedNotificationTitle. + /// + /// In es, this message translates to: + /// **'Alarma perdida'** + String get alarmMissedNotificationTitle; + + /// No description provided for @alarmMissedNotificationText. + /// + /// In es, this message translates to: + /// **'{name} se silenció automáticamente después de 10 minutos.'** + String alarmMissedNotificationText(Object name); + /// No description provided for @pauseAction. /// /// 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 c4978f4..d9125c9 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -899,6 +899,21 @@ class AppLocalizationsAr extends AppLocalizations { @override String get stopAlarmAction => 'إيقاف المنبه'; + @override + String get alarmStopFailedMessage => + 'تعذّر التأكد من إيقاف المنبه. حاول مرة أخرى.'; + + @override + String get alarmForceStopAction => 'إيقاف قسري'; + + @override + String get alarmMissedNotificationTitle => 'منبه فائت'; + + @override + String alarmMissedNotificationText(Object name) { + return 'تم كتم $name تلقائيًا بعد 10 دقائق.'; + } + @override String get pauseAction => 'إيقاف مؤقت'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index ecd6b3a..653a332 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -908,6 +908,21 @@ class AppLocalizationsBn extends AppLocalizations { @override String get stopAlarmAction => 'অ্যালার্ম বন্ধ করুন'; + @override + String get alarmStopFailedMessage => + 'অ্যালার্ম বন্ধ হয়েছে তা নিশ্চিত করা যায়নি। আবার চেষ্টা করুন।'; + + @override + String get alarmForceStopAction => 'জোর করে বন্ধ করুন'; + + @override + String get alarmMissedNotificationTitle => 'মিস হওয়া অ্যালার্ম'; + + @override + String alarmMissedNotificationText(Object name) { + return '১০ মিনিট পর $name স্বয়ংক্রিয়ভাবে নিঃশব্দ করা হয়েছে।'; + } + @override String get pauseAction => 'বিরতি দিন'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 0d88696..28600ed 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -910,6 +910,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get stopAlarmAction => 'Alarm stoppen'; + @override + String get alarmStopFailedMessage => + 'Wir konnten nicht bestätigen, dass der Alarm gestoppt wurde. Versuche es erneut.'; + + @override + String get alarmForceStopAction => 'Erzwungen stoppen'; + + @override + String get alarmMissedNotificationTitle => 'Verpasster Alarm'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name wurde nach 10 Minuten automatisch stummgeschaltet.'; + } + @override String get pauseAction => 'Pausieren'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 1953649..969af11 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -903,6 +903,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get stopAlarmAction => 'Stop alarm'; + @override + String get alarmStopFailedMessage => + 'We couldn\'t confirm the alarm stopped. Try again.'; + + @override + String get alarmForceStopAction => 'Force stop'; + + @override + String get alarmMissedNotificationTitle => 'Missed alarm'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name was silenced automatically after 10 minutes.'; + } + @override String get pauseAction => 'Pause'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 8c9fdeb..8e69f61 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -907,6 +907,21 @@ class AppLocalizationsEs extends AppLocalizations { @override String get stopAlarmAction => 'Detener alarma'; + @override + String get alarmStopFailedMessage => + 'No pudimos confirmar que la alarma se detuvo. Inténtalo de nuevo.'; + + @override + String get alarmForceStopAction => 'Forzar detención'; + + @override + String get alarmMissedNotificationTitle => 'Alarma perdida'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name se silenció automáticamente después de 10 minutos.'; + } + @override String get pauseAction => 'Pausar'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 7016594..9ba66b0 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -913,6 +913,21 @@ class AppLocalizationsFr extends AppLocalizations { @override String get stopAlarmAction => 'Arrêter l’alarme'; + @override + String get alarmStopFailedMessage => + 'Nous n’avons pas pu confirmer l’arrêt de l’alarme. Réessayez.'; + + @override + String get alarmForceStopAction => 'Forcer l’arrêt'; + + @override + String get alarmMissedNotificationTitle => 'Alarme manquée'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name a été mise en sourdine automatiquement après 10 minutes.'; + } + @override String get pauseAction => 'Pause'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 1b3ec2c..edfa192 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -904,6 +904,21 @@ class AppLocalizationsHi extends AppLocalizations { @override String get stopAlarmAction => 'अलार्म रोकें'; + @override + String get alarmStopFailedMessage => + 'हम पुष्टि नहीं कर सके कि अलार्म बंद हुआ। फिर से कोशिश करें।'; + + @override + String get alarmForceStopAction => 'जबरन बंद करें'; + + @override + String get alarmMissedNotificationTitle => 'छूटा हुआ अलार्म'; + + @override + String alarmMissedNotificationText(Object name) { + return '10 मिनट बाद $name अपने आप म्यूट कर दिया गया।'; + } + @override String get pauseAction => 'विराम दें'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 1e3919e..69e1d00 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -908,6 +908,21 @@ class AppLocalizationsId extends AppLocalizations { @override String get stopAlarmAction => 'Hentikan alarm'; + @override + String get alarmStopFailedMessage => + 'Kami tidak dapat memastikan alarm berhenti. Coba lagi.'; + + @override + String get alarmForceStopAction => 'Paksa berhenti'; + + @override + String get alarmMissedNotificationTitle => 'Alarm terlewat'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name dibisukan secara otomatis setelah 10 menit.'; + } + @override String get pauseAction => 'Jeda'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 37b9f2b..676271d 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -910,6 +910,21 @@ class AppLocalizationsIt extends AppLocalizations { @override String get stopAlarmAction => 'Ferma sveglia'; + @override + String get alarmStopFailedMessage => + 'Non siamo riusciti a confermare l’arresto della sveglia. Riprova.'; + + @override + String get alarmForceStopAction => 'Forza arresto'; + + @override + String get alarmMissedNotificationTitle => 'Sveglia mancata'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name è stata disattivata automaticamente dopo 10 minuti.'; + } + @override String get pauseAction => 'Pausa'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 37d4a6d..13d1107 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -875,6 +875,20 @@ class AppLocalizationsJa extends AppLocalizations { @override String get stopAlarmAction => 'アラームを停止'; + @override + String get alarmStopFailedMessage => 'アラームが停止したことを確認できませんでした。もう一度お試しください。'; + + @override + String get alarmForceStopAction => '強制停止'; + + @override + String get alarmMissedNotificationTitle => 'アラームの聞き逃し'; + + @override + String alarmMissedNotificationText(Object name) { + return '$nameは10分後に自動的に消音されました。'; + } + @override String get pauseAction => '一時停止'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index b3bb647..98a8e45 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -905,6 +905,21 @@ class AppLocalizationsPt extends AppLocalizations { @override String get stopAlarmAction => 'Parar alarme'; + @override + String get alarmStopFailedMessage => + 'Não conseguimos confirmar que o alarme parou. Tente novamente.'; + + @override + String get alarmForceStopAction => 'Forçar parada'; + + @override + String get alarmMissedNotificationTitle => 'Alarme perdido'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name foi silenciado automaticamente após 10 minutos.'; + } + @override String get pauseAction => 'Pausar'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 766e64c..f200d01 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -909,6 +909,21 @@ class AppLocalizationsRu extends AppLocalizations { @override String get stopAlarmAction => 'Остановить будильник'; + @override + String get alarmStopFailedMessage => + 'Не удалось подтвердить, что будильник остановлен. Попробуйте снова.'; + + @override + String get alarmForceStopAction => 'Принудительно остановить'; + + @override + String get alarmMissedNotificationTitle => 'Пропущенный будильник'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name был автоматически отключён через 10 минут.'; + } + @override String get pauseAction => 'Пауза'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 44628ea..fae7db1 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -871,6 +871,20 @@ class AppLocalizationsZh extends AppLocalizations { @override String get stopAlarmAction => '停止闹钟'; + @override + String get alarmStopFailedMessage => '无法确认闹钟已停止,请重试。'; + + @override + String get alarmForceStopAction => '强制停止'; + + @override + String get alarmMissedNotificationTitle => '错过的闹钟'; + + @override + String alarmMissedNotificationText(Object name) { + return '$name已在10分钟后自动静音。'; + } + @override String get pauseAction => '暂停'; diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index e771843..9bf1c28 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -30,9 +30,31 @@ class _PantallaAlarmaSonandoState extends State { /// (a second _dismissScreen would pop the route UNDER the alarm screen). bool _salidaEnCurso = false; + /// Retryable force-stop affordance (Finding A, spec `alarm-stop-safety` / + /// "Retryable Force-Stop Affordance"): true while a VERIFIED stop failure + /// (or an unknown-state exception) is outstanding. Unlike a timed SnackBar, + /// this drives a persistent in-screen banner that stays until a confirmed + /// stop clears it — the ring is still audible while this is true, so the + /// screen intentionally does NOT dismiss. + bool _falloDetencionVisible = false; + + late final EstadoAlarmas _alarmas; + @override void initState() { super.initState(); + _alarmas = context.read(); + _alarmas.addListener(_alReconciliarFinExterno); + } + + /// External end-of-ring reconciliation (RES-1): if this alarm's occurrence + /// gets recorded as MISSED while this screen is up, auto-dismiss instead of + /// leaving a stale ringing screen with no audio behind it. + void _alReconciliarFinExterno() { + if (_salidaEnCurso || !mounted) return; + if (_alarmas.ultimaAlarmaPerdidaId != widget.alarma.id) return; + _salidaEnCurso = true; + _dismissScreen(); } /// Pure UI: the ring's audio is owned entirely by the native @@ -43,15 +65,46 @@ class _PantallaAlarmaSonandoState extends State { if (_salidaEnCurso) return; _salidaEnCurso = true; final alarmas = context.read(); - // Dismiss is run from finally so a failing reschedule/teardown can never - // leave the ringing screen stuck open (which would also block the next - // ring via the _alarmaSonandoActiva guard in app.dart). + final alarmaId = widget.alarma.id; try { - await alarmas.finalizarEjecucion(widget.alarma.id); + await alarmas.finalizarEjecucion(alarmaId); + if (alarmas.error != null) { + // Verified stop failure (Finding A): the alarm is still ringing, so + // dismissing now would hide the only retry affordance. Reset the + // single-exit guard so a retry (this button again, back gesture, or + // the banner's own action below) can run the teardown again. + _salidaEnCurso = false; + if (mounted) setState(() => _falloDetencionVisible = true); + return; + } } catch (e) { debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e'); - } finally { + // Unknown state (Finding A): treat exactly like a verified failure — + // stay and show the retry banner. The notification's native Stop + // action remains the out-of-band fallback, and PopScope already routes + // back through this same method on a subsequent back-press. + _salidaEnCurso = false; + if (mounted) setState(() => _falloDetencionVisible = true); + return; + } + if (mounted) _dismissScreen(); + } + + /// Retry action bound to the persistent force-stop banner (Finding A, + /// SS-3b): re-invokes the fail-safe stop directly; dismisses ONLY on a + /// confirmed success, otherwise the banner stays exactly as it was. + Future _forzarDetencion() async { + if (_salidaEnCurso) return; + _salidaEnCurso = true; + final alarmas = context.read(); + await alarmas.forzarDetencion(widget.alarma.id); + if (alarmas.error == null) { if (mounted) _dismissScreen(); + } else { + // Verified failure (RES-2): reset the guard so the banner's own retry + // action (or another button) can run the teardown again. + _salidaEnCurso = false; + if (mounted) setState(() {}); } } @@ -109,6 +162,7 @@ class _PantallaAlarmaSonandoState extends State { @override void dispose() { + _alarmas.removeListener(_alReconciliarFinExterno); super.dispose(); } @@ -209,6 +263,10 @@ class _PantallaAlarmaSonandoState extends State { icon: const Icon(Icons.stop_rounded), label: Text(l10n.stopAlarmAction), ), + if (_falloDetencionVisible) ...[ + const SizedBox(height: 14), + _bannerFalloDetencion(context, l10n, tokens), + ], ], ), ).pluriFadeIn(context), @@ -217,6 +275,37 @@ class _PantallaAlarmaSonandoState extends State { ), ); } + + /// Persistent force-stop retry banner (Finding A, spec `alarm-stop-safety` + /// / "Retryable Force-Stop Affordance"): an in-screen section rather than a + /// timed SnackBar, so it stays visible until [_forzarDetencion] confirms a + /// stop (or the screen is torn down externally) instead of auto-dismissing + /// after a fixed duration. + Widget _bannerFalloDetencion( + BuildContext context, + AppLocalizations l10n, + PluriWaveTokens tokens, + ) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: tokens.warmCoral.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.4)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.alarmStopFailedMessage, textAlign: TextAlign.center), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: _forzarDetencion, + child: Text(l10n.alarmForceStopAction), + ), + ], + ), + ); + } } String _hora(AlarmaMusical alarma) => diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 9f72053..4ccd7a3 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -27,6 +27,10 @@ class EventoAlarmaAndroid { /// countdown notification ("Detener" while the app may be killed). static const accionSnoozeCancelled = 'snoozeCancelled'; + /// Action reported when a fired alarm auto-silenced unattended after the + /// 10-minute bound (Decision 3), never a user-initiated stop. + static const accionMissed = 'missed'; + final String alarmaId; final String titulo; final String accion; @@ -108,6 +112,36 @@ class DiagnosticoAlarmasAndroid { } } +/// Fail-safe stop result (Decision 1). `detenido` reports whether a +/// non-no-op teardown was dispatched (never silently swallowed); `alarmaId` +/// is the id that was actually ringing, for Dart-side reconciliation. +class ResultadoDetencion { + const ResultadoDetencion({ + required this.detenido, + required this.estabaSonando, + this.alarmaId, + }); + + final bool detenido; + final bool estabaSonando; + final String? alarmaId; + + /// A thrown channel error or a missing native response is treated as a + /// failure, never as an implicit success. + static const fallo = ResultadoDetencion( + detenido: false, + estabaSonando: false, + ); + + factory ResultadoDetencion.fromMap(Map map) { + return ResultadoDetencion( + detenido: map['stopped'] as bool? ?? false, + estabaSonando: map['wasRinging'] as bool? ?? false, + alarmaId: map['activeAlarmId'] as String?, + ); + } +} + class EjecucionAlarmaNativa { const EjecucionAlarmaNativa({ required this.alarmaId, @@ -137,7 +171,20 @@ abstract class PuertoAlarmasAndroid { Future programar(AlarmaMusical alarma); Future cancelar(String alarmaId); Future ocultarNotificacionAlarma(String alarmaId); + + /// Notification-only dismissal (RES-1): hides the fire notification for + /// [alarmaId] WITHOUT stopping native ring audio for any alarm. Used when a + /// genuinely different alarm rings while another one is still active. + Future ocultarSoloNotificacion(String alarmaId); Future detenerSonidoNativo(String alarmaId); + + /// Synchronous companion snapshot (Decision 1): the id of the alarm + /// currently ringing natively, or null if none is. + Future alarmaSonandoId(); + + /// Id-agnostic fail-safe stop: silences whatever is ringing regardless of + /// which alarm the caller thinks is active, and reports a verified result. + Future detenerSonidoActivo(); Future solicitarPermisoAlarmasExactas(); Future solicitarPermisoNotificaciones(); Future solicitarPermisoPantallaCompleta(); @@ -195,6 +242,8 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { 'snoozeCountdownTemplate': _plantillaMinutos(l10n.snoozeCountdown), 'openFolderTitle': l10n.openFolderChooserTitle, 'openRecordingTitle': l10n.openRecordingChooserTitle, + 'missedTitle': l10n.alarmMissedNotificationTitle, + 'missedTemplate': _plantillaNombre(l10n.alarmMissedNotificationText), }); } catch (e) { debugPrint('[PluriWave][alarmas] setNotificationStrings ERROR $e'); @@ -209,6 +258,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { return traducir(sentinel).replaceFirst(sentinel.toString(), '{minutes}'); } + /// Same sentinel-swap approach as [_plantillaMinutos], but for a + /// `{String} -> String` message: swaps a unique sentinel token back for the + /// literal `{name}` placeholder Kotlin fills in at fire time. + static String _plantillaNombre(String Function(Object) traducir) { + const sentinel = 'PLURIWAVE_NAME_SENTINEL'; + return traducir(sentinel).replaceFirst(sentinel, '{name}'); + } + @override Stream get eventosAlarma => _eventosController.stream; @@ -286,10 +343,43 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { Future ocultarNotificacionAlarma(String alarmaId) => _logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId}); + @override + Future ocultarSoloNotificacion(String alarmaId) => + _logAndInvokeVoid('dismissAlarmNotificationOnly', {'id': alarmaId}); + @override Future detenerSonidoNativo(String alarmaId) => _logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId}); + @override + Future alarmaSonandoId() async { + try { + return await _channel.invokeMethod('getActiveRingingAlarmId'); + } catch (e) { + // Fail-toward-silence (Finding 2): a query failure must NOT be + // mistaken for "nothing is ringing" by callers like + // EstadoAlarmas._detenerSiEstaSonando, which would otherwise skip the + // stop entirely on a genuinely ringing alarm. Rethrow so the caller can + // fall back to the id-scoped legacy stop instead. + debugPrint('[PluriWave][alarmas] alarmaSonandoId ERROR $e'); + rethrow; + } + } + + @override + Future detenerSonidoActivo() async { + try { + final raw = await _channel.invokeMethod>( + 'stopActiveAlarm', + ); + if (raw == null) return ResultadoDetencion.fallo; + return ResultadoDetencion.fromMap(raw); + } catch (e) { + debugPrint('[PluriWave][alarmas] detenerSonidoActivo ERROR $e'); + return ResultadoDetencion.fallo; + } + } + @override Future solicitarPermisoAlarmasExactas() async { final abierto = await _channel.invokeMethod( diff --git a/openspec/changes/alarm-system-overhaul/apply-progress.md b/openspec/changes/alarm-system-overhaul/apply-progress.md new file mode 100644 index 0000000..4b8f62f --- /dev/null +++ b/openspec/changes/alarm-system-overhaul/apply-progress.md @@ -0,0 +1,68 @@ +# alarm-system-overhaul — Apply Progress (Slice A) + +## Status: complete (pending final full-suite verification + bounded review) + +Slice A (Phases 1-7 of tasks.md) is fully implemented and every task checkbox is marked with +verified evidence. Phase 8 / Slice B (P1 permission fallbacks) is intentionally untouched — +deferred to a follow-up delivery per the orchestrator decision recorded in Engram +(`decision/alarm-system-overhaul-pre-apply-orchestrator-decisions`). + +## How this phase ran (recovery notes) + +- The initial apply agent implemented most of Slice A but stalled (stream watchdog) before + recording progress; a second continuation agent also stalled early. Recovery was done in + smaller stages: (1) a mapping agent verified every Phase 1-7 task acceptance criterion + against the working tree (grep/diff evidence, table below summarized) and marked tasks.md; + (2) a surgical TDD agent closed the one functional gap the mapping found (task 7.4); + (3) the orchestrator ran the final full-suite verification. +- Baseline established during recovery: full `flutter test` suite green (544 tests) and + `flutter analyze` clean (1 known pre-existing info in test/estado/estado_radio_test.dart:641) + BEFORE the 7.4 fix; final run after 7.4 recorded below. + +## Task evidence summary (full table in the mapping agent report; all grep-verified) + +- 1.1-1.4 (fail-safe native stop): `@Volatile activeRingingId` companion snapshot; + atomic `stopEverything()` (stopForeground(REMOVE)+stopSelf+wakelock+MediaPlayer+notification) + routed from ACTION_STOP/ACTION_STOP_ACTIVE/onDestroy/missed paths; id-agnostic + `ACTION_STOP_ACTIVE` for ringing UI + notification Stop (`stopPendingIntent` action updated). +- 2.1-2.7 (auto-silence + durable firing record): `AUTO_SILENCE_MILLIS` (10 min), + AlarmManager-armed `ACTION_MISSED` → `onAlarmMissed` (stops if active, posts missed + notification via existing channel, clears firing record, no re-arm of the fired instance); + durable firing record written before `player.start()`, `onStartCommand` stale re-validation, + `START_NOT_STICKY` preserved; boot-time `cleanupStaleFiringRecords()` in + `reschedulePersistedAlarms`; `missedTitle`/`missedText` in AlarmNotificationStrings. +- 3.1 (channel contract): `stopActiveAlarm` + `getActiveRingingAlarmId` in MainActivity dispatcher; + `stopActiveAlarm` reports `STOP_FAILED` errors instead of silent void. +- 4.1-4.6 (Dart orchestration): `ResultadoDetencion`, `alarmaSonandoId()`, + `detenerSonidoActivo()` on the puerto; fakes gained `fallaDetener`/`alarmaSonandoIdValor`/ + `detencionesActivas`; `_detenerSiEstaSonando` wired into `guardarAlarma` (covers + `cambiarActiva`) and `eliminarAlarma`; `finalizarEjecucion` stops active sound and surfaces + `_error` on failure. Spec scenarios SS-1a/b/c/d, SS-2a/b covered by tests. +- 5.1-5.4 (ringing screen fail-safe UX): `forzarDetencion()`; `_detener()` keeps + dismiss-by-design but captures ScaffoldMessenger pre-dismiss and shows a retryable + force-stop SnackBar on failure (mirrors the existing snooze-failure pattern). + SS-3a/b/c covered by tests. +- 6.1-6.2 (missed-event sync): `accionMissed` native event → `_registrarEjecucionPerdida` + → `completarEjecucion`, no re-arm. Test present. +- 7.1-7.4 (l10n): 4 new keys in en+es ARBs only (11 other locales untouched — CI ARB guard); + missed-alarm strings wired through `setNotificationStrings` (`missedTitle`, + `missedTemplate` with `{name}` sentinel via new `_plantillaNombre` helper) — 7.4 closed the + gap found during mapping (RED→GREEN evidence in task report). + +## Deviations + +- Task 7.4 added during apply verification (missed-string wiring was implied by NA-3a intent + but not an explicit task). No other scope deviations; no unexpected diff content. + +## Changed-line accounting (excluding generated lib/l10n/gen/*) + +- Working tree at mapping time: ~1045 insertions total incl. generated; non-generated portion + within the 800-line session ceiling (final figure recorded by the review lifecycle at + review start). + +## Remaining work + +- None for Slice A code. Next: bounded 4-lens review → sdd-verify → commit/push. +- Slice B (Phase 8, P1 fallbacks) — follow-up delivery. +- On-device QA checklist (tasks.md) — requires the user with real hardware; sdd-archive held + until that passes. diff --git a/openspec/changes/alarm-system-overhaul/design.md b/openspec/changes/alarm-system-overhaul/design.md new file mode 100644 index 0000000..5fc3394 --- /dev/null +++ b/openspec/changes/alarm-system-overhaul/design.md @@ -0,0 +1,212 @@ +# Design: Alarm System Overhaul — Fail-Safe Stop/Dismiss + +## Technical Approach + +Evolve the existing native-owns-audio architecture; no rewrite. The single key enabler is that +the service, activity and receivers share ONE process (confirmed: no `android:process`), so +`MainActivity` can read a `@Volatile` companion field on `PluriWaveAlarmService` synchronously to +build verifiable stop results without a service round-trip. Four surgical additions: (1) an +id-agnostic fail-safe stop that can never no-op a live ring; (2) an atomic `stopEverything()` all +stop paths funnel through; (3) an AlarmManager-armed `FIRED→MISSED` auto-silence bound; (4) a +durable firing record in the existing device-protected prefs for process-death recovery. All +decision logic lives in Dart (mutation-while-ringing guard, stop-result handling) behind new fake +switches; Kotlin stays trivially static-grep-verifiable. Maps to proposal P0 (stop-safety) + P1 +(fallbacks); P2 deferred. + +## Architecture Decisions + +### Decision 1 — Native stop semantics (verify-and-report) + +| Option | Tradeoff | Decision | +|---|---|---| +| Service writes result to static field, channel polls | Race between async `onStartCommand` and channel read | Rejected | +| Channel reads `@Volatile` companion snapshot (same process), then dispatches id-agnostic stop | Snapshot is authoritative pre-stop; stop cannot no-op | **Chosen** | + +`PluriWaveAlarmService` gains companion `@Volatile var activeRingingId: String?` (set in +`startAlarm`, cleared in `stopEverything`). New channel methods: +- `getActiveRingingAlarmId(): String?` — synchronous companion read; this is HOW Dart knows what + is ringing. +- `stopActiveAlarm(): { stopped: bool, wasRinging: bool, activeAlarmId: String? }` — reads the + snapshot, dispatches `ACTION_STOP_ACTIVE` (id-agnostic full teardown), returns the pre-stop + snapshot. `stopped` = a non-no-op teardown was dispatched (always true on success path); + `wasRinging` = `activeAlarmId != null`. **Error contract**: internal exception → + `result.error("STOP_FAILED", msg, null)`; Dart treats a thrown channel error OR `stopped==false` + as failure → retryable force-stop. + +**Concurrent case**: only one alarm rings at a time (`startAlarm` early-returns while +`activeAlarmId != null`). `stopActiveAlarm` silences the ONE audible ring and returns its id so +Dart reconciles; the id-agnostic form is used ONLY by the ringing UI Stop and the notification Stop +action (explicit "silence what I hear" intents). Mutation guards (Decision 5) gate on the returned +`activeAlarmId` so a background toggle of a non-ringing alarm never stops a live one. Ambiguity +fails toward silence per the acceptance bar. + +### Decision 2 — Atomic `stopEverything()` + +Extract the current teardown block (`PluriWaveAlarmService.kt` L410-433) into one private +`stopEverything()`: cancel fallback+fade runnables, `player.stop()/release()`, `activeAlarmId=null`, +clear companion, `releaseWakeLock()`, `abandonAlarmAudioFocus()`, cancel notification, +`AlarmScheduler(this).clearFiringRecord(id)` + `cancelAutoSilence(id)`, +`stopForeground(REMOVE)`, `stopSelf()`. Every path routes through it: `ACTION_STOP` +(id match/null), `ACTION_STOP_ACTIVE`, `ACTION_SNOOZE`, `ACTION_MISSED`, `onDestroy`. `stopAlarm(id)` +stays as the id-scoped wrapper (foreign-id mismatch → cancel that id's notification only, then +return; else `stopEverything()`). + +### Decision 3 — Auto-silence (AlarmManager-armed MISSED) + +| Option | Tradeoff | Decision | +|---|---|---| +| In-service `Handler.postDelayed(10min)` | Dies with process; no missed-notification/rearm if service later gone | Rejected | +| AlarmManager `setExactAndAllowWhileIdle` → receiver `ACTION_MISSED` | Survives process death; posts missed notification + rearm even if FGS was killed; reuses existing scheduling infra | **Chosen** | + +Fixed 10 min (matches the existing wakelock cap; configurability = P2). Armed in `onAlarmFired` +(already runs at fire time and already rearms the next occurrence, so `onAlarmMissed` must NOT +re-rearm — only silence + notify + clear record). Cancelled in `snooze`, `skipNext`, `cancelAlarm` +and `stopEverything`. `onAlarmMissed(id)`: stop the service if this id still rings, post a missed +notification (reuse the pre-notice non-FSI channel + `AlarmNotificationStrings`, new keys +`missedTitle`/`missedText`), clear the firing record. + +### Decision 4 — Durable firing record + +Store in the existing device-protected prefs `pluriwave_alarm_scheduler` (`AlarmScheduler.prefs()`, +direct-boot safe). Schema: `KEY_FIRING_IDS` (string set) + `firing_` → `firedAtMillis` (Long). +**Write** in `onAlarmFired` BEFORE the service/audio starts (receiver runs `onAlarmFired` first). +**Clear** in `stopEverything`/`onAlarmMissed`. **`onStartCommand` re-validation**: `startAlarm` +checks `firingRecordAgeMillis(id)`; if `> AUTO_SILENCE_MILLIS` → abort start, run `onAlarmMissed` +cleanup (defends against redelivery/resurrection; `START_NOT_STICKY` already prevents blind +restart). **Boot cleanup**: `reschedulePersistedAlarms` calls `cleanupStaleFiringRecords()` first — +any record older than the window is cleared as missed, so a reboot mid-ring never resurrects audio. + +### Decision 5 — Dart orchestration + +`PuertoAlarmasAndroid` gains `Future alarmaSonandoId()` and +`Future detenerSonidoActivo()` (new value type +`ResultadoDetencion{ bool detenido; bool estabaSonando; String? alarmaId; }`); `detenerSonidoNativo` +stays for compatibility. `EstadoAlarmas` gains a shared guard +`_detenerSiEstaSonando(String id)`: query `alarmaSonandoId()`; if it equals `id` → +`detenerSonidoActivo()`. Wired into `guardarAlarma`/`cambiarActiva(false)`/`eliminarAlarma` +(upgrade its existing `detenerSonidoNativo` call) so any mutation of the ringing alarm silences it +first. `finalizarEjecucion` (the Stop path) calls `detenerSonidoActivo()` directly; on failure it +sets `_error` (same channel the snooze SnackBar already reads). New `forzarDetencion()` re-invokes +`detenerSonidoActivo()` for the retry action. + +**UX** (`pantalla_alarma_sonando.dart`): keep dismiss-by-design, EXCEPT on a verified `_detener()` +failure — a timed SnackBar would auto-dismiss while the alarm is still audibly ringing, hiding the +only retry affordance. **Amended by review round 2**: instead the screen stays up and renders a +persistent in-screen banner (`alarmStopFailedMessage` + a `alarmForceStopAction` button calling +`forzarDetencion()`) that clears only on a confirmed stop, never on a timer. + +**l10n** (new keys): `alarmStopFailedMessage`, `alarmForceStopAction`, +`alarmMissedNotificationTitle`, `alarmMissedNotificationText({name})`. Provide `en` (template, with +`@`-metadata + placeholders) and `es`. **Policy for the other 12 locales**: given the ARB +placeholder-corruption CI guard, define placeholder metadata ONLY in the template and mirror it +byte-exactly in `es`; omit the keys from the remaining locales so gen-l10n falls back to `en` at +runtime (untranslated-message warning is acceptable) — this avoids introducing placeholder metadata +into 12 files and the corruption risk the guard protects against. Flag full translation as a +follow-up. + +### Decision 6 — P1 permission/FSI fallbacks + +FSI fallback is largely automatic: `buildNotification` keeps `IMPORTANCE_HIGH` + +`setFullScreenIntent`, which the platform degrades to heads-up when `canUseFullScreenIntent()` is +false. The P1 work is the in-app WARNING: `diagnostics` already exposes +`canUseFullScreenIntent`/`notificationsEnabled`/`canScheduleExactAlarms`; surface warning banners on +the existing diagnostics/settings surface bound to `EstadoAlarmas.diagnostico` (new warning strings). +Lightly specified; sliced after P0. + +## Data Flow + + Fire: Receiver(ACTION_FIRE) ─→ AlarmScheduler.onAlarmFired + │ writes firing record (before audio) + arms MISSED + rearms next + └─→ Service.startAlarm ─→ companion.activeRingingId=id ─→ audio + + Stop: RingingUI/NotifStop ─→ (channel) stopActiveAlarm + │ reads companion snapshot ─→ result{stopped,wasRinging,activeAlarmId} + └─→ ACTION_STOP_ACTIVE ─→ stopEverything() ─→ clear record + cancel MISSED + + Mutate: EstadoAlarmas.guardar/cambiar/eliminar ─→ alarmaSonandoId() + └─ if == target ─→ detenerSonidoActivo() ─→ stopEverything() + + Timeout: AlarmManager(MISSED) ─→ Receiver(ACTION_MISSED) ─→ onAlarmMissed + └─→ stop service + missed notification + clear record (no re-rearm) + +## File Changes + +| File | Action | Change | +|---|---|---| +| `PluriWaveAlarmService.kt` | Modify | companion `activeRingingId`/`ACTION_STOP_ACTIVE`/`AUTO_SILENCE_MILLIS`/`stopActive()`; `onStartCommand` +ACTION_STOP_ACTIVE; `startAlarm` sets companion + stale-record re-validation; extract `stopEverything()` | +| `AlarmScheduler.kt` | Modify | `recordFiring`/`clearFiringRecord`/`firingRecordAgeMillis`/`cleanupStaleFiringRecords`; `armAutoSilence`/`cancelAutoSilence`/`onAlarmMissed`; `onAlarmFired`+arm+record; `reschedulePersistedAlarms`+cleanup; cancel in snooze/skipNext/cancelAlarm | +| `PluriWaveAlarmReceiver.kt` | Modify | `ACTION_MISSED` constant + branch → `onAlarmMissed`; `pendingMissedIntent` helper | +| `MainActivity.kt` | Modify | channel `stopActiveAlarm` (returns map) + `getActiveRingingAlarmId`; pass missed strings in `setNotificationStrings` | +| `AlarmNotificationStrings.kt` | Modify | `missedTitle`/`missedText` getters+setters | +| `servicio_alarmas_android.dart` | Modify | `ResultadoDetencion`; `alarmaSonandoId()`; `detenerSonidoActivo()`; interface additions | +| `estado/estado_alarmas.dart` | Modify | `_detenerSiEstaSonando` guard; wire into guardar/cambiar/eliminar/finalizar; `forzarDetencion()`; missed-event handling | +| `pantallas/pantalla_alarma_sonando.dart` | Modify | force-stop SnackBar action on stop failure | +| `l10n/arb/app_en.arb`, `app_es.arb` | Modify | 4 new keys (en template + es) | +| `test/helpers/fakes_alarmas.dart` | Modify | `fallaDetener`, `alarmaSonandoIdValor`, `detencionesActivas`, new interface impls | +| `test/**` (Dart) | New | stop/mutation/force-stop/missed tests | + +## Interfaces / Contracts + +```dart +class ResultadoDetencion { + final bool detenido; // stop dispatched, cannot no-op + final bool estabaSonando; // audio was live + final String? alarmaId; // what was actually ringing +} +abstract class PuertoAlarmasAndroid { + Future alarmaSonandoId(); // getActiveRingingAlarmId + Future detenerSonidoActivo(); // stopActiveAlarm + // ...existing members unchanged +} +``` + +Channel (`pluriwave/alarm_scheduler`): `getActiveRingingAlarmId → String?`, +`stopActiveAlarm → {stopped,wasRinging,activeAlarmId}`. + +## Testing Strategy + +| Layer | What | How | +|---|---|---| +| Unit (Dart) | Stop success/failure surfaces result | `fallaDetener` → `EstadoAlarmas.error` set; assert `detenerSonidoActivo` called | +| Unit (Dart) | Mutation-while-ringing stops audio | `alarmaSonandoIdValor=target` → `cambiarActiva(false)`/`guardarAlarma(inactive)`/`eliminarAlarma` assert force-stop invoked | +| Unit (Dart) | No false stop on non-ringing target | mismatched `alarmaSonandoIdValor` → assert force-stop NOT invoked | +| Widget | Force-stop retry affordance | stop failure → SnackBar with action → action calls `forzarDetencion` | +| Unit (Dart) | Missed event bookkeeping | native `missed` event → `completarEjecucion` recorded | +| On-device QA | Native-only proofs | see checklist | + +**On-device QA checklist** (only a device proves): Stop from ringing UI (id match AND mismatch); +Stop from lock-screen notification; disable/edit/delete while ringing; 10-min untouched → +auto-silence + missed notification + repeating rearm; kill app mid-ring (audio stops); reboot +mid-ring (boot cleanup, no resurrection); concurrent second alarm; FSI-denied heads-up fallback. + +## Threat Matrix + +N/A — no routing, shell, subprocess, VCS/PR automation, executable-file classification, or +shell/process-integration boundary. (Android service/IPC is not in scope of the shell/subprocess +threat matrix.) + +## Migration / Rollout + +No migration. New pref keys (`firing_`, `KEY_FIRING_IDS`) are additive and self-cleaning by age; +orphaned entries are ignored if the reader is reverted. Single feature branch to `main`; P0 first, +P1 as a follow-up slice within budget. + +## Consequences / Rollback + +Ships to live alarm users. Regression manifestations & mitigations: +- **Wrong concurrent alarm silenced** — bounded by the one-ring-at-a-time guard; `stopActiveAlarm` + returns the stopped id for reconciliation. +- **Auto-silence fires early** — bounded to 10 min = wakelock cap; user re-arm unaffected. +- **Firing-record bug → false "missed" or blocked start** — age-gated; stale-only cleanup; worst + case a legitimate ring is cut at the 10-min bound (still better than the unbounded incident). + +**Fastest rollback**: `git revert` the merge/PR — native and Dart changes are additive to existing +stop paths, so revert restores current behavior; orphaned firing-record keys go unread (no migration +rollback needed). + +## Open Questions + +- [ ] Missed notification channel: reuse the pre-notice channel vs. a dedicated low-importance + `missed` channel? (Leaning reuse for minimal diff; confirm in tasks.) +- [ ] Should `finalizarEjecucion` return `Future` for a cleaner contract, or keep the + `_error`-field convention the snooze path uses? (Leaning `_error` for consistency.) diff --git a/openspec/changes/alarm-system-overhaul/explore-codebase.md b/openspec/changes/alarm-system-overhaul/explore-codebase.md new file mode 100644 index 0000000..0a8fd9e --- /dev/null +++ b/openspec/changes/alarm-system-overhaul/explore-codebase.md @@ -0,0 +1,56 @@ +# alarm-system-overhaul — Codebase Deep-Dive (explore phase 1/2) + +## 1. Inventory + +### Dart (lib/) +- `estado/estado_alarmas.dart` — ChangeNotifier, canonical alarm state (`ConfiguracionAlarmas`). Key methods: `guardarAlarma` (L99, calls `android.programar`, NEVER calls `detenerSonidoNativo` even if the saved alarm is the one currently ringing), `eliminarAlarma` (L155, DOES call `android.detenerSonidoNativo` before `cancelar`), `cambiarActiva` (L164, delegates to `guardarAlarma` — same gap), `posponerAlarma` (L195), `posponerProximaDesdePreaviso` (L241), `finalizarEjecucion` (L272, calls `android.ocultarNotificacionAlarma` then `servicio.completarEjecucion`), `_alRecibirEventoNativo`/`_registrarCancelacionSnoozeNativa` (native-event sync), `_importarSnoozesNativosActivos` (cold-start snooze import). +- `servicios/servicio_alarmas_android.dart` — `ServicioAlarmasAndroid implements PuertoAlarmasAndroid`, wraps `MethodChannel('pluriwave/alarm_scheduler')`. `_logAndInvokeVoid` (L386) invokes the channel with NO try/catch — errors propagate to caller. Methods: `programar`, `cancelar`→`cancelAlarm`, `ocultarNotificacionAlarma`→`dismissAlarmNotification`, `detenerSonidoNativo`→`stopNativeAlarmSound`. `_instalarHandler` (L394) receives native `alarmFired` events. +- `servicios/servicio_alarmas.dart`, `servicio_programacion_alarmas.dart` — pure scheduling/next-occurrence math + persistence (`persistencia_tolerante.dart`). +- `pantallas/pantalla_alarma_sonando.dart` — ringing screen, audio-free (native owns audio). `_detener()` (L42) and `_posponer()` (L61) both: single-exit guard `_salidaEnCurso`, call into `EstadoAlarmas`, wrap in try/catch, **`finally { _dismissScreen() }` — the screen ALWAYS closes even if the native stop/snooze call throws** ("dismiss-by-design", intentional per comments, to avoid a stuck screen — but it also means a failed/no-op native stop is invisible to the user). `PopScope(canPop:false)` routes system back through the same `_detener()`. +- `app.dart` — `_alarmaSonandoActiva`/`_alarmaSonandoId` guard (L107-109) is now `finally`-protected (L388-392) — the historical stuck-modal/skipped-next-ring bug (single failure, two symptoms) is fixed. `_mostrarAlarmaSonando` (L354) correctly no-ops a duplicate delivery of the SAME ring and hides the notification only for a genuinely different concurrent alarm id (L371-373). + +### Kotlin (android/app/src/main/kotlin/es/freetimelab/pluriwave/) +- `PluriWaveAlarmService.kt` — foreground service, SOLE audio owner (MediaPlayer on STREAM_ALARM/USAGE_ALARM). `onStartCommand` (L51) dispatches ACTION_STOP→`stopAlarm` (L58), ACTION_SNOOZE→native `AlarmScheduler.snooze` + `stopAlarm` (L61-84), FIRE/null→`startAlarm` (L85). `startAlarm` (L91) early-returns if `activeAlarmId != null` (single-ring-at-a-time). `startAudio`/`startStationAudio`/`startFallbackAudio` implement a 3-stage fallback chain (station → fallback station → bundled WAV) each with a 15s timeout (`scheduleStationFallback`) and a shared exponential dB fade loop (`startFadeLoop`/`computeFadeVolume`). **`stopAlarm` (L390) has an id-scoped guard: `if (alarmId != null && activeAlarmId != null && alarmId != activeAlarmId)` → does NOT stop audio/service, only cancels the notification for the mismatched id** — deliberate (protects the real ring from a second alarm's stop request) but is also the single point where an id mismatch would silently no-op a real stop. `buildNotification` (L436) posts the ONE fire notification (NOTIFICATION_ID=92841) with Snooze+Stop actions as `PendingIntent.getService` DIRECTLY to this service (bypasses Flutter entirely — robust even with a dead engine). WakeLock capped at 10 min (L509) — irrelevant to audio stoppability (CPU only). +- `PluriWaveAlarmReceiver.kt` — BroadcastReceiver for FIRE/PRE_NOTICE/SKIP_NEXT/POSTPONE_NEXT/SNOOZE_COUNTDOWN/SNOOZE_AGAIN/CANCEL_SNOOZE. `notificationIdForAlarm`/`fireNotificationIdForAlarm` (L274-275) are deterministic hash-based ids (53*hash+7 / 59*hash+9) — two DIFFERENT notification ids per alarm id (pre-notice/countdown vs. fire), so no id collision between the two channels. +- `AlarmScheduler.kt` — `scheduleAlarm`/`scheduleSpec` (trusts Dart's trigger when fresh, native recompute only as fallback — documented on-device divergence bug already fixed), `onAlarmFired`, `snooze`/`postponeNext`/`snoozeAgain` (anchor semantics documented), `cancelSnooze`, `cancelAlarm` (L659, does NOT touch the running service/audio — only cancels PendingIntents/notifications), `dismissFireNotification` (L676, notification-cancel only, no audio stop), `reschedulePersistedAlarms` (boot/unlock/TZ-change/package-replace/exact-alarm-permission-change). +- `MainActivity.kt` — MethodChannel `pluriwave/alarm_scheduler` handler (L89): `scheduleAlarm`, `cancelAlarm`, `dismissAlarmNotification` (L144, calls **both** `PluriWaveAlarmService.stop(this,id)` AND `alarmScheduler.dismissFireNotification(id)`), `stopNativeAlarmSound` (L155, calls only `PluriWaveAlarmService.stop`), `diagnostics`, permission requests, `getInitialAlarmIntent`/`getHandledAlarmOccurrences`/`getNativeSnoozeState`, `setNotificationStrings`. `notifyAlarmEvent` (companion, L1204) forwards native-originated events to Flutter ONLY if `activeInstance` (the live Activity) is non-null — dead-engine snoozes rely on cold-start `getNativeSnoozeState` sync instead. +- `PluriWaveBootReceiver.kt` — BOOT_COMPLETED/LOCKED_BOOT_COMPLETED/USER_UNLOCKED/MY_PACKAGE_REPLACED/TIME_SET/TIMEZONE_CHANGED/SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED → `AlarmScheduler.reschedulePersistedAlarms()`. +- `AlarmNotificationStrings.kt`, `NotificationBrand.kt` — device-protected-storage-backed i18n strings pushed from Dart via `setNotificationStrings` (works even before first unlock, direct-boot-aware). +- No separate `android:process` — service/activity/receivers all share the app's default process; `MainActivity.activeInstance` reachability is NOT a cross-process concern. + +### Channels / persistence +- MethodChannel `pluriwave/alarm_scheduler` (Dart↔Kotlin): scheduleAlarm, cancelAlarm, dismissAlarmNotification, stopNativeAlarmSound, diagnostics, requestExactAlarmPermission, requestPostNotificationsPermission, requestFullScreenIntentPermission, requestIgnoreBatteryOptimizations, getInitialAlarmIntent, getHandledAlarmOccurrences, getNativeSnoozeState, setNotificationStrings; reverse direction `alarmFired` (native→Dart). +- SharedPreferences (regular, per-alarm native spec store) + device-protected-storage prefs (`pluriwave_alarm_channels` migration flag, `AlarmNotificationStrings`). +- Dart side: `persistencia_tolerante.dart` for alarm config resilience against corruption. + +## 2. Full lifecycle traces — key points +- Scheduling always prefers `setAlarmClock` (L235), falls back through `setExactAndAllowWhileIdle`→`setAndAllowWhileIdle`→`set` depending on SDK/exact-alarm permission (`scheduleMainAlarm`, AlarmScheduler.kt L228-278). +- Fire path: Receiver (ACTION_FIRE) → `AlarmScheduler.onAlarmFired` (reschedule bookkeeping) → `PluriWaveAlarmService.start` (posts FSI notification BEFORE audio prepares) → `startActivity(MainActivity)` (brings UI forward regardless of process state) → native audio 3-stage fallback with fade-in. +- Stop from notification: `PendingIntent.getService` → service `ACTION_STOP` directly (Flutter-independent, most robust path). +- Stop from in-app modal: MethodChannel → `stopNativeAlarmSound`/`dismissAlarmNotification` → `PluriWaveAlarmService.stop` (same code path as notification button) — but gated by Flutter engine being alive AND the call succeeding. +- Dead-app fire: Receiver creates process, starts service + activity; Flutter engine boots concurrently; `getInitialAlarmIntent`/cold-start sync reconciles state once engine is up. + +## 3. FAILURE-MODE ANALYSIS (ranked by likelihood/evidence) + +1. **[HIGHEST] Silent no-op on native id mismatch, masked by Dart's "dismiss-by-design".** `PluriWaveAlarmService.stopAlarm` (L400-409) silently no-ops the actual stop when `alarmId != activeAlarmId` (only cancels a notification). This call NEVER throws in that branch, so Dart's `_detener()`/`_posponer()` try/catch never fires and the ringing screen closes as if it worked (comment at pantalla_alarma_sonando.dart:38-41, and the "dismiss-by-design preserved" test explicitly locks in this behavior for the SNOOZE path only). If ANY id-derivation drift exists between what Dart passes and the service's `activeAlarmId` (e.g. after a snooze/reschedule mutates the spec, or during the documented "second alarm during ring" scenario), the user sees the screen close/app return to normal while the native `MediaPlayer` keeps playing — matching "not by opening the app" in the incident exactly (opening the app and tapping Stop APPEARED to work, screen closed, but audio never stopped). +2. **[HIGH] Toggling an alarm off (or editing/saving it) while it is the one currently ringing does not stop the native audio.** `EstadoAlarmas.guardarAlarma` (L99-116) → `android.programar` → (if now inactive) `cancelar` (native `cancelAlarm`, AlarmScheduler.kt L659) — cancels FUTURE schedules/notifications only, never calls `PluriWaveAlarmService.stop`. Only `eliminarAlarma` (full delete) calls `detenerSonidoNativo` first. A user who — after a failed/ambiguous Stop tap — panics and disables the alarm from the Alarms list will NOT stop the ringing audio, and will have destroyed the association between the alarm config and the still-ringing id, making a subsequent recovery attempt harder to reason about. +3. **[MEDIUM] Untested failure path for the exact defensive code that exists.** `FakePuertoAlarmasAndroid` (test/helpers/fakes_alarmas.dart) has a `fallaProgramar` failure switch for `programar` (used to test the snooze-failure SnackBar), but NO equivalent switch for `ocultarNotificacionAlarma`/`detenerSonidoNativo`. No test exercises `_detener()`'s catch/finally when the STOP call itself fails — the exact guard meant to catch this class of incident is unverified by CI. +4. **[MEDIUM] OEM background-execution restrictions / Doze / battery optimization.** `diagnostics` channel already surfaces `isIgnoringBatteryOptimizations` and requests exemption, but this is opt-in/dismissible by the user; on aggressive OEM skins (MIUI/EMUI/etc.) a `startService()` call from a notification action can be delayed or dropped even when the app already runs a foreground service — flagged as a plausible but unverifiable-from-code contributor. +5. **[LOWER] Reschedule/notification double-post races across concurrent alarms.** Code has explicit guards (`activeAlarmId != null` early-return in `startAlarm`, id-scoped `stopAlarm`) that appear to correctly prevent a second alarm's fire/stop from disturbing an active ring — analysis suggests this is already handled, kept as a residual risk only if the guard's assumptions (single Service instance, sequential onStartCommand dispatch) are violated by an OS-specific behavior. + +### Other fragilities found (not part of the core incident but real gaps) +- `cancelAlarm`/`dismissFireNotification` (AlarmScheduler.kt) never stop an active ring — see #2. +- WakeLock hardcoded 10-minute cap (PluriWaveAlarmService.kt:509) — does not affect stoppability but could affect CPU scheduling on rings intentionally left running longer (fade-in test/edge cases). +- `alarm-clock-module` OpenSpec change is stuck at `status: planned / phase: tasks-ready` since 2026-05-21 despite the alarm feature clearly being implemented and iterated on since — stale/orphaned SDD tracking artifact, needs reconciliation. +- `app-quality-and-native-alarms` is `status: proposed / phase: apply-complete` (2026-06-12) and was NEVER moved to verify/archive — explicitly flagged in its own risk table ("`alarm-clock-module` state drift... out of scope to fix mid-flight"). Contains Slice 1 (native reliability: foreground-service type, dedup notifications, channel sound, fallback station, battery exemption, native fade-in) and Slice 2 (full snooze-path audit) — need to verify against CURRENT code which of these already landed (current code already shows FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK|SYSTEM_EXEMPTED, single FSI-owning service notification, fallback station support, native fade-in — so Slice 1 appears substantially implemented even though the artifact was never archived). + +## 4. Test coverage map +- Covered (Dart): snooze failure SnackBar + dismiss-by-design (`pantalla_alarma_sonando_dismiss_guard_test.dart`), `_alarmaSonandoActiva` guard regressions, native snooze sync (`estado_alarmas_snooze_test.dart`), pre-notice/countdown templates, alarm cache/corruption/persistence tolerance. +- NOT covered (Dart): failure/no-op of `ocultarNotificacionAlarma`/`detenerSonidoNativo` from the Stop path (no fake failure switch exists); toggling/saving an alarm while it is the one actively ringing; concurrent-alarm id-mismatch stop scenario end-to-end. +- NOT covered (Kotlin): **zero** — no Kotlin test files exist in the repo (`android/**/*Test*.kt` glob returns nothing) and there is no Android build environment available in this session to add/run any. All native-service claims above (id-scoped stop guard, 3-stage audio fallback, fade loop, wakelock) are verified only by static code reading, never executed. + +## 5. Known-debt from prior OpenSpec alarm changes +- `alarm-clock-module` (2026-05-21): stuck at tasks-ready, never applied/archived in SDD tracking — reconcile or supersede. +- `app-quality-and-native-alarms` (2026-06-11/12): apply-complete but never verified/archived; its own risk table flags the `alarm-clock-module` drift as deferred. Needs a fresh verify pass against current code to confirm which of its 7 slices actually landed. +- `alarm-live-countdown` (archived 2026-06-28, PASS WITH WARNINGS): pre-notice l10n + snooze dismiss guard — done, warnings were about Spanish-only button labels (deferred) and absent Kotlin test infra (still absent today). +- `snooze-reschedule-fix` (archived 2026-07-01, PASS WITH WARNINGS): posponerAlarma/posponerProximaDesdePreaviso error-handling parity — done; noted follow-ups: DI seams for PluriWaveApp testability (still not done — contributed to inability to widget-test app.dart's routing), dedicated l10n key for snooze failure (still reuses androidExactAlarmScheduleError). diff --git a/openspec/changes/alarm-system-overhaul/explore-research.md b/openspec/changes/alarm-system-overhaul/explore-research.md new file mode 100644 index 0000000..8d445c4 --- /dev/null +++ b/openspec/changes/alarm-system-overhaul/explore-research.md @@ -0,0 +1,112 @@ +# Alarm Platform Contract — Android Alarm Reliability Reference (mid-2026) + +Scope: authoritative rules for building a bulletproof Android alarm clock, targeting Android 14/15/16-era devices (targetSdk 35/36). Grounds the `alarm-system-overhaul` behavior spec and refactor. Repo context checked: `android/app/src/main/AndroidManifest.xml` declares `SCHEDULE_EXACT_ALARM`+`USE_EXACT_ALARM` (redundant pairing, see Rule 3), `USE_FULL_SCREEN_INTENT`, `POST_NOTIFICATIONS`, `FOREGROUND_SERVICE_MEDIA_PLAYBACK`+`FOREGROUND_SERVICE_SYSTEM_EXEMPTED`; has custom native `.PluriWaveAlarmService` (`foregroundServiceType=mediaPlayback|systemExempted`), `.PluriWaveAlarmReceiver`, `.PluriWaveBootReceiver` (handles LOCKED_BOOT_COMPLETED/BOOT_COMPLETED/USER_UNLOCKED/MY_PACKAGE_REPLACED/TIME_SET/TIMEZONE_CHANGED/SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED — good coverage of reschedule triggers) — AND a separate `com.ryanheise.audioservice.AudioService` (Flutter `audio_service` plugin, used for radio streaming). Two independent audio/media-session owners in one app is the most likely root cause of the app's documented incident (alarm rang 15 min, only uninstall stopped it). + +## 1. Scheduling + +1.1 Use `AlarmManager.setAlarmClock()` for user-facing alarm-clock semantics, not `setExactAndAllowWhileIdle()`. `setAlarmClock()` is the highest-priority alarm type: the system exits Doze/App Standby to deliver it and never defers it, and it shows the alarm-clock icon in the status bar. `setExactAndAllowWhileIdle()` is "nearly precise" and intended for non-user-visible exact work, not primary alarm firing. [Android AlarmManager docs](https://developer.android.com/develop/background-work/services/alarms/schedule) — all API levels, but the precision distinction matters most from Android 6 (Doze) onward. + +1.2 On Android 12+ (API 31+), declare `SCHEDULE_EXACT_ALARM` and call `AlarmManager.canScheduleExactAlarms()` before scheduling; a `SecurityException` is thrown otherwise. On Android 14 (API 34), this permission is **no longer pre-granted on fresh installs** — apps must send the user to `ACTION_REQUEST_SCHEDULE_EXACT_ALARM` with an in-app rationale first. [Android 14 behavior change](https://developer.android.com/about/versions/14/changes/schedule-exact-alarms) + +1.3 `USE_EXACT_ALARM` (Android 13+/API 33+) is an install-time-granted, non-revocable-by-user permission but is restricted by Play Store policy to apps whose **core function** is alarms or calendars. Declaring BOTH `SCHEDULE_EXACT_ALARM` and `USE_EXACT_ALARM` in the same manifest (as this app currently does) is redundant and risky: Play may reject `USE_EXACT_ALARM` if core-functionality review fails, silently falling back to the revocable permission — the app must not assume `USE_EXACT_ALARM` guarantees a grant. [Schedule alarms guide](https://developer.android.com/develop/background-work/services/alarms/schedule) + +1.4 Register a receiver for `AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` and, on receipt, re-check `canScheduleExactAlarms()` and reschedule/rebuild every pending alarm instance. Do not trust a cached permission flag — the system can revoke exact-alarm permission automatically (e.g. long-unused apps) and this broadcast is the only signal. Repo already listens for this action in `PluriWaveBootReceiver` — verify the handler actually reschedules rather than just logging. + +1.5 Reschedule ALL alarms on: `BOOT_COMPLETED` (+ `LOCKED_BOOT_COMPLETED` for direct-boot-aware alarms), `MY_PACKAGE_REPLACED` (app update wipes AlarmManager state), `TIME_SET`, `TIMEZONE_CHANGED`. Missing any of these is a classic bug class ("alarm survived reboot but not timezone change"). Repo's `PluriWaveBootReceiver` already covers all four — confirm the Kotlin implementation actually recomputes trigger times rather than re-arming stale timestamps. + +1.6 If exact-alarm permission is denied, fall back to `setAndAllowWhileIdle()`/`setWindow()`, never silently drop the alarm — and surface a persistent "exact alarms disabled, alarm may be late" warning in-app, mirroring AOSP DeskClock's user-facing permission nags. + +## 2. Firing / Ringing (native-only responsibility) + +2.1 Canonical pattern: `AlarmManager` fires a `PendingIntent` → **BroadcastReceiver.onReceive()** (short-lived, <10s budget) → immediately calls `Context.startForegroundService()` (or `startForeground()` from the service within 5s per Android 8+ FGS rules) → the **Foreground Service** owns the wake lock, the ringtone/media player, and vibration for the entire ringing lifetime. This exact chain is what AOSP DeskClock's `AlarmService` + `AlarmActivity` do, and what the `alarm` (gdelataillade) Flutter plugin's native Android layer does — audio ownership lives in native Kotlin/Java, never in a Dart isolate, because Dart isolates are not guaranteed to be alive when the alarm fires. [Alarm plugin Android install guide](https://github.com/gdelataillade/alarm/blob/main/help/INSTALL-ANDROID.md) + +2.2 Foreground service type: use `mediaPlayback` (Android 10+ requirement) — AOSP DeskClock and the `alarm` plugin both use a media-playback-typed FGS for the ringing service. `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` alone is not a substitute; this app's `PluriWaveAlarmService` already declares `mediaPlayback|systemExempted`, which is correct, but the app must ensure exact-alarm-triggered FGS starts are exempt from the Android 12+ background-start restrictions (they are, by design, per platform docs: exact alarms are excluded from FGS-from-background limits). + +2.3 Do NOT rely on the audio system's implicit wake lock alone for anything beyond audio — the FGS itself must hold `PARTIAL_WAKE_LOCK` (`WAKE_LOCK` permission, already declared) for any non-audio work (vibration loop, timeout logic, UI signaling) and must release it deterministically in every stop path (`onDestroy`, explicit stop, timeout). When only `MediaPlayer`/`ExoPlayer` plays audio with `AudioAttributes.USAGE_ALARM`, the audio framework manages its own wake lock for the playback itself — but application-level state transitions still need an explicit lock if the CPU could otherwise sleep between them. [Background work: wake locks](https://developer.android.com/develop/background-work/background-tasks/awake/wakelock/identify-wls) + +2.4 Play ringtone via `MediaPlayer`/`ExoPlayer`/`Ringtone` configured with `AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)`. This ties playback to the **alarm** audio stream (`STREAM_ALARM`), which (a) bypasses Do Not Disturb by default — Android treats alarm-stream audio as a high-priority interruption that sounds regardless of DND unless the user explicitly disabled "Alarms" under DND exceptions — and (b) uses the alarm volume slider, not media/ring volume. **This is the single highest-value correctness rule for a hybrid Flutter+native app**: if the alarm ever plays through the `audio_service`/`just_audio` media-session pipeline (music/media stream) instead of a dedicated `STREAM_ALARM`/`USAGE_ALARM` player, it will (a) respect DND media-silencing, (b) follow media volume (can be 0), and (c) fight the radio-streaming engine for the same media session — a plausible root cause of "two engines playing / can't stop" incidents. + +2.5 Auto-silence: industry-standard timeout is a bounded window (AOSP DeskClock uses a configurable timeout, default historically ~15 minutes, now user-configurable "Silence after" 1–30 min or "Never"); after timeout, stop audio/vibration, transition to a "missed alarm" notification, and — for repeating alarms — compute and arm the next occurrence. Never let the ringing service loop indefinitely with no upper bound; an unbounded loop is exactly the "rang 15 minutes, uninstall required" failure mode. [AOSP DeskClock `AlarmStateManager`](https://github.com/LineageOS/android_packages_apps_DeskClock/blob/89aae7601d0bc17bf5f6e89f5a0b919a184256ae/src/com/android/deskclock/alarms/AlarmStateManager.java) + +## 3. UI over lock screen + +3.1 Android 14+ (API 34): `USE_FULL_SCREEN_INTENT` becomes a **special app access** permission auto-granted by Play only to apps whose core function is calls or alarms; otherwise it must be requested via `ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT` and checked with `NotificationManager.canUseFullScreenIntent()`. An alarm-clock app qualifies for default grant, but must still runtime-check the flag (Play/OEM can still revoke) and have a non-FSI fallback (heads-up high-priority notification) if denied. [source.android.com FSI limits](https://source.android.com/docs/core/permissions/fsi-limits) — effective policy date May 31 2024 / enforced from Jan 22 2025. + +3.2 Notification channel for ringing must be `IMPORTANCE_HIGH` (or `IMPORTANCE_MAX` where the platform still exposes it) with `setSound(null, ...)` — the CHANNEL must not play its own sound because the foreground service is already playing the alarm tone; a channel sound + service audio together is a double-audio bug. Set `category = CATEGORY_ALARM`. Attach `setFullScreenIntent(pendingIntent, true)` pointing at a dedicated full-screen Activity, and `setOngoing(true)`. + +3.3 The full-screen ringing Activity must call `setShowWhenLocked(true)` + `setTurnScreenOn(true)` (API 27+) instead of the deprecated window flags, matching this app's `MainActivity` manifest attributes — but the ringing UI should be its OWN activity (not `MainActivity`) with `android:excludeFromRecents`, `singleInstance`/`singleTask`, so it can be shown/dismissed independently of app navigation state. + +3.4 Android 12+ (API 31+) notification trampoline restriction: Stop/Snooze notification action buttons must be `PendingIntent.getForegroundService()` or `PendingIntent.getBroadcast()` directly — **never** a broadcast/service that itself calls `startActivity()`. Any notification action that needs to show UI after tapping must build a `PendingIntent` pointing straight at the destination Activity; do not chain through a receiver-that-launches-activity ("trampoline"), which is blocked and produces silent no-ops or logcat-only failures on Android 12+. [Notification trampoline restrictions](https://developer.android.com/about/versions/12/behavior-changes-12) + +## 4. Stop/Snooze correctness (single source of truth) + +4.1 There must be exactly ONE process/component that owns "is the alarm currently ringing" state and exactly ONE component that owns the audio player instance. In a Flutter app, this MUST be native Kotlin (the foreground service), not Dart — Dart/Flutter engine lifecycle (background isolates, `audio_service`'s Flutter-side handlers) is not guaranteed to be running or reachable when the user taps Stop from a lock-screen notification while the main Flutter engine is not attached. All Stop/Snooze `PendingIntent`s must route to the SAME native receiver/service that started the ringing, using a stable, unique request-code/alarm-instance-id so the intent unambiguously identifies the alarm to stop — never a broadcast that "maybe" reaches Dart via a plugin channel. + +4.2 Foreground service must NOT use `START_STICKY` for the ringing service if that causes the OS to resurrect it after being killed with no user-visible way to stop it. If restart-on-kill is desired for reliability, the restarted service must re-check persisted "is this alarm still supposed to be ringing" state (e.g., a stop-timestamp or dismissed flag in SharedPreferences/DB) on `onStartCommand` and self-terminate immediately if the alarm was already dismissed/stopped — otherwise a killed-and-restarted service can resume playing after the user already stopped it, exactly matching "un-dismissable alarm" reports. [START_STICKY restart discussion](https://issuetracker.google.com/issues/36986292) + +4.3 Notification cancellation and service lifecycle must be coupled atomically: cancelling/removing the notification does NOT stop a foreground service, and stopping the service without cancelling the notification leaves a stuck notification. Every stop path (user tap, timeout, snooze) must call BOTH `stopForeground(STOP_FOREGROUND_REMOVE)` (or equivalent) AND `stopSelf()`, plus release the wake lock, in one atomic method — never rely on the notification's own dismissal to imply the service stopped. + +4.4 If audio is ever routed through a second engine (e.g., `audio_service`'s `AudioService`/media session, used here for radio streaming) instead of the alarm-ringing native player, the Stop action must explicitly stop/abandon audio focus and release that engine too — a stop path that only signals the native alarm service while a second, independently-running audio engine continues playing is a documented Flutter/plugin-interaction failure mode (`audio_service` + background alarm plugin conflicts are reported upstream). [audio_service background-alarm issue](https://github.com/ryanheise/audio_service/issues/704) + +4.5 Handle process death mid-ring: persist "alarm X is currently firing since T" to durable storage (not just in-memory) BEFORE starting playback, and clear it only on a confirmed stop. On any service restart/reboot, if a "firing" record has no matching stop record and its age exceeds the auto-silence timeout, treat it as missed and clean up rather than resuming indefinitely. + +## 5. Reference state machine (AOSP DeskClock `AlarmStateManager`) + +States: `SILENT_STATE` → `LOW_NOTIFICATION_STATE` → `HIGH_NOTIFICATION_STATE` → `FIRED_STATE` → (`SNOOZE_STATE` | `MISSED_STATE`) → `DISMISSED_STATE`, plus a transient `HIDE_NOTIFICATION_STATE`. +- SILENT → LOW_NOTIFICATION: scheduled ahead-of-time via `setSilentState()`/`scheduleInstanceStateChange()`. +- LOW → HIGH_NOTIFICATION: automatic at `getHighNotificationTime()`; HIGH cannot be user-hidden (only dismiss/snooze). +- HIGH → FIRED: at the actual alarm trigger time; `setFiredState()` arms a timeout via `scheduleInstanceStateChange(context, timeout, instance, MISSED_STATE)`. +- FIRED → SNOOZE: user action, increments instance trigger time and re-arms. +- FIRED → MISSED: automatic on timeout expiry (this IS the auto-silence mechanism). +- MISSED → (delete | disable | reschedule next occurrence): `updateParentAlarm()` — one-shot alarms with "delete after use" are deleted, others disabled; repeating alarms create the next instance via `createInstanceAfter()`. +- Any state → DISMISSED: final, deletes the instance and checks the parent alarm for rescheduling. +[AOSP DeskClock AlarmStateManager, LineageOS mirror](https://github.com/LineageOS/android_packages_apps_DeskClock/blob/89aae7601d0bc17bf5f6e89f5a0b919a184256ae/src/com/android/deskclock/alarms/AlarmStateManager.java) + +Division of responsibility to replicate: the STATE MACHINE (scheduling transitions, timeout arming, missed/dismiss bookkeeping) can be modeled in Dart/domain layer as long as it only computes *when* to transition — but the ACTUAL transition execution at fire time (starting the FGS, playing audio, holding the wake lock, posting the FSI notification, handling Stop/Snooze taps) MUST be native Kotlin, invoked directly from the BroadcastReceiver, independent of whether the Flutter engine is attached. + +Contrast with Fossify/Simple-Clock's simpler model: their `AlarmReceiver` does NOT use a foreground service at all when screen is off — it posts a high-importance full-screen notification and launches a `ReminderActivity` directly, which owns audio playback itself while visible. This is simpler but riskier (no dedicated FGS-held wake lock across the whole ring duration); AOSP DeskClock's FGS-centric model is the more robust reference for a "wakes reliably from any state" alarm. [Simple-Clock AlarmReceiver](https://github.com/SimpleMobileTools/Simple-Clock/blob/master/app/src/main/kotlin/com/simplemobiletools/clock/receivers/AlarmReceiver.kt) + +## 6. Flutter-specific plugin architecture notes + +6.1 `android_alarm_manager_plus`: creates its own background `FlutterEngine` to run a Dart callback on alarm fire. Documented limitations: does not manage `SCHEDULE_EXACT_ALARM` permission at all (app must handle it); can crash when combined with other plugins that also spin up background engines/services (conflicts with `audio_service`-style plugins); callback reliability degrades when the app process was fully killed. **Verdict: not suitable as the sole mechanism for ringing UI/audio — at most usable for lightweight rescheduling logic, never for owning playback.** [plus_plugins issue #266](https://github.com/fluttercommunity/plus_plugins/issues/266) + +6.2 `alarm` (gdelataillade): purpose-built alarm plugin that keeps ALL ringing responsibility (foreground service, `NotificationOnKillService` safety net, audio, vibration, volume) in native Kotlin, exposing only schedule/cancel/stream-of-events to Dart. This is the correct architectural split for a Flutter alarm app and is the closer analog to what this app's custom `PluriWaveAlarmService`/`PluriWaveAlarmReceiver` should converge toward — provided the alarm audio path is fully decoupled from `audio_service`'s media session. [alarm plugin Android install docs](https://github.com/gdelataillade/alarm/blob/main/help/INSTALL-ANDROID.md) + +## 7. Notifications + +7.1 `POST_NOTIFICATIONS` runtime permission (Android 13+/API 33) must be requested before posting any notification, including the ringing FSI notification — if denied, the FSI/heads-up path degrades to nothing being shown, so the app needs an in-app fallback warning. +7.2 Alarm channel: `IMPORTANCE_HIGH`, `sound = null` (service owns audio), `CATEGORY_ALARM`, `setOngoing(true)` while ringing, action buttons wired to direct broadcast/service `PendingIntent`s (never trampolines — see 3.4). +7.3 Android 16 "Notification Cooldown" (gradual volume reduction for bursty same-app notifications) explicitly **does not apply to calls, alarms, priority conversations, or emergency alerts** — but this exemption is presumably keyed off `CATEGORY_ALARM`/correct channel classification, so mis-categorized alarm notifications could incorrectly get cooled down. Classify correctly. [Android 16 notification cooldown](https://www.computerworld.com/article/3609666/android-16-notification-cooldown.html) +7.4 Update (not re-post) the ringing notification via a stable notification ID per alarm instance; re-posting can retrigger heads-up/FSI unexpectedly and complicates the "notification removed but service alive" desync bug (Rule 4.3). + +## 8. Failure-mode checklist (must survive) + +- [ ] Process death mid-ring (service killed by LMK/OEM) — state persisted, restart re-validates before resuming (4.2, 4.5). +- [ ] Device reboot — `BOOT_COMPLETED`/`LOCKED_BOOT_COMPLETED` reschedules every pending alarm from durable storage (1.5). +- [ ] Doze / App Standby — `setAlarmClock()` used, not a type that Doze can defer (1.1); FGS start from exact alarm is exempt from BG-start restrictions (2.2). +- [ ] SCHEDULE_EXACT_ALARM revoked by user/system mid-session — `ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` handled, fallback scheduling used, user warned (1.4, 1.6). +- [ ] Locale/timezone change — `TIME_SET`/`TIMEZONE_CHANGED` recompute all trigger times (1.5). +- [ ] App update — `MY_PACKAGE_REPLACED` reschedules (1.5). +- [ ] Media/ring volume at 0 — irrelevant if alarm audio correctly uses `STREAM_ALARM`/`USAGE_ALARM` (2.4); verify alarm volume specifically, not device master volume. +- [ ] DND enabled — alarm-stream audio bypasses DND by default; verify the app is not accidentally routing through a stream DND does silence (2.4). +- [ ] Battery optimization / not in Doze-exemption whitelist — regular (non-`setAlarmClock`) alarms are deferred under Doze; either use `setAlarmClock()` (exempt) or guide the user through `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (1.1, repo already declares `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`). +- [ ] Two audio engines racing (native alarm player vs. `audio_service` media session) — single source of truth for "is ringing" and for the player instance; Stop must tear down both if both can ever be active (4.1, 4.4) — **highest-priority item given this app's dual audio-engine manifest**. +- [ ] Notification dismissed/swiped but service still alive, or vice versa — coupled stop path (4.3). +- [ ] Full-screen intent permission denied/revoked (Android 14+ policy or user revocation) — `canUseFullScreenIntent()` checked, heads-up fallback exists (3.1). +- [ ] Notification trampoline blocked (Android 12+) — all action `PendingIntent`s go straight to service/broadcast or directly to an Activity, never chained (3.4). +- [ ] Auto-silence/missed-alarm path — bounded ring duration, transition to MISSED, repeating alarms rearm next occurrence (2.5, state machine section 5). + +## Top 10 rules this Flutter app is most likely violating (given dual native-service + audio_service architecture) + +1. Alarm ringing audio possibly not exclusively on `STREAM_ALARM`/`USAGE_ALARM` if it shares code paths with `audio_service`'s media-session player used for radio (Rule 2.4) — needs verification. +2. Two independent audio/media engines (`PluriWaveAlarmService` native + `com.ryanheise.audioservice.AudioService`) with no single documented "who owns ringing audio" contract (Rule 4.1, 4.4) — matches the reported incident signature. +3. Redundant `SCHEDULE_EXACT_ALARM` + `USE_EXACT_ALARM` declaration without a documented fallback if Play strips `USE_EXACT_ALARM` core-functionality grant (Rule 1.3). +4. Unclear whether Stop/Snooze notification actions route directly to service/broadcast `PendingIntent`s vs. any trampoline-like indirection (Rule 3.4) — needs code check. +5. Unclear whether the ringing foreground service re-validates "already stopped" state on every `onStartCommand` (protection against `START_STICKY` resurrection) (Rule 4.2). +6. Unclear whether notification cancellation and `stopForeground()`/`stopSelf()` are coupled atomically in every stop path (Rule 4.3). +7. Unclear whether a durable "currently firing" record exists to recover deterministically from process death mid-ring (Rule 4.5). +8. No confirmed bounded auto-silence timeout / missed-alarm transition (Rule 2.5) — an unbounded ring loop is the literal shape of the known incident. +9. `ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` receiver exists in the manifest but its Kotlin handler's actual behavior (reschedule vs. log-only) is unverified (Rule 1.4). +10. Full-screen intent fallback path (heads-up notification when FSI is denied/revoked) is unverified — app relies on `USE_FULL_SCREEN_INTENT` but Android 14+ can still revoke it via Play/OEM policy (Rule 3.1). + +Note: items 2–9 require the codebase-focused sdd-explore pass (Kotlin source read) to confirm/refute; this document is the external-research half only. diff --git a/openspec/changes/alarm-system-overhaul/proposal.md b/openspec/changes/alarm-system-overhaul/proposal.md new file mode 100644 index 0000000..e4b308a --- /dev/null +++ b/openspec/changes/alarm-system-overhaul/proposal.md @@ -0,0 +1,72 @@ +# Proposal: Alarm System Overhaul — Fail-Safe Stop/Dismiss + +## Intent + +A real alarm rang ~15 min and could not be stopped by any means; the user uninstalled. Two confirmed causes: (1) native `stopAlarm` silently no-ops on `alarmId != activeAlarmId` while the Dart screen dismisses regardless ("dismiss-by-design" masks the failure); (2) disabling/editing a ringing alarm never stops audio. No bounded auto-silence and no durable firing record exist — an unbounded ring is the literal incident. Make the alarm system fail-safe per the acceptance bar (always rings; every action does exactly what it says; stop/snooze/disable/edit always silence a live ring), grounded in the Alarm Platform Contract and verifiable WITHOUT an Android build. + +## Scope + +### In Scope +- **P0 — Stop/dismiss correctness (incident class):** + - Fail-safe native stop: the ringing UI and notification Stop action use id-agnostic `stopActive` semantics — stop MUST NEVER silently no-op. Concurrent-alarm case uses verify-and-report; ambiguity fails toward silence. + - Any Dart mutation of the ringing alarm (disable/edit/save/delete/snooze) stops audio first — shared guard in `EstadoAlarmas`. + - Bounded auto-silence (fixed 10 min this slice) via AlarmManager-scheduled `FIRED→MISSED` transition; repeating alarms rearm next occurrence; one-shots disable. + - Atomic stop: `stopForeground(REMOVE)` + `stopSelf()` + wakelock release in one path. + - Durable "firing since T" record + `onStartCommand` re-validation; `START_NOT_STICKY` (no blind resurrection); stale record past timeout → clean up as missed. + - Dart verification feedback: stop/snooze channel calls return success/failure; dismiss-by-design stays, but failures raise a persistent retryable "Force stop" affordance. +- **P1 — Hardening:** `canUseFullScreenIntent()` check + heads-up fallback; exact-alarm-denial and `POST_NOTIFICATIONS`-denial in-app warnings. + +### Out of Scope +- iOS; Android Auto local-music; radio-playback behavior. +- **P2 (deferred):** dedicated ringing Activity (vs MainActivity routing); formal state-machine class; belt-and-suspenders `audio_service` teardown on stop (native already owns ring audio); user-configurable silence duration. +- Executing the stale `alarm-clock-module` / `app-quality-and-native-alarms` tasks (see Dependencies). + +## Capabilities + +### New Capabilities +- `alarm-stop-safety`: Dart-side fail-safe orchestration — any mutation targeting the ringing alarm guarantees audio stops; stop/snooze expose verifiable success/failure with a retryable force-stop path. + +### Modified Capabilities +- `native-alarms`: stop never silently no-ops (fail-safe id semantics); atomic `stopForeground`+`stopSelf`+wakelock coupling; bounded auto-silence → `MISSED` with next-occurrence rearm; durable firing record + `onStartCommand` re-validation; `START_NOT_STICKY`; FSI/permission fallbacks. + +## Approach + +Evolve the current architecture — do NOT rewrite. The native service already owns audio and notification actions already use `PendingIntent.getService`. Build on that: add an id-agnostic stop action, a durable firing record, an AlarmManager-armed missed transition, and a coupled stop method. Push all testable logic to Dart (mutation-while-ringing guard, stop-result handling, auto-silence timing/state model) with new fake failure switches; keep Kotlin changes trivially static-grep-verifiable. No new plugin dependencies. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `PluriWaveAlarmService.kt` | Modified | Fail-safe `stopActive`, atomic stop, durable record, `onStartCommand` revalidate, auto-silence, `START_NOT_STICKY` | +| `AlarmScheduler.kt` | Modified | Arm `FIRED→MISSED` transition; missed cleanup + rearm | +| `MainActivity.kt` | Modified | Stop channel returns result; FSI/permission checks | +| `estado/estado_alarmas.dart` | Modified | Mutation-while-ringing stop guard; stop/snooze result handling | +| `servicios/servicio_alarmas_android.dart` | Modified | Channel methods return success/failure | +| `pantallas/pantalla_alarma_sonando.dart` | Modified | Retryable force-stop affordance on failure | +| `test/` (Dart) + fakes | New | Failure switches for stop/dismiss; mutation-while-ringing; auto-silence timing | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Native changes unverifiable without build env | High | Keep Kotlin trivially readable; push logic + tests to Dart; on-device QA checklist | +| Fail-safe stop silences the wrong concurrent alarm | Low | Verify-and-report; fail toward silence — stuck-ring cost >> wrong-stop cost | +| Auto-silence fires early on a legitimately long ring | Low | 10-min bound matches wakelock cap; configurability deferred to P2 | +| P0+P1 exceeds 800-line budget | Med | tasks phase slices P0 first, P1 as follow-up | + +## Rollback Plan + +Single feature branch to `main`. Revert the branch/PR; native and Dart changes are additive to existing stop paths, so reverting restores current behavior. Durable firing record is a new pref key — orphaned entries are self-cleaning (age check), so no migration rollback needed. + +## Dependencies + +- **Stale artifact reconciliation (no work done here):** `alarm-clock-module` (stuck `tasks-ready`) and `app-quality-and-native-alarms` (`apply-complete`, never verified) are SUPERSEDED by this change — this proposal re-specifies the alarm-reliability behavior authoritatively. Recommend archiving both as superseded during this change's archive phase; do NOT re-verify or execute their tasks. + +## Success Criteria + +- [ ] Stop/snooze/disable/edit/delete of a ringing alarm ALWAYS silences audio (Dart tests with failure fakes). +- [ ] Native `stopAlarm` has no branch that silently no-ops a live ring. +- [ ] A ring cannot exceed the bounded auto-silence window; missed transition + rearm covered. +- [ ] Durable firing record + `onStartCommand` re-validation prevent resurrection; `START_NOT_STICKY` set. +- [ ] Stop/snooze failures are visible and retryable in-app (force-stop). +- [ ] P1 FSI/permission fallbacks present and static-verifiable. diff --git a/openspec/changes/alarm-system-overhaul/specs/alarm-stop-safety/spec.md b/openspec/changes/alarm-system-overhaul/specs/alarm-stop-safety/spec.md new file mode 100644 index 0000000..c8108e7 --- /dev/null +++ b/openspec/changes/alarm-system-overhaul/specs/alarm-stop-safety/spec.md @@ -0,0 +1,93 @@ +# Alarm Stop Safety Specification + +## Purpose + +Dart-side fail-safe orchestration: every surface that can act on a ringing alarm (ringing-screen Stop/Snooze, alarm-list disable/toggle, edit/save, delete) MUST deterministically silence a live ring or surface a visible, retryable failure. Dismiss-by-design (the ringing screen always closes) is preserved but MUST NOT mask a failed native stop. + +## Requirements + +### Requirement: Mutation-While-Ringing Stop Guard + +`EstadoAlarmas` MUST route any mutation targeting the currently-ringing alarm — disable/toggle (`cambiarActiva`), edit/save (`guardarAlarma`), delete (`eliminarAlarma`) — through one centralized stop-first guard, not per-call-site logic. + +#### Scenario: Toggling the ringing alarm off stops audio +**Testability**: [dart-testable] + +- GIVEN alarm X is ringing and active +- WHEN `cambiarActiva(X, false)` is called +- THEN `detenerSonidoNativo(X)` runs before/alongside `programar`/`cancelar` + +#### Scenario: Editing/saving the ringing alarm stops audio +**Testability**: [dart-testable] + +- GIVEN alarm X is ringing +- WHEN `guardarAlarma` is called with an edited config for id X +- THEN the stop guard fires before the save persists + +#### Scenario: Deleting the ringing alarm stops audio (regression lock) +**Testability**: [dart-testable] + +- GIVEN alarm X is ringing +- WHEN `eliminarAlarma(X)` is called +- THEN `detenerSonidoNativo(X)` runs before `cancelar` (locks in existing correct behavior) + +#### Scenario: Mutating a non-ringing alarm does not trigger the guard +**Testability**: [dart-testable] + +- GIVEN alarm Y is ringing, alarm Z is not +- WHEN `cambiarActiva(Z, ...)` or `guardarAlarma(Z)` is called +- THEN no stop call targets Y + +### Requirement: Stop/Snooze Result Verification + +Stop (`detenerSonidoNativo`) and snooze channel calls MUST return a success/failure/unconfirmed result instead of only throwing or being swallowed. `EstadoAlarmas` MUST record this outcome per alarm. + +#### Scenario: Native stop confirms success +**Testability**: [dart-testable] + +- GIVEN the stop channel call resolves with `confirmed=true` +- WHEN `_detener()` completes +- THEN `EstadoAlarmas` records a confirmed-stop state for that alarm + +#### Scenario: Native stop reports unconfirmed/failure +**Testability**: [dart-testable] + +- GIVEN the fake channel is set to fail or return `confirmed=false` +- WHEN `_detener()`/`_posponer()` runs +- THEN `EstadoAlarmas` records a failed/unconfirmed state, never a confirmed one + +### Requirement: Retryable Force-Stop Affordance + +When a stop/snooze attempt is not confirmed, the app MUST present a persistent, retryable "Force stop" affordance until a confirmed stop is recorded or the alarm is externally cleared. Dismiss-by-design (screen closing) stays independent of this affordance. + +#### Scenario: Failed stop surfaces Force stop +**Testability**: [dart-testable] + +- GIVEN `_detener()` receives an unconfirmed/failure result +- WHEN the ringing screen finishes its dismiss-by-design close +- THEN a persistent retryable Force-stop affordance appears + +#### Scenario: Retrying force-stop attempts stop again +**Testability**: [dart-testable] + +- GIVEN a Force-stop affordance is visible after a failed attempt +- WHEN the user retries +- THEN `detenerSonidoNativo` is invoked again; success clears the affordance, failure keeps it visible + +#### Scenario: Confirmed stop shows no failure UI +**Testability**: [dart-testable] + +- GIVEN a confirmed-stop result +- WHEN the screen dismisses +- THEN no Force-stop affordance appears + +### Requirement: Notification Stop/Snooze Stays Native-Only + +Notification Stop/Snooze actions use `PendingIntent.getService` directly (native-only, Flutter-independent) and remain outside this Dart guard's scope; only Dart-initiated mutations and in-app screens are covered here. + +#### Scenario: Notification action bypasses the Dart guard by design +**Testability**: [kotlin-static] + +- GIVEN a fire notification with Stop/Snooze actions +- WHEN the user taps either action +- THEN it invokes the native service directly, never touching `EstadoAlarmas`' guard diff --git a/openspec/changes/alarm-system-overhaul/specs/native-alarms/spec.md b/openspec/changes/alarm-system-overhaul/specs/native-alarms/spec.md new file mode 100644 index 0000000..6cc6f71 --- /dev/null +++ b/openspec/changes/alarm-system-overhaul/specs/native-alarms/spec.md @@ -0,0 +1,125 @@ +# Delta for Native Alarms + +## ADDED Requirements + +### Requirement: Fail-Safe stopActive Semantics + +`PluriWaveAlarmService` MUST expose an id-agnostic stop action (`stopActive`), used by the ringing UI and the notification Stop action, that stops the currently-active ring regardless of the alarm id it is called with. It MUST NEVER silently no-op while a ring is active. On concurrent-alarm ambiguity, it MUST verify state and report the outcome, failing toward silencing the active ring. + +#### Scenario: Stop with mismatched id still silences the active ring +**Testability**: [kotlin-static] + +- GIVEN `activeAlarmId = A` is ringing +- WHEN `stopActive` is invoked with id B (mismatch) +- THEN ring A is stopped, not silently ignored, and the caller gets a verified result + +#### Scenario: Stop with no active ring reports cleanly +**Testability**: [kotlin-static] + +- GIVEN no alarm is currently ringing +- WHEN `stopActive` is invoked +- THEN it reports "nothing to stop" rather than throwing or hanging + +#### Scenario: Concurrent ambiguity fails toward silence +**Testability**: [device-qa] + +- GIVEN two alarms could plausibly be "the active one" +- WHEN `stopActive` resolves the ambiguity +- THEN the active ring MUST be silenced (over-stop preferred over leaving it ringing) + +### Requirement: Atomic Stop Coupling + +Every stop path MUST perform `stopForeground(STOP_FOREGROUND_REMOVE)`, `stopSelf()`, and wakelock release together, with no branch that performs a subset. + +#### Scenario: Stop path releases all three resources together +**Testability**: [kotlin-static] + +- GIVEN any stop entry point in `PluriWaveAlarmService` +- WHEN read/inspected +- THEN all three teardown calls are reachable from it with no partial-teardown branch + +### Requirement: Bounded Auto-Silence at 10 Minutes + +If a fired alarm is not stopped within 10 minutes, the system MUST transition it FIRED→MISSED via the atomic stop path, post a missed-alarm notification, rearm the next occurrence for repeating alarms, and leave one-shot alarms disabled. + +#### Scenario: Unattended ring auto-silences at 10 minutes +**Testability**: [kotlin-static] + [device-qa] + +- GIVEN an alarm fires and is never stopped +- WHEN 10 minutes elapse +- THEN audio stops via the atomic stop path and a missed notification posts + +#### Scenario: Repeating alarm rearms after auto-silence +**Testability**: [kotlin-static] + +- GIVEN a repeating alarm auto-silences +- WHEN the MISSED transition completes +- THEN `AlarmScheduler` arms the next occurrence + +#### Scenario: One-shot alarm disables after auto-silence +**Testability**: [kotlin-static] + +- GIVEN a one-shot alarm auto-silences +- WHEN the MISSED transition completes +- THEN the alarm is left disabled, not rearmed + +### Requirement: Durable Firing Record + Restart Re-Validation + +Before playback starts, the service MUST persist a durable "firing since T" record. `onStartCommand` MUST re-validate it on every entry, and the service MUST run `START_NOT_STICKY`. The record MUST clear only on a confirmed stop or a completed auto-silence transition. + +#### Scenario: Firing record persists before audio starts +**Testability**: [kotlin-static] + +- GIVEN a fire event +- WHEN `startAlarm` begins +- THEN a durable record is written before `MediaPlayer.start()` + +#### Scenario: onStartCommand re-validates on restart +**Testability**: [kotlin-static] + +- GIVEN the service process is killed and restarted by the OS +- WHEN `onStartCommand` runs again +- THEN it checks the record's age/state before resuming any audio action and returns `START_NOT_STICKY` + +#### Scenario: Confirmed stop clears the record +**Testability**: [kotlin-static] + +- GIVEN a confirmed stop via `stopActive` +- WHEN the atomic stop path completes +- THEN the durable firing record is cleared + +### Requirement: Boot/Restart Cleanup of Stale Firing Records + +On boot/unlock/package-replace, a durable firing record older than the 10-minute auto-silence bound MUST be treated as missed and cleaned up, not left dangling. + +#### Scenario: Stale record cleaned at boot +**Testability**: [kotlin-static] + +- GIVEN a firing record older than 10 minutes exists at boot +- WHEN `PluriWaveBootReceiver` runs `reschedulePersistedAlarms` +- THEN the stale record is cleared and treated as a MISSED transition + +### Requirement: P1 — FSI, Exact-Alarm, and Notification-Permission Fallbacks + +The system MUST call `canUseFullScreenIntent()` before relying on FSI and fall back to a heads-up notification when denied. It MUST show an in-app warning when exact-alarm scheduling permission is denied and when `POST_NOTIFICATIONS` is denied. + +#### Scenario: FSI unavailable falls back to heads-up +**Testability**: [kotlin-static] + +- GIVEN `canUseFullScreenIntent()` returns false +- WHEN a fire notification is built +- THEN it posts as heads-up instead of FSI + +#### Scenario: Exact-alarm denial warns in-app +**Testability**: [dart-testable] + +- GIVEN exact-alarm scheduling permission is denied +- WHEN the user schedules/saves an alarm +- THEN an in-app warning is shown + +#### Scenario: POST_NOTIFICATIONS denial warns in-app +**Testability**: [dart-testable] + +- GIVEN `POST_NOTIFICATIONS` permission is denied +- WHEN the user schedules an alarm +- THEN an in-app warning is shown diff --git a/openspec/changes/alarm-system-overhaul/tasks.md b/openspec/changes/alarm-system-overhaul/tasks.md new file mode 100644 index 0000000..db83908 --- /dev/null +++ b/openspec/changes/alarm-system-overhaul/tasks.md @@ -0,0 +1,106 @@ +# Tasks: Alarm System Overhaul — Fail-Safe Stop/Dismiss + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines (Slice A, code+tests) | ~650–800 | +| Estimated changed lines (Slice B, P1 follow-up) | ~80–120 | +| 400-line budget risk | High (well above the 400-line reviewer-cognition guard) | +| 800-line session budget risk | Medium (near the ceiling; any test-file growth pushes over) | +| Chained PRs recommended | No (session delivery is direct commits to `main`, no PRs) | +| Suggested split | Slice A = this delivery (Phases 1–7); Slice B = P1 follow-up (Phase 8) | +| Delivery strategy | single-pr-default (direct commits to main, no PRs) | +| Chain strategy | size-exception | + +Decision needed before apply: Yes +Chained PRs recommended: No +Chain strategy: size-exception +400-line budget risk: High + +Rationale: no-PR direct-commit strategy forecloses PR chaining, but Slice A crosses the 400-line +guard on its own. Maintainer must explicitly accept a `size:exception` for Slice A before `sdd-apply` +proceeds; Slice B (P1) ships as a separate, smaller follow-up commit set once Slice A lands. + +### Suggested Work Units (sequenced commit batches, not PRs) + +| Unit | Goal | Focused test command | Runtime harness | Rollback boundary | +|------|------|-----------------------|------------------|--------------------| +| A1 — Native stop core (Ph.1–3) | id-agnostic stop, atomic teardown, notification route fix, channel bridge | N/A (no Kotlin build env; grep-verifiable) | Device QA checklist below | Revert `PluriWaveAlarmService.kt`/`MainActivity.kt` hunks; additive-only | +| A2 — Native auto-silence + durable record (Ph.2) | MISSED transition, firing record, boot cleanup | N/A (grep-verifiable) | Device QA: 10-min + reboot scenarios | Revert `AlarmScheduler.kt`/`PluriWaveAlarmReceiver.kt`; orphaned pref keys unread | +| A3 — Dart orchestration (Ph.4–6) | mutation guard, force-stop UI, missed bookkeeping | `flutter test test/estado/estado_alarmas_test.dart test/servicios/servicio_alarmas_android_test.dart test/pantallas/pantalla_alarma_sonando_test.dart` | N/A (unit/widget-covered) | Revert Dart hunks; guard is additive to existing stop calls | +| A4 — l10n (Ph.7) | 4 new keys, en+es only | `flutter test` + CI ARB guard | N/A | Revert 2 ARB files | +| B1 — P1 fallback banners (Phase 8, follow-up) | FSI/exact-alarm/notification-permission warnings | `flutter test test/estado` | Device QA: FSI-denied scenario | Revert diagnostics-banner hunk only | + +## Phase 1: Native Stop Core (Kotlin, static-edit) + +- [x] 1.1 `PluriWaveAlarmService.kt`: add companion `@Volatile var activeRingingId: String?`, set in `startAlarm`, cleared in `stopEverything`. [kotlin-static] → Decision 1. Accept: grep shows `@Volatile` field + both set/clear sites. +- [x] 1.2 Add `ACTION_STOP_ACTIVE` const; extract `stopEverything()` from the L410-433 teardown (runnables, player stop/release, `activeAlarmId=null`, companion clear, wakelock release, audio-focus abandon, notification cancel, `clearFiringRecord`+`cancelAutoSilence`, `stopForeground(STOP_FOREGROUND_REMOVE)`, `stopSelf()`). [kotlin-static] → NA-2, Decision 2. Accept: grep shows all 3 teardown calls in `stopEverything()` and every stop path (`ACTION_STOP`, `ACTION_STOP_ACTIVE`, `ACTION_SNOOZE`, `ACTION_MISSED`, `onDestroy`) calls it. +- [x] 1.3 `onStartCommand` `ACTION_STOP_ACTIVE` branch → `stopEverything()` id-agnostically; `stopAlarm(id)` keeps id-scoped wrapper (foreign-id mismatch cancels only that id's notification). [kotlin-static] → NA-1a, NA-1b, NA-1c, Decision 1. Accept: branch present, ignores extras id. +- [x] 1.4 **(feedback item 1)** Fix `stopPendingIntent(alarmId)`: change dispatched `action` from `ACTION_STOP` to `ACTION_STOP_ACTIVE` so the notification Stop button routes to the id-agnostic stop. [kotlin-static] → SS-4a, NA-1a. Accept: grep confirms `action = ACTION_STOP_ACTIVE` inside `stopPendingIntent`. + +## Phase 2: Native Auto-Silence + Durable Firing Record (Kotlin, static-edit) + +- [x] 2.1 `AlarmScheduler.kt`: add `AUTO_SILENCE_MILLIS` (10 min), `recordFiring(id)`/`clearFiringRecord(id)`/`firingRecordAgeMillis(id)` on `KEY_FIRING_IDS`+`firing_` in `pluriwave_alarm_scheduler` prefs. [kotlin-static] → NA-4a, Decision 4. +- [x] 2.2 `armAutoSilence(id)`/`cancelAutoSilence(id)` via `AlarmManager.setExactAndAllowWhileIdle` → `ACTION_MISSED`; arm+record in `onAlarmFired`; cancel in `snooze`, `skipNext`, `cancelAlarm`, `stopEverything`. [kotlin-static] → NA-3a, Decision 3. Accept: grep shows 1 arm site + 4 cancel sites. +- [x] 2.3 `onAlarmMissed(id)`: `stopEverything()` if id still rings, post missed notification (reuse pre-notice channel + `AlarmNotificationStrings.missedTitle/missedText`), clear firing record, **no rearm**; one-shot stays disabled. [kotlin-static] → NA-3a, NA-3b, NA-3c. Accept: grep shows no rearm/`programarSiguiente` call inside `onAlarmMissed`. +- [x] 2.4 `PluriWaveAlarmReceiver.kt`: `ACTION_MISSED` const + branch → `onAlarmMissed`; `pendingMissedIntent` helper. [kotlin-static] → NA-3a. +- [x] 2.5 `startAlarm`: if `firingRecordAgeMillis(id) > AUTO_SILENCE_MILLIS` → abort + `onAlarmMissed` cleanup; else `recordFiring(id)` before `MediaPlayer.start()`; keep `START_NOT_STICKY`. [kotlin-static] → NA-4a, NA-4b. Accept: grep shows `recordFiring` textually precedes `MediaPlayer.start()` and the stale-check branch exists. +- [x] 2.6 `cleanupStaleFiringRecords()`; call from `reschedulePersistedAlarms` before rescheduling. [kotlin-static] → NA-5a. Accept: grep shows call precedes the reschedule loop. +- [x] 2.7 `AlarmNotificationStrings.kt` `missedTitle`/`missedText` getters+setters; `MainActivity.kt setNotificationStrings` passes them through. [kotlin-static] → NA-3a. + +## Phase 3: Channel Bridge (Kotlin, static-edit) + +- [x] 3.1 `MainActivity.kt`: add `getActiveRingingAlarmId` (sync companion read) and `stopActiveAlarm` (`{stopped, wasRinging, activeAlarmId}`; internal exception → `result.error("STOP_FAILED", msg, null)`). [kotlin-static] → Decision 1, NA-1a, NA-1b. Accept: grep shows both method names in the channel dispatcher + the `error()` call. + +## Phase 4: Dart Orchestration — Stop Result + Mutation Guard (TDD) + +- [x] 4.1 RED — `test/servicios/servicio_alarmas_android_test.dart`: failing test for `ResultadoDetencion{detenido,estabaSonando,alarmaId}` mapping from `stopActiveAlarm`/`alarmaSonandoId()`. [dart-testable] → Decision 5 interface. `flutter test` fails. +- [x] 4.2 GREEN — `servicio_alarmas_android.dart`: add `ResultadoDetencion`, `alarmaSonandoId()`, `detenerSonidoActivo()`, `PuertoAlarmasAndroid` additions. `flutter test` green. +- [x] 4.3 RED — `test/helpers/fakes_alarmas.dart` + `estado_alarmas_test.dart`: add `fallaDetener`/`alarmaSonandoIdValor`/`detencionesActivas`; failing tests for SS-1a/SS-1b/SS-1c/SS-1d (`cambiarActiva(false)`/`guardarAlarma`/`eliminarAlarma` stop only when target id == ringing id). [dart-testable] +- [x] 4.4 GREEN — `estado_alarmas.dart`: add `_detenerSiEstaSonando(id)` guard; wire into `guardarAlarma`, `cambiarActiva(false)`, `eliminarAlarma`. `flutter test` green. +- [x] 4.5 RED — failing tests for SS-2a/SS-2b: `finalizarEjecucion` calls `detenerSonidoActivo()` directly; confirmed → no `_error`; unconfirmed/failure → `_error` set. [dart-testable] +- [x] 4.6 GREEN — `estado_alarmas.dart`: wire `finalizarEjecucion` accordingly. `flutter test` green. + +## Phase 5: Dart UX — Force-Stop Affordance (TDD) + +- [x] 5.1 RED — failing test for SS-3b: `forzarDetencion()` re-invokes `detenerSonidoActivo()`; success clears failure state, failure keeps it. [dart-testable] +- [x] 5.2 GREEN — `estado_alarmas.dart`: add `forzarDetencion()`. `flutter test` green. +- [x] 5.3 RED — `test/pantallas/pantalla_alarma_sonando_test.dart`: failing widget test for SS-3a/SS-3c (failure → screen stays up with a persistent `alarmForceStopAction` banner; confirmed → no banner). [dart-testable] +- [x] 5.4 GREEN — `pantalla_alarma_sonando.dart`: on `_detener()` failure, stay on screen and render a persistent in-screen banner (not a timed SnackBar) with a force-stop action → `forzarDetencion()`; clears only on a confirmed stop. `flutter test` green. +- [x] 5.5 **(review round 3 correction)** `_forzarDetencion()` now respects `_salidaEnCurso` (guard reset on failure) and the ringing screen reconciles an external MISSED transition for its own alarm id (`EstadoAlarmas.ultimaAlarmaPerdidaId`) by auto-dismissing. [dart-testable] + +## Phase 6: Dart — Missed-Event Bookkeeping (TDD) + +- [x] 6.1 RED — failing test: native `missed` event → `completarEjecucion` records it. [dart-testable] +- [x] 6.2 GREEN — `estado_alarmas.dart`: wire missed event in `_alRecibirEventoNativo`. `flutter test` green. + +## Phase 7: l10n (corrected policy — en+es only) + +- [x] 7.1 `lib/l10n/app_en.arb`: add `alarmStopFailedMessage`, `alarmForceStopAction`, `alarmMissedNotificationTitle`, `alarmMissedNotificationText({name})` with `@`-metadata. [dart-testable] Accept: `flutter test` (Phase 5 widget test) resolves the new keys. +- [x] 7.2 `lib/l10n/app_es.arb`: mirror the 4 keys, placeholder metadata byte-exact with `en`. [dart-testable] +- [x] 7.3 **(review round 2 — superseded correction)** The original "do NOT touch the other 11 locales" policy above was itself superseded by the round-2 review fix: the 4 keys were deliberately ADDED, with real translations, to all 13 `lib/l10n/app_*.arb` files instead of falling back to `en`. Accept: CI ARB placeholder-corruption guard passes across all 13 files. +- [x] 7.4 Wire missed-alarm strings through setNotificationStrings (gap found in apply verification) +- [x] 7.5 **(review round 3 correction)** Native KDoc (`AlarmScheduler.AUTO_SILENCE_MILLIS`, `AlarmNotificationStrings.missedText`) updated to reference all 13 ARB files instead of only `en`/`es`, matching 7.3. + +## Phase 8 — Slice B (P1 follow-up, not part of this delivery) + +- [ ] 8.1 Diagnostics banners bound to `EstadoAlarmas.diagnostico.canUseFullScreenIntent/notificationsEnabled/canScheduleExactAlarms`; new warning strings (en+es only, same policy as Phase 7). [dart-testable] → NA-6b, NA-6c. +- [ ] 8.2 RED→GREEN Dart tests for exact-alarm-denied and `POST_NOTIFICATIONS`-denied warnings. [dart-testable] → NA-6b, NA-6c. +- [ ] 8.3 Confirm FSI auto-fallback needs no code change (platform degrades `setFullScreenIntent` when `canUseFullScreenIntent()` is false); log as device-QA-only. [kotlin-static]+[device-qa] → NA-6a. + +## On-Device QA Checklist (collected — run on real hardware) + +- [ ] Stop from ringing UI, id match AND id mismatch (NA-1a, NA-1c) +- [ ] Stop from lock-screen notification (SS-4a, NA-1a — after Phase 1.4 fix) +- [ ] Disable/edit/delete the ringing alarm from the list (SS-1a/b/c) +- [ ] Leave a fired alarm untouched 10 minutes → audio stops, missed notification posts, repeating rearms, one-shot stays disabled (NA-3a/b/c) +- [ ] Kill the app mid-ring → audio stops +- [ ] Reboot mid-ring → boot cleanup runs, no audio resurrection (NA-5a) +- [ ] Trigger a concurrent second alarm while one rings (NA-1c) +- [ ] Force FSI-denied state → heads-up fallback posts instead of full-screen intent (NA-6a) + +## Open Questions Carried to Apply + +- [ ] Missed-notification channel: reuse pre-notice channel (default assumed above) vs. dedicated low-importance channel — confirm before Phase 2.3 lands. +- [ ] `finalizarEjecucion` return contract: `Future` vs. existing `_error`-field convention (default assumed above: `_error`) — confirm before Phase 4.6 lands. diff --git a/test/estado/estado_alarmas_test.dart b/test/estado/estado_alarmas_test.dart index 0b24538..fc5773c 100644 --- a/test/estado/estado_alarmas_test.dart +++ b/test/estado/estado_alarmas_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/modelos/alarma_musical.dart'; @@ -220,6 +222,362 @@ void main() { expect(android.solicitudesExencionBateria, 0); }); + test( + 'cambiarActiva(false) detiene el audio cuando la alarma esta sonando (SS-1a)', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ring1', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.alarmaSonandoIdValor = 'ring1'; + + await estado.cambiarActiva(estado.alarmas.single, false); + + expect(android.detencionesActivas, contains('ring1')); + }, + ); + + test( + 'guardarAlarma detiene el audio cuando edita la alarma sonando (SS-1b)', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ring2', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.alarmaSonandoIdValor = 'ring2'; + + await estado.guardarAlarma(estado.alarmas.single.copyWith(minuto: 45)); + + expect(android.detencionesActivas, contains('ring2')); + }, + ); + + test( + 'eliminarAlarma detiene el audio antes de cancelar cuando esta sonando ' + '(SS-1c, guardia de regresion)', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ring3', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.alarmaSonandoIdValor = 'ring3'; + + await estado.eliminarAlarma('ring3'); + + expect(android.detencionesActivas, contains('ring3')); + expect(android.canceladas, contains('ring3')); + }, + ); + + test( + 'eliminarAlarma usa detenerSonidoNativo cuando la consulta de sonando ' + 'falla (fail-toward-silence, regresion de eliminarAlarma)', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ring4', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.fallaConsultaSonando = true; + + await estado.eliminarAlarma('ring4'); + + expect(android.detenidas, contains('ring4')); + expect(android.canceladas, contains('ring4')); + }, + ); + + test( + 'guardarAlarma (deshabilitar) usa detenerSonidoNativo cuando la consulta ' + 'de sonando falla (fail-toward-silence)', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'ring5', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.fallaConsultaSonando = true; + + await estado.cambiarActiva(estado.alarmas.single, false); + + expect(android.detenidas, contains('ring5')); + }, + ); + + test( + 'mutar una alarma distinta a la que suena no dispara el guard (SS-1d)', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'y1', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'z1', + nombre: 'Quieta', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.alarmaSonandoIdValor = 'y1'; + + final z1 = estado.alarmas.firstWhere((a) => a.id == 'z1'); + await estado.cambiarActiva(z1, false); + + expect(android.detencionesActivas, isEmpty); + }, + ); + + test( + 'finalizarEjecucion no registra error cuando el stop nativo se confirma ' + '(SS-2a)', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'fin1', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + android.alarmaSonandoIdValor = 'fin1'; + + await estado.finalizarEjecucion('fin1'); + + expect(android.detencionesActivas, contains('fin1')); + expect(estado.error, isNull); + }, + ); + + test( + 'finalizarEjecucion registra error cuando el stop nativo no se confirma ' + '(SS-2b)', + () async { + final android = FakePuertoAlarmasAndroid()..fallaDetener = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'fin2', + nombre: 'Sonando', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + await estado.finalizarEjecucion('fin2'); + + expect(estado.error, isNotNull); + }, + ); + + test( + 'forzarDetencion reintenta el stop nativo y limpia el error si tiene ' + 'exito (SS-3b)', + () async { + final android = FakePuertoAlarmasAndroid()..fallaDetener = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'force1', + nombre: 'Forzada', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + await estado.finalizarEjecucion('force1'); + expect(estado.error, isNotNull); + + android.fallaDetener = false; + await estado.forzarDetencion('force1'); + + expect(estado.error, isNull); + expect(android.detencionesActivas.length, 2); + }, + ); + + test( + 'forzarDetencion mantiene el error si el reintento tambien falla (SS-3b)', + () async { + final android = FakePuertoAlarmasAndroid()..fallaDetener = true; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + const AlarmaMusical( + id: 'force2', + nombre: 'Forzada', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + await estado.finalizarEjecucion('force2'); + + await estado.forzarDetencion('force2'); + + expect(estado.error, isNotNull); + }, + ); + + test( + 'evento nativo missed completa la ejecucion (Phase 6)', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + AlarmaMusical( + id: 'miss1', + nombre: 'Perdida', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2026, 5, 25, 7, 30), + ), + ); + + final notificado = Completer(); + estado.addListener(() { + if (!notificado.isCompleted) notificado.complete(); + }); + android.emitirEvento( + EventoAlarmaAndroid( + alarmaId: 'miss1', + titulo: 'Perdida', + accion: EventoAlarmaAndroid.accionMissed, + occurrenceAtMillis: DateTime(2026, 5, 25, 7, 30).millisecondsSinceEpoch, + ), + ); + await notificado.future; + + expect( + estado.alarmas.single.proximaEjecucion, + DateTime(2026, 5, 26, 7, 30), + ); + }, + ); + test( 'inicializar sincroniza ejecucion nativa y evita reprogramar al instante', () async { diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index e64367b..4eb3ca7 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -11,6 +11,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { final canceladas = []; final detenidas = []; final ocultadas = []; + final soloOcultadas = []; final ejecucionesNativas = []; final snoozesNativos = []; final _eventos = StreamController.broadcast(); @@ -22,6 +23,27 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { /// could not otherwise produce. bool fallaProgramar = false; + /// Test-only failure switch: when true, [detenerSonidoActivo] reports an + /// unconfirmed/failed stop instead of a confirmed one. + bool fallaDetener = false; + + /// Simulates [PluriWaveAlarmService.activeRingingId]: the id the fake + /// reports as currently ringing, or null if nothing rings. + String? alarmaSonandoIdValor; + + /// Test-only failure switch (Finding 2, fail-toward-silence): when true, + /// [alarmaSonandoId] throws instead of returning a value, exercising the + /// [detenerSonidoNativo] fallback in `EstadoAlarmas._detenerSiEstaSonando`. + bool fallaConsultaSonando = false; + + /// Every alarm id [detenerSonidoActivo] was invoked for, in call order. + final detencionesActivas = []; + + /// Test-only gate (RES-2 guard test): when set, [detenerSonidoActivo] + /// awaits it before resolving, letting tests exercise an overlapping + /// in-flight call. + Completer? detenerActivoGate; + /// Simulates a native -> Flutter `alarmFired` MethodChannel event. void emitirEvento(EventoAlarmaAndroid evento) => _eventos.add(evento); @@ -49,11 +71,39 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { detenidas.add(alarmaId); } + @override + Future alarmaSonandoId() async { + if (fallaConsultaSonando) { + throw StateError('fake alarmaSonandoId failure'); + } + return alarmaSonandoIdValor; + } + + @override + Future detenerSonidoActivo() async { + detencionesActivas.add(alarmaSonandoIdValor ?? ''); + final gate = detenerActivoGate; + if (gate != null) await gate.future; + if (fallaDetener) { + return const ResultadoDetencion(detenido: false, estabaSonando: true); + } + return ResultadoDetencion( + detenido: true, + estabaSonando: alarmaSonandoIdValor != null, + alarmaId: alarmaSonandoIdValor, + ); + } + @override Future ocultarNotificacionAlarma(String alarmaId) async { ocultadas.add(alarmaId); } + @override + Future ocultarSoloNotificacion(String alarmaId) async { + soloOcultadas.add(alarmaId); + } + @override Future diagnostico() async => DiagnosticoAlarmasAndroid( diff --git a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart index f4872f5..50354f3 100644 --- a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart @@ -289,6 +289,40 @@ void main() { ); }, ); + + testWidgets( + 'detener: root-launch (canPop false) + fallo de detencion verificado NO ' + 'llama a SystemNavigator.pop y deja visible el banner de retry ' + '(Finding A, escenario canonico de FSI con app muerta)', + (tester) async { + final env = await _buildEnv(); + addTearDown(env.dispose); + env.android.fallaDetener = true; + final spy = _SystemNavigatorSpy()..install(); + addTearDown(spy.uninstall); + + await _montarComoRaiz( + tester, + android: env.android, + estadoAlarmas: env.estadoAlarmas, + radio: env.radio, + ); + + await tester.tap(find.text(l10n.stopAlarmAction)); + await tester.pumpAndSettle(); + + expect(find.byType(PantallaAlarmaSonando), findsOneWidget); + expect(find.text(l10n.alarmStopFailedMessage), findsOneWidget); + expect( + spy.popCalls, + 0, + reason: + 'SystemNavigator.pop must not fire while the alarm is still ' + 'ringing after a verified stop failure — the root-launch ' + 'scenario is the one where a lost affordance is unrecoverable', + ); + }, + ); }); group('PantallaAlarmaSonando snooze failure feedback (Phase 4)', () { diff --git a/test/pantallas/pantalla_alarma_sonando_test.dart b/test/pantallas/pantalla_alarma_sonando_test.dart index cb900ac..59a4202 100644 --- a/test/pantallas/pantalla_alarma_sonando_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_test.dart @@ -9,6 +9,7 @@ import 'package:pluriwave/modelos/alarma_musical.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart'; import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -92,7 +93,12 @@ Future<_Entorno> _montarPantalla( locale: const Locale('es'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const SizedBox.shrink(), + // A real Scaffold (not a bare SizedBox) is required: the + // ScaffoldMessenger only displays a SnackBar through a currently + // registered ScaffoldState, and the ringing screen's own Scaffold + // pops off the tree by the time the SS-3a force-stop SnackBar shows + // (mirrors pantalla_alarma_sonando_dismiss_guard_test.dart). + home: const Scaffold(body: SizedBox.shrink()), ), ), ); @@ -163,6 +169,102 @@ void main() { }, ); + group('detener y el force-stop de fallback', () { + testWidgets( + 'detener fallido NO cierra la pantalla y muestra el banner de forzar ' + 'detencion; forzar detencion con exito si la cierra (SS-3a/SS-3b)', + (tester) async { + final entorno = await _montarPantalla(tester); + entorno.android.fallaDetener = true; + + await tester.tap(find.text(l10n.stopAlarmAction)); + await tester.pumpAndSettle(); + + // Verified stop failure (Finding A): the alarm is still ringing, so + // the ringing screen must stay up — dismissing here would hide the + // only retry affordance while the native ring keeps sounding. + expect(find.byType(PantallaAlarmaSonando), findsOneWidget); + expect(find.text(l10n.alarmStopFailedMessage), findsOneWidget); + expect(find.text(l10n.alarmForceStopAction), findsOneWidget); + + // Retry via the banner's own action succeeds this time (SS-3b). + entorno.android.fallaDetener = false; + await tester.tap(find.text(l10n.alarmForceStopAction)); + await tester.pumpAndSettle(); + + expect(find.byType(PantallaAlarmaSonando), findsNothing); + }, + ); + + testWidgets( + 'detener confirmado no muestra el banner de fallo (SS-3c)', + (tester) async { + final entorno = await _montarPantalla(tester); + + await tester.tap(find.text(l10n.stopAlarmAction)); + await tester.pumpAndSettle(); + + expect(find.byType(PantallaAlarmaSonando), findsNothing); + expect(find.text(l10n.alarmStopFailedMessage), findsNothing); + expect(entorno.android.detencionesActivas, isNotEmpty); + }, + ); + + testWidgets( + 'forzar detencion: invocacion superpuesta es no-op y tras un fallo ' + 'el boton sigue funcionando (RES-2)', + (tester) async { + final entorno = await _montarPantalla(tester); + entorno.android.fallaDetener = true; + await tester.tap(find.text(l10n.stopAlarmAction)); + await tester.pumpAndSettle(); + final llamadasPrevias = entorno.android.detencionesActivas.length; + + entorno.android.detenerActivoGate = Completer(); + await tester.tap(find.text(l10n.alarmForceStopAction)); + await tester.pump(); + await tester.tap(find.text(l10n.alarmForceStopAction)); + await tester.pump(); + expect( + entorno.android.detencionesActivas.length, + llamadasPrevias + 1, + reason: 'la segunda invocacion superpuesta debe ser no-op', + ); + entorno.android.detenerActivoGate!.complete(); + await tester.pumpAndSettle(); + + // Fallo confirmado: el banner sigue y el guard debe haberse + // reseteado para permitir un reintento. + expect(find.byType(PantallaAlarmaSonando), findsOneWidget); + entorno.android.fallaDetener = false; + await tester.tap(find.text(l10n.alarmForceStopAction)); + await tester.pumpAndSettle(); + expect(find.byType(PantallaAlarmaSonando), findsNothing); + }, + ); + }); + + group('reconciliacion de fin de ring externo (RES-1)', () { + testWidgets( + 'si la alarma se registra como perdida externamente, la pantalla se ' + 'auto-cierra', + (tester) async { + final entorno = await _montarPantalla(tester); + + entorno.android.emitirEvento( + EventoAlarmaAndroid( + alarmaId: 'ring1', + titulo: 'Despertar', + accion: EventoAlarmaAndroid.accionMissed, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(PantallaAlarmaSonando), findsNothing); + }, + ); + }); + group('salidas del ring fuera de los botones', () { testWidgets( 'el boton atras del sistema se comporta como Detener: finaliza la ' diff --git a/test/servicios/servicio_alarmas_android_test.dart b/test/servicios/servicio_alarmas_android_test.dart index 9ccff30..1cc47a9 100644 --- a/test/servicios/servicio_alarmas_android_test.dart +++ b/test/servicios/servicio_alarmas_android_test.dart @@ -20,6 +20,10 @@ void main() { return true; case 'requestIgnoreBatteryOptimizations': return true; + case 'getActiveRingingAlarmId': + return 'ring1'; + case 'stopActiveAlarm': + return {'stopped': true, 'wasRinging': true, 'activeAlarmId': 'ring1'}; } return null; }); @@ -107,4 +111,54 @@ void main() { }, ); + test( + 'detenerSonidoActivo mapea el resultado nativo confirmado a ResultadoDetencion', + () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + + final resultado = await servicio.detenerSonidoActivo(); + final sonando = await servicio.alarmaSonandoId(); + + expect(resultado.detenido, isTrue); + expect(resultado.estabaSonando, isTrue); + expect(resultado.alarmaId, 'ring1'); + expect(sonando, 'ring1'); + }, + ); + + test( + 'detenerSonidoActivo retorna un resultado fallido cuando el canal lanza error', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + llamadas.add(call); + throw PlatformException(code: 'STOP_FAILED', message: 'boom'); + }); + final servicio = ServicioAlarmasAndroid(channel: channel); + + final resultado = await servicio.detenerSonidoActivo(); + + expect(resultado.detenido, isFalse); + expect(resultado.estabaSonando, isFalse); + expect(resultado.alarmaId, isNull); + }, + ); + + test( + 'alarmaSonandoId propaga el error del canal (fail-toward-silence, ' + 'Finding 2)', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + llamadas.add(call); + throw PlatformException(code: 'QUERY_FAILED', message: 'boom'); + }); + final servicio = ServicioAlarmasAndroid(channel: channel); + + expect( + () => servicio.alarmaSonandoId(), + throwsA(isA()), + ); + }, + ); } diff --git a/test/servicios/servicio_alarmas_pre_notice_template_test.dart b/test/servicios/servicio_alarmas_pre_notice_template_test.dart index 9c7fab3..9234f55 100644 --- a/test/servicios/servicio_alarmas_pre_notice_template_test.dart +++ b/test/servicios/servicio_alarmas_pre_notice_template_test.dart @@ -53,6 +53,11 @@ void main() { contains('{minutes}'), reason: 'snooze countdown template must keep the {minutes} placeholder', ); + expect( + args['missedTemplate'], + contains('{name}'), + reason: 'missed template must keep the {name} placeholder', + ); // Every notification/channel/chooser string must be present and non-empty // so the native side never falls back to English for a configured locale. @@ -68,6 +73,7 @@ void main() { 'preNoticeChannelDescription', 'openFolderTitle', 'openRecordingTitle', + 'missedTitle', ]; for (final clave in claves) { expect(args[clave], isA(), reason: '$clave missing');