fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s

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.
This commit is contained in:
Javier Bautista Fernández
2026-07-22 23:52:36 +02:00
parent 0f9a6a1719
commit 29f7d54e85
50 changed files with 2461 additions and 24 deletions
@@ -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<String, Any?>) {
@@ -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
@@ -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"
@@ -152,6 +152,16 @@ class MainActivity : AudioServiceActivity() {
result.success(null)
}
}
"dismissAlarmNotificationOnly" -> {
val id = call.argument<String>("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<String>("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
@@ -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
)
}
}
@@ -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
+13 -8
View File
@@ -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<EstadoAlarmas>();
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;
}
+88 -1
View File
@@ -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<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
List<ExcepcionAlarma> 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<void> _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<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
@@ -271,6 +308,7 @@ class EstadoAlarmas extends ChangeNotifier {
Future<void> 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<void> 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<void> 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<void> _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<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
try {
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "يرن باستخدام الصوت الداخلي الآمن.",
"ringingPreparingInternalAudio": "جارٍ تجهيز الصوت الداخلي الآمن.",
"stopAlarmAction": "إيقاف المنبه",
"alarmStopFailedMessage": "تعذّر التأكد من إيقاف المنبه. حاول مرة أخرى.",
"alarmForceStopAction": "إيقاف قسري",
"alarmMissedNotificationTitle": "منبه فائت",
"alarmMissedNotificationText": "تم كتم {name} تلقائيًا بعد 10 دقائق.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "إيقاف مؤقت",
"miniPlayerOpenLabel": "فتح المشغل لـ {stationName}",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "নিরাপদ অভ্যন্তরীণ শব্দ দিয়ে বাজছে।",
"ringingPreparingInternalAudio": "নিরাপদ অভ্যন্তরীণ শব্দ প্রস্তুত হচ্ছে।",
"stopAlarmAction": "অ্যালার্ম বন্ধ করুন",
"alarmStopFailedMessage": "অ্যালার্ম বন্ধ হয়েছে তা নিশ্চিত করা যায়নি। আবার চেষ্টা করুন।",
"alarmForceStopAction": "জোর করে বন্ধ করুন",
"alarmMissedNotificationTitle": "মিস হওয়া অ্যালার্ম",
"alarmMissedNotificationText": "১০ মিনিট পর {name} স্বয়ংক্রিয়ভাবে নিঃশব্দ করা হয়েছে।",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "বিরতি দিন",
"miniPlayerOpenLabel": "{stationName}-এর প্লেয়ার খুলুন",
"@miniPlayerOpenLabel": {
+9
View File
@@ -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": {
+9
View File
@@ -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": {
+5
View File
@@ -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": {}}},
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Son sécurisé interne en cours.",
"ringingPreparingInternalAudio": "Préparation du son sécurisé interne.",
"stopAlarmAction": "Arrêter lalarme",
"alarmStopFailedMessage": "Nous navons pas pu confirmer larrêt de lalarme. Réessayez.",
"alarmForceStopAction": "Forcer larrê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": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "सुरक्षित आंतरिक ध्वनि के साथ बज रहा है।",
"ringingPreparingInternalAudio": "सुरक्षित आंतरिक ध्वनि तैयार हो रही है।",
"stopAlarmAction": "अलार्म रोकें",
"alarmStopFailedMessage": "हम पुष्टि नहीं कर सके कि अलार्म बंद हुआ। फिर से कोशिश करें।",
"alarmForceStopAction": "जबरन बंद करें",
"alarmMissedNotificationTitle": "छूटा हुआ अलार्म",
"alarmMissedNotificationText": "10 मिनट बाद {name} अपने आप म्यूट कर दिया गया।",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "विराम दें",
"miniPlayerOpenLabel": "{stationName} का प्लेयर खोलें",
"@miniPlayerOpenLabel": {
+9
View File
@@ -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": {
+9
View File
@@ -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 larresto 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": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "安全な内部音で鳴っています。",
"ringingPreparingInternalAudio": "安全な内部音を準備中です。",
"stopAlarmAction": "アラームを停止",
"alarmStopFailedMessage": "アラームが停止したことを確認できませんでした。もう一度お試しください。",
"alarmForceStopAction": "強制停止",
"alarmMissedNotificationTitle": "アラームの聞き逃し",
"alarmMissedNotificationText": "{name}は10分後に自動的に消音されました。",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "一時停止",
"miniPlayerOpenLabel": "{stationName}のプレーヤーを開く",
"@miniPlayerOpenLabel": {
+9
View File
@@ -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": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "Звонит с безопасным внутренним звуком.",
"ringingPreparingInternalAudio": "Подготовка безопасного внутреннего звука.",
"stopAlarmAction": "Остановить будильник",
"alarmStopFailedMessage": "Не удалось подтвердить, что будильник остановлен. Попробуйте снова.",
"alarmForceStopAction": "Принудительно остановить",
"alarmMissedNotificationTitle": "Пропущенный будильник",
"alarmMissedNotificationText": "{name} был автоматически отключён через 10 минут.",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "Пауза",
"miniPlayerOpenLabel": "Открыть плеер для {stationName}",
"@miniPlayerOpenLabel": {
+9
View File
@@ -466,6 +466,15 @@
"ringingInternalAudioActive": "正在使用内部安全音频响铃。",
"ringingPreparingInternalAudio": "正在准备内部安全音频。",
"stopAlarmAction": "停止闹钟",
"alarmStopFailedMessage": "无法确认闹钟已停止,请重试。",
"alarmForceStopAction": "强制停止",
"alarmMissedNotificationTitle": "错过的闹钟",
"alarmMissedNotificationText": "{name}已在10分钟后自动静音。",
"@alarmMissedNotificationText": {
"placeholders": {
"name": {}
}
},
"pauseAction": "暂停",
"miniPlayerOpenLabel": "打开 {stationName} 的播放器",
"@miniPlayerOpenLabel": {
+24
View File
@@ -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:
+15
View File
@@ -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 => 'إيقاف مؤقت';
+15
View File
@@ -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 => 'বিরতি দিন';
+15
View File
@@ -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';
+15
View File
@@ -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';
+15
View File
@@ -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';
+15
View File
@@ -913,6 +913,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get stopAlarmAction => 'Arrêter lalarme';
@override
String get alarmStopFailedMessage =>
'Nous navons pas pu confirmer larrêt de lalarme. Réessayez.';
@override
String get alarmForceStopAction => 'Forcer larrê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';
+15
View File
@@ -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 => 'विराम दें';
+15
View File
@@ -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';
+15
View File
@@ -910,6 +910,21 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get stopAlarmAction => 'Ferma sveglia';
@override
String get alarmStopFailedMessage =>
'Non siamo riusciti a confermare larresto 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';
+14
View File
@@ -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 => '一時停止';
+15
View File
@@ -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';
+15
View File
@@ -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 => 'Пауза';
+14
View File
@@ -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 => '暂停';
+94 -5
View File
@@ -30,9 +30,31 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
/// (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<EstadoAlarmas>();
_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<PantallaAlarmaSonando> {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
// 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<void> _forzarDetencion() async {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
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<PantallaAlarmaSonando> {
@override
void dispose() {
_alarmas.removeListener(_alReconciliarFinExterno);
super.dispose();
}
@@ -209,6 +263,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
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<PantallaAlarmaSonando> {
),
);
}
/// 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) =>
@@ -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<Object?, Object?> 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<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
Future<void> 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<void> ocultarSoloNotificacion(String alarmaId);
Future<void> detenerSonidoNativo(String alarmaId);
/// Synchronous companion snapshot (Decision 1): the id of the alarm
/// currently ringing natively, or null if none is.
Future<String?> 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<ResultadoDetencion> detenerSonidoActivo();
Future<bool> solicitarPermisoAlarmasExactas();
Future<bool> solicitarPermisoNotificaciones();
Future<bool> 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<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
@@ -286,10 +343,43 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
Future<void> ocultarNotificacionAlarma(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId});
@override
Future<void> ocultarSoloNotificacion(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotificationOnly', {'id': alarmaId});
@override
Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
@override
Future<String?> alarmaSonandoId() async {
try {
return await _channel.invokeMethod<String>('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<ResultadoDetencion> detenerSonidoActivo() async {
try {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'stopActiveAlarm',
);
if (raw == null) return ResultadoDetencion.fallo;
return ResultadoDetencion.fromMap(raw);
} catch (e) {
debugPrint('[PluriWave][alarmas] detenerSonidoActivo ERROR $e');
return ResultadoDetencion.fallo;
}
}
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
@@ -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.
@@ -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_<id>``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<String?> alarmaSonandoId()` and
`Future<ResultadoDetencion> 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<String?> alarmaSonandoId(); // getActiveRingingAlarmId
Future<ResultadoDetencion> 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_<id>`, `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<bool>` for a cleaner contract, or keep the
`_error`-field convention the snooze path uses? (Leaning `_error` for consistency.)
@@ -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).
@@ -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" 130 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 29 require the codebase-focused sdd-explore pass (Kotlin source read) to confirm/refute; this document is the external-research half only.
@@ -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.
@@ -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
@@ -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
@@ -0,0 +1,106 @@
# Tasks: Alarm System Overhaul — Fail-Safe Stop/Dismiss
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines (Slice A, code+tests) | ~650800 |
| Estimated changed lines (Slice B, P1 follow-up) | ~80120 |
| 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 17); 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.13) | 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.46) | 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_<id>` 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<bool>` vs. existing `_error`-field convention (default assumed above: `_error`) — confirm before Phase 4.6 lands.
+358
View File
@@ -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<void>();
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 {
+50
View File
@@ -11,6 +11,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
final canceladas = <String>[];
final detenidas = <String>[];
final ocultadas = <String>[];
final soloOcultadas = <String>[];
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
final snoozesNativos = <EstadoSnoozeNativo>[];
final _eventos = StreamController<EventoAlarmaAndroid>.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 = <String>[];
/// Test-only gate (RES-2 guard test): when set, [detenerSonidoActivo]
/// awaits it before resolving, letting tests exercise an overlapping
/// in-flight call.
Completer<void>? 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<String?> alarmaSonandoId() async {
if (fallaConsultaSonando) {
throw StateError('fake alarmaSonandoId failure');
}
return alarmaSonandoIdValor;
}
@override
Future<ResultadoDetencion> 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<void> ocultarNotificacionAlarma(String alarmaId) async {
ocultadas.add(alarmaId);
}
@override
Future<void> ocultarSoloNotificacion(String alarmaId) async {
soloOcultadas.add(alarmaId);
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
DiagnosticoAlarmasAndroid(
@@ -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)', () {
@@ -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<void>();
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 '
@@ -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<PlatformException>()),
);
},
);
}
@@ -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<String>(), reason: '$clave missing');