fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes, only uninstall silenced it) plus systematic hardening of every stop path. Native (Kotlin): - Verified stop: stopActiveAlarm now derives its result from the real post-teardown state (companion instance + synchronous stopEverything + activeRingingId check) instead of reporting unconditional success. - Atomic teardown: every stop path (stop action, notification button, snooze, missed, onDestroy, startForeground failure) funnels through one stopEverything() covering audio, wakelock, notification, foreground state and firing-record cleanup; player.release() guarded. - Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a FIRED->MISSED transition with a localized missed-alarm notification; repeating alarms keep their native rearm, deleted alarms never produce ghost MISSED notifications. - Durable firing record with onStartCommand re-validation (resurrection guard) and boot-time stale cleanup; firing records cleared on every refuse/mismatch/cancel path. - New notification-only dismissal channel (dismissAlarmNotificationOnly) so UI-level dedup can never kill a live ring's audio. Flutter (Dart): - Stop/disable/edit/delete of a ringing alarm always attempt to silence it; on native-query failure the stop falls back toward silence via the id-scoped legacy stop. - Verified-stop results surface failures: the ringing screen keeps dismiss-by-design on success, but on a verified failure it stays up with a persistent force-stop banner (guarded against double-dismiss) and auto-dismisses if the ring ends externally (missed/notification). - Missed events sync alarm bookkeeping without opening the ringing UI. - 4 new l10n keys translated across all 13 locales (ARB guard green). 550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds (2 deterministic + 1 refuter-corroborated critical fixed); formal gentle-ai receipt waived by maintainer authorization (correction scope legitimately exceeded the frozen genesis paths). On-device QA checklist in openspec/changes/alarm-system-overhaul/tasks.md pending before archive.
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "يرن باستخدام الصوت الداخلي الآمن.",
|
||||
"ringingPreparingInternalAudio": "جارٍ تجهيز الصوت الداخلي الآمن.",
|
||||
"stopAlarmAction": "إيقاف المنبه",
|
||||
"alarmStopFailedMessage": "تعذّر التأكد من إيقاف المنبه. حاول مرة أخرى.",
|
||||
"alarmForceStopAction": "إيقاف قسري",
|
||||
"alarmMissedNotificationTitle": "منبه فائت",
|
||||
"alarmMissedNotificationText": "تم كتم {name} تلقائيًا بعد 10 دقائق.",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "إيقاف مؤقت",
|
||||
"miniPlayerOpenLabel": "فتح المشغل لـ {stationName}",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "নিরাপদ অভ্যন্তরীণ শব্দ দিয়ে বাজছে।",
|
||||
"ringingPreparingInternalAudio": "নিরাপদ অভ্যন্তরীণ শব্দ প্রস্তুত হচ্ছে।",
|
||||
"stopAlarmAction": "অ্যালার্ম বন্ধ করুন",
|
||||
"alarmStopFailedMessage": "অ্যালার্ম বন্ধ হয়েছে তা নিশ্চিত করা যায়নি। আবার চেষ্টা করুন।",
|
||||
"alarmForceStopAction": "জোর করে বন্ধ করুন",
|
||||
"alarmMissedNotificationTitle": "মিস হওয়া অ্যালার্ম",
|
||||
"alarmMissedNotificationText": "১০ মিনিট পর {name} স্বয়ংক্রিয়ভাবে নিঃশব্দ করা হয়েছে।",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "বিরতি দিন",
|
||||
"miniPlayerOpenLabel": "{stationName}-এর প্লেয়ার খুলুন",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {}}},
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "Son sécurisé interne en cours.",
|
||||
"ringingPreparingInternalAudio": "Préparation du son sécurisé interne.",
|
||||
"stopAlarmAction": "Arrêter l’alarme",
|
||||
"alarmStopFailedMessage": "Nous n’avons pas pu confirmer l’arrêt de l’alarme. Réessayez.",
|
||||
"alarmForceStopAction": "Forcer l’arrêt",
|
||||
"alarmMissedNotificationTitle": "Alarme manquée",
|
||||
"alarmMissedNotificationText": "{name} a été mise en sourdine automatiquement après 10 minutes.",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "Pause",
|
||||
"miniPlayerOpenLabel": "Ouvrir le lecteur de {stationName}",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "सुरक्षित आंतरिक ध्वनि के साथ बज रहा है।",
|
||||
"ringingPreparingInternalAudio": "सुरक्षित आंतरिक ध्वनि तैयार हो रही है।",
|
||||
"stopAlarmAction": "अलार्म रोकें",
|
||||
"alarmStopFailedMessage": "हम पुष्टि नहीं कर सके कि अलार्म बंद हुआ। फिर से कोशिश करें।",
|
||||
"alarmForceStopAction": "जबरन बंद करें",
|
||||
"alarmMissedNotificationTitle": "छूटा हुआ अलार्म",
|
||||
"alarmMissedNotificationText": "10 मिनट बाद {name} अपने आप म्यूट कर दिया गया।",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "विराम दें",
|
||||
"miniPlayerOpenLabel": "{stationName} का प्लेयर खोलें",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "Suono sicuro interno in riproduzione.",
|
||||
"ringingPreparingInternalAudio": "Preparazione del suono sicuro interno.",
|
||||
"stopAlarmAction": "Ferma sveglia",
|
||||
"alarmStopFailedMessage": "Non siamo riusciti a confermare l’arresto della sveglia. Riprova.",
|
||||
"alarmForceStopAction": "Forza arresto",
|
||||
"alarmMissedNotificationTitle": "Sveglia mancata",
|
||||
"alarmMissedNotificationText": "{name} è stata disattivata automaticamente dopo 10 minuti.",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "Pausa",
|
||||
"miniPlayerOpenLabel": "Apri il lettore per {stationName}",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "安全な内部音で鳴っています。",
|
||||
"ringingPreparingInternalAudio": "安全な内部音を準備中です。",
|
||||
"stopAlarmAction": "アラームを停止",
|
||||
"alarmStopFailedMessage": "アラームが停止したことを確認できませんでした。もう一度お試しください。",
|
||||
"alarmForceStopAction": "強制停止",
|
||||
"alarmMissedNotificationTitle": "アラームの聞き逃し",
|
||||
"alarmMissedNotificationText": "{name}は10分後に自動的に消音されました。",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "一時停止",
|
||||
"miniPlayerOpenLabel": "{stationName}のプレーヤーを開く",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "Звонит с безопасным внутренним звуком.",
|
||||
"ringingPreparingInternalAudio": "Подготовка безопасного внутреннего звука.",
|
||||
"stopAlarmAction": "Остановить будильник",
|
||||
"alarmStopFailedMessage": "Не удалось подтвердить, что будильник остановлен. Попробуйте снова.",
|
||||
"alarmForceStopAction": "Принудительно остановить",
|
||||
"alarmMissedNotificationTitle": "Пропущенный будильник",
|
||||
"alarmMissedNotificationText": "{name} был автоматически отключён через 10 минут.",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "Пауза",
|
||||
"miniPlayerOpenLabel": "Открыть плеер для {stationName}",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -466,6 +466,15 @@
|
||||
"ringingInternalAudioActive": "正在使用内部安全音频响铃。",
|
||||
"ringingPreparingInternalAudio": "正在准备内部安全音频。",
|
||||
"stopAlarmAction": "停止闹钟",
|
||||
"alarmStopFailedMessage": "无法确认闹钟已停止,请重试。",
|
||||
"alarmForceStopAction": "强制停止",
|
||||
"alarmMissedNotificationTitle": "错过的闹钟",
|
||||
"alarmMissedNotificationText": "{name}已在10分钟后自动静音。",
|
||||
"@alarmMissedNotificationText": {
|
||||
"placeholders": {
|
||||
"name": {}
|
||||
}
|
||||
},
|
||||
"pauseAction": "暂停",
|
||||
"miniPlayerOpenLabel": "打开 {stationName} 的播放器",
|
||||
"@miniPlayerOpenLabel": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 => 'إيقاف مؤقت';
|
||||
|
||||
|
||||
@@ -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 => 'বিরতি দিন';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -913,6 +913,21 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get stopAlarmAction => 'Arrêter l’alarme';
|
||||
|
||||
@override
|
||||
String get alarmStopFailedMessage =>
|
||||
'Nous n’avons pas pu confirmer l’arrêt de l’alarme. Réessayez.';
|
||||
|
||||
@override
|
||||
String get alarmForceStopAction => 'Forcer l’arrêt';
|
||||
|
||||
@override
|
||||
String get alarmMissedNotificationTitle => 'Alarme manquée';
|
||||
|
||||
@override
|
||||
String alarmMissedNotificationText(Object name) {
|
||||
return '$name a été mise en sourdine automatiquement après 10 minutes.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pauseAction => 'Pause';
|
||||
|
||||
|
||||
@@ -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 => 'विराम दें';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -910,6 +910,21 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get stopAlarmAction => 'Ferma sveglia';
|
||||
|
||||
@override
|
||||
String get alarmStopFailedMessage =>
|
||||
'Non siamo riusciti a confermare l’arresto della sveglia. Riprova.';
|
||||
|
||||
@override
|
||||
String get alarmForceStopAction => 'Forza arresto';
|
||||
|
||||
@override
|
||||
String get alarmMissedNotificationTitle => 'Sveglia mancata';
|
||||
|
||||
@override
|
||||
String alarmMissedNotificationText(Object name) {
|
||||
return '$name è stata disattivata automaticamente dopo 10 minuti.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pauseAction => 'Pausa';
|
||||
|
||||
|
||||
@@ -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 => '一時停止';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Пауза';
|
||||
|
||||
|
||||
@@ -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 => '暂停';
|
||||
|
||||
|
||||
@@ -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" 1–30 min or "Never"); after timeout, stop audio/vibration, transition to a "missed alarm" notification, and — for repeating alarms — compute and arm the next occurrence. Never let the ringing service loop indefinitely with no upper bound; an unbounded loop is exactly the "rang 15 minutes, uninstall required" failure mode. [AOSP DeskClock `AlarmStateManager`](https://github.com/LineageOS/android_packages_apps_DeskClock/blob/89aae7601d0bc17bf5f6e89f5a0b919a184256ae/src/com/android/deskclock/alarms/AlarmStateManager.java)
|
||||
|
||||
## 3. UI over lock screen
|
||||
|
||||
3.1 Android 14+ (API 34): `USE_FULL_SCREEN_INTENT` becomes a **special app access** permission auto-granted by Play only to apps whose core function is calls or alarms; otherwise it must be requested via `ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT` and checked with `NotificationManager.canUseFullScreenIntent()`. An alarm-clock app qualifies for default grant, but must still runtime-check the flag (Play/OEM can still revoke) and have a non-FSI fallback (heads-up high-priority notification) if denied. [source.android.com FSI limits](https://source.android.com/docs/core/permissions/fsi-limits) — effective policy date May 31 2024 / enforced from Jan 22 2025.
|
||||
|
||||
3.2 Notification channel for ringing must be `IMPORTANCE_HIGH` (or `IMPORTANCE_MAX` where the platform still exposes it) with `setSound(null, ...)` — the CHANNEL must not play its own sound because the foreground service is already playing the alarm tone; a channel sound + service audio together is a double-audio bug. Set `category = CATEGORY_ALARM`. Attach `setFullScreenIntent(pendingIntent, true)` pointing at a dedicated full-screen Activity, and `setOngoing(true)`.
|
||||
|
||||
3.3 The full-screen ringing Activity must call `setShowWhenLocked(true)` + `setTurnScreenOn(true)` (API 27+) instead of the deprecated window flags, matching this app's `MainActivity` manifest attributes — but the ringing UI should be its OWN activity (not `MainActivity`) with `android:excludeFromRecents`, `singleInstance`/`singleTask`, so it can be shown/dismissed independently of app navigation state.
|
||||
|
||||
3.4 Android 12+ (API 31+) notification trampoline restriction: Stop/Snooze notification action buttons must be `PendingIntent.getForegroundService()` or `PendingIntent.getBroadcast()` directly — **never** a broadcast/service that itself calls `startActivity()`. Any notification action that needs to show UI after tapping must build a `PendingIntent` pointing straight at the destination Activity; do not chain through a receiver-that-launches-activity ("trampoline"), which is blocked and produces silent no-ops or logcat-only failures on Android 12+. [Notification trampoline restrictions](https://developer.android.com/about/versions/12/behavior-changes-12)
|
||||
|
||||
## 4. Stop/Snooze correctness (single source of truth)
|
||||
|
||||
4.1 There must be exactly ONE process/component that owns "is the alarm currently ringing" state and exactly ONE component that owns the audio player instance. In a Flutter app, this MUST be native Kotlin (the foreground service), not Dart — Dart/Flutter engine lifecycle (background isolates, `audio_service`'s Flutter-side handlers) is not guaranteed to be running or reachable when the user taps Stop from a lock-screen notification while the main Flutter engine is not attached. All Stop/Snooze `PendingIntent`s must route to the SAME native receiver/service that started the ringing, using a stable, unique request-code/alarm-instance-id so the intent unambiguously identifies the alarm to stop — never a broadcast that "maybe" reaches Dart via a plugin channel.
|
||||
|
||||
4.2 Foreground service must NOT use `START_STICKY` for the ringing service if that causes the OS to resurrect it after being killed with no user-visible way to stop it. If restart-on-kill is desired for reliability, the restarted service must re-check persisted "is this alarm still supposed to be ringing" state (e.g., a stop-timestamp or dismissed flag in SharedPreferences/DB) on `onStartCommand` and self-terminate immediately if the alarm was already dismissed/stopped — otherwise a killed-and-restarted service can resume playing after the user already stopped it, exactly matching "un-dismissable alarm" reports. [START_STICKY restart discussion](https://issuetracker.google.com/issues/36986292)
|
||||
|
||||
4.3 Notification cancellation and service lifecycle must be coupled atomically: cancelling/removing the notification does NOT stop a foreground service, and stopping the service without cancelling the notification leaves a stuck notification. Every stop path (user tap, timeout, snooze) must call BOTH `stopForeground(STOP_FOREGROUND_REMOVE)` (or equivalent) AND `stopSelf()`, plus release the wake lock, in one atomic method — never rely on the notification's own dismissal to imply the service stopped.
|
||||
|
||||
4.4 If audio is ever routed through a second engine (e.g., `audio_service`'s `AudioService`/media session, used here for radio streaming) instead of the alarm-ringing native player, the Stop action must explicitly stop/abandon audio focus and release that engine too — a stop path that only signals the native alarm service while a second, independently-running audio engine continues playing is a documented Flutter/plugin-interaction failure mode (`audio_service` + background alarm plugin conflicts are reported upstream). [audio_service background-alarm issue](https://github.com/ryanheise/audio_service/issues/704)
|
||||
|
||||
4.5 Handle process death mid-ring: persist "alarm X is currently firing since T" to durable storage (not just in-memory) BEFORE starting playback, and clear it only on a confirmed stop. On any service restart/reboot, if a "firing" record has no matching stop record and its age exceeds the auto-silence timeout, treat it as missed and clean up rather than resuming indefinitely.
|
||||
|
||||
## 5. Reference state machine (AOSP DeskClock `AlarmStateManager`)
|
||||
|
||||
States: `SILENT_STATE` → `LOW_NOTIFICATION_STATE` → `HIGH_NOTIFICATION_STATE` → `FIRED_STATE` → (`SNOOZE_STATE` | `MISSED_STATE`) → `DISMISSED_STATE`, plus a transient `HIDE_NOTIFICATION_STATE`.
|
||||
- SILENT → LOW_NOTIFICATION: scheduled ahead-of-time via `setSilentState()`/`scheduleInstanceStateChange()`.
|
||||
- LOW → HIGH_NOTIFICATION: automatic at `getHighNotificationTime()`; HIGH cannot be user-hidden (only dismiss/snooze).
|
||||
- HIGH → FIRED: at the actual alarm trigger time; `setFiredState()` arms a timeout via `scheduleInstanceStateChange(context, timeout, instance, MISSED_STATE)`.
|
||||
- FIRED → SNOOZE: user action, increments instance trigger time and re-arms.
|
||||
- FIRED → MISSED: automatic on timeout expiry (this IS the auto-silence mechanism).
|
||||
- MISSED → (delete | disable | reschedule next occurrence): `updateParentAlarm()` — one-shot alarms with "delete after use" are deleted, others disabled; repeating alarms create the next instance via `createInstanceAfter()`.
|
||||
- Any state → DISMISSED: final, deletes the instance and checks the parent alarm for rescheduling.
|
||||
[AOSP DeskClock AlarmStateManager, LineageOS mirror](https://github.com/LineageOS/android_packages_apps_DeskClock/blob/89aae7601d0bc17bf5f6e89f5a0b919a184256ae/src/com/android/deskclock/alarms/AlarmStateManager.java)
|
||||
|
||||
Division of responsibility to replicate: the STATE MACHINE (scheduling transitions, timeout arming, missed/dismiss bookkeeping) can be modeled in Dart/domain layer as long as it only computes *when* to transition — but the ACTUAL transition execution at fire time (starting the FGS, playing audio, holding the wake lock, posting the FSI notification, handling Stop/Snooze taps) MUST be native Kotlin, invoked directly from the BroadcastReceiver, independent of whether the Flutter engine is attached.
|
||||
|
||||
Contrast with Fossify/Simple-Clock's simpler model: their `AlarmReceiver` does NOT use a foreground service at all when screen is off — it posts a high-importance full-screen notification and launches a `ReminderActivity` directly, which owns audio playback itself while visible. This is simpler but riskier (no dedicated FGS-held wake lock across the whole ring duration); AOSP DeskClock's FGS-centric model is the more robust reference for a "wakes reliably from any state" alarm. [Simple-Clock AlarmReceiver](https://github.com/SimpleMobileTools/Simple-Clock/blob/master/app/src/main/kotlin/com/simplemobiletools/clock/receivers/AlarmReceiver.kt)
|
||||
|
||||
## 6. Flutter-specific plugin architecture notes
|
||||
|
||||
6.1 `android_alarm_manager_plus`: creates its own background `FlutterEngine` to run a Dart callback on alarm fire. Documented limitations: does not manage `SCHEDULE_EXACT_ALARM` permission at all (app must handle it); can crash when combined with other plugins that also spin up background engines/services (conflicts with `audio_service`-style plugins); callback reliability degrades when the app process was fully killed. **Verdict: not suitable as the sole mechanism for ringing UI/audio — at most usable for lightweight rescheduling logic, never for owning playback.** [plus_plugins issue #266](https://github.com/fluttercommunity/plus_plugins/issues/266)
|
||||
|
||||
6.2 `alarm` (gdelataillade): purpose-built alarm plugin that keeps ALL ringing responsibility (foreground service, `NotificationOnKillService` safety net, audio, vibration, volume) in native Kotlin, exposing only schedule/cancel/stream-of-events to Dart. This is the correct architectural split for a Flutter alarm app and is the closer analog to what this app's custom `PluriWaveAlarmService`/`PluriWaveAlarmReceiver` should converge toward — provided the alarm audio path is fully decoupled from `audio_service`'s media session. [alarm plugin Android install docs](https://github.com/gdelataillade/alarm/blob/main/help/INSTALL-ANDROID.md)
|
||||
|
||||
## 7. Notifications
|
||||
|
||||
7.1 `POST_NOTIFICATIONS` runtime permission (Android 13+/API 33) must be requested before posting any notification, including the ringing FSI notification — if denied, the FSI/heads-up path degrades to nothing being shown, so the app needs an in-app fallback warning.
|
||||
7.2 Alarm channel: `IMPORTANCE_HIGH`, `sound = null` (service owns audio), `CATEGORY_ALARM`, `setOngoing(true)` while ringing, action buttons wired to direct broadcast/service `PendingIntent`s (never trampolines — see 3.4).
|
||||
7.3 Android 16 "Notification Cooldown" (gradual volume reduction for bursty same-app notifications) explicitly **does not apply to calls, alarms, priority conversations, or emergency alerts** — but this exemption is presumably keyed off `CATEGORY_ALARM`/correct channel classification, so mis-categorized alarm notifications could incorrectly get cooled down. Classify correctly. [Android 16 notification cooldown](https://www.computerworld.com/article/3609666/android-16-notification-cooldown.html)
|
||||
7.4 Update (not re-post) the ringing notification via a stable notification ID per alarm instance; re-posting can retrigger heads-up/FSI unexpectedly and complicates the "notification removed but service alive" desync bug (Rule 4.3).
|
||||
|
||||
## 8. Failure-mode checklist (must survive)
|
||||
|
||||
- [ ] Process death mid-ring (service killed by LMK/OEM) — state persisted, restart re-validates before resuming (4.2, 4.5).
|
||||
- [ ] Device reboot — `BOOT_COMPLETED`/`LOCKED_BOOT_COMPLETED` reschedules every pending alarm from durable storage (1.5).
|
||||
- [ ] Doze / App Standby — `setAlarmClock()` used, not a type that Doze can defer (1.1); FGS start from exact alarm is exempt from BG-start restrictions (2.2).
|
||||
- [ ] SCHEDULE_EXACT_ALARM revoked by user/system mid-session — `ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` handled, fallback scheduling used, user warned (1.4, 1.6).
|
||||
- [ ] Locale/timezone change — `TIME_SET`/`TIMEZONE_CHANGED` recompute all trigger times (1.5).
|
||||
- [ ] App update — `MY_PACKAGE_REPLACED` reschedules (1.5).
|
||||
- [ ] Media/ring volume at 0 — irrelevant if alarm audio correctly uses `STREAM_ALARM`/`USAGE_ALARM` (2.4); verify alarm volume specifically, not device master volume.
|
||||
- [ ] DND enabled — alarm-stream audio bypasses DND by default; verify the app is not accidentally routing through a stream DND does silence (2.4).
|
||||
- [ ] Battery optimization / not in Doze-exemption whitelist — regular (non-`setAlarmClock`) alarms are deferred under Doze; either use `setAlarmClock()` (exempt) or guide the user through `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (1.1, repo already declares `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`).
|
||||
- [ ] Two audio engines racing (native alarm player vs. `audio_service` media session) — single source of truth for "is ringing" and for the player instance; Stop must tear down both if both can ever be active (4.1, 4.4) — **highest-priority item given this app's dual audio-engine manifest**.
|
||||
- [ ] Notification dismissed/swiped but service still alive, or vice versa — coupled stop path (4.3).
|
||||
- [ ] Full-screen intent permission denied/revoked (Android 14+ policy or user revocation) — `canUseFullScreenIntent()` checked, heads-up fallback exists (3.1).
|
||||
- [ ] Notification trampoline blocked (Android 12+) — all action `PendingIntent`s go straight to service/broadcast or directly to an Activity, never chained (3.4).
|
||||
- [ ] Auto-silence/missed-alarm path — bounded ring duration, transition to MISSED, repeating alarms rearm next occurrence (2.5, state machine section 5).
|
||||
|
||||
## Top 10 rules this Flutter app is most likely violating (given dual native-service + audio_service architecture)
|
||||
|
||||
1. Alarm ringing audio possibly not exclusively on `STREAM_ALARM`/`USAGE_ALARM` if it shares code paths with `audio_service`'s media-session player used for radio (Rule 2.4) — needs verification.
|
||||
2. Two independent audio/media engines (`PluriWaveAlarmService` native + `com.ryanheise.audioservice.AudioService`) with no single documented "who owns ringing audio" contract (Rule 4.1, 4.4) — matches the reported incident signature.
|
||||
3. Redundant `SCHEDULE_EXACT_ALARM` + `USE_EXACT_ALARM` declaration without a documented fallback if Play strips `USE_EXACT_ALARM` core-functionality grant (Rule 1.3).
|
||||
4. Unclear whether Stop/Snooze notification actions route directly to service/broadcast `PendingIntent`s vs. any trampoline-like indirection (Rule 3.4) — needs code check.
|
||||
5. Unclear whether the ringing foreground service re-validates "already stopped" state on every `onStartCommand` (protection against `START_STICKY` resurrection) (Rule 4.2).
|
||||
6. Unclear whether notification cancellation and `stopForeground()`/`stopSelf()` are coupled atomically in every stop path (Rule 4.3).
|
||||
7. Unclear whether a durable "currently firing" record exists to recover deterministically from process death mid-ring (Rule 4.5).
|
||||
8. No confirmed bounded auto-silence timeout / missed-alarm transition (Rule 2.5) — an unbounded ring loop is the literal shape of the known incident.
|
||||
9. `ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` receiver exists in the manifest but its Kotlin handler's actual behavior (reschedule vs. log-only) is unverified (Rule 1.4).
|
||||
10. Full-screen intent fallback path (heads-up notification when FSI is denied/revoked) is unverified — app relies on `USE_FULL_SCREEN_INTENT` but Android 14+ can still revoke it via Play/OEM policy (Rule 3.1).
|
||||
|
||||
Note: items 2–9 require the codebase-focused sdd-explore pass (Kotlin source read) to confirm/refute; this document is the external-research half only.
|
||||
@@ -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) | ~650–800 |
|
||||
| Estimated changed lines (Slice B, P1 follow-up) | ~80–120 |
|
||||
| 400-line budget risk | High (well above the 400-line reviewer-cognition guard) |
|
||||
| 800-line session budget risk | Medium (near the ceiling; any test-file growth pushes over) |
|
||||
| Chained PRs recommended | No (session delivery is direct commits to `main`, no PRs) |
|
||||
| Suggested split | Slice A = this delivery (Phases 1–7); Slice B = P1 follow-up (Phase 8) |
|
||||
| Delivery strategy | single-pr-default (direct commits to main, no PRs) |
|
||||
| Chain strategy | size-exception |
|
||||
|
||||
Decision needed before apply: Yes
|
||||
Chained PRs recommended: No
|
||||
Chain strategy: size-exception
|
||||
400-line budget risk: High
|
||||
|
||||
Rationale: no-PR direct-commit strategy forecloses PR chaining, but Slice A crosses the 400-line
|
||||
guard on its own. Maintainer must explicitly accept a `size:exception` for Slice A before `sdd-apply`
|
||||
proceeds; Slice B (P1) ships as a separate, smaller follow-up commit set once Slice A lands.
|
||||
|
||||
### Suggested Work Units (sequenced commit batches, not PRs)
|
||||
|
||||
| Unit | Goal | Focused test command | Runtime harness | Rollback boundary |
|
||||
|------|------|-----------------------|------------------|--------------------|
|
||||
| A1 — Native stop core (Ph.1–3) | id-agnostic stop, atomic teardown, notification route fix, channel bridge | N/A (no Kotlin build env; grep-verifiable) | Device QA checklist below | Revert `PluriWaveAlarmService.kt`/`MainActivity.kt` hunks; additive-only |
|
||||
| A2 — Native auto-silence + durable record (Ph.2) | MISSED transition, firing record, boot cleanup | N/A (grep-verifiable) | Device QA: 10-min + reboot scenarios | Revert `AlarmScheduler.kt`/`PluriWaveAlarmReceiver.kt`; orphaned pref keys unread |
|
||||
| A3 — Dart orchestration (Ph.4–6) | mutation guard, force-stop UI, missed bookkeeping | `flutter test test/estado/estado_alarmas_test.dart test/servicios/servicio_alarmas_android_test.dart test/pantallas/pantalla_alarma_sonando_test.dart` | N/A (unit/widget-covered) | Revert Dart hunks; guard is additive to existing stop calls |
|
||||
| A4 — l10n (Ph.7) | 4 new keys, en+es only | `flutter test` + CI ARB guard | N/A | Revert 2 ARB files |
|
||||
| B1 — P1 fallback banners (Phase 8, follow-up) | FSI/exact-alarm/notification-permission warnings | `flutter test test/estado` | Device QA: FSI-denied scenario | Revert diagnostics-banner hunk only |
|
||||
|
||||
## Phase 1: Native Stop Core (Kotlin, static-edit)
|
||||
|
||||
- [x] 1.1 `PluriWaveAlarmService.kt`: add companion `@Volatile var activeRingingId: String?`, set in `startAlarm`, cleared in `stopEverything`. [kotlin-static] → Decision 1. Accept: grep shows `@Volatile` field + both set/clear sites.
|
||||
- [x] 1.2 Add `ACTION_STOP_ACTIVE` const; extract `stopEverything()` from the L410-433 teardown (runnables, player stop/release, `activeAlarmId=null`, companion clear, wakelock release, audio-focus abandon, notification cancel, `clearFiringRecord`+`cancelAutoSilence`, `stopForeground(STOP_FOREGROUND_REMOVE)`, `stopSelf()`). [kotlin-static] → NA-2, Decision 2. Accept: grep shows all 3 teardown calls in `stopEverything()` and every stop path (`ACTION_STOP`, `ACTION_STOP_ACTIVE`, `ACTION_SNOOZE`, `ACTION_MISSED`, `onDestroy`) calls it.
|
||||
- [x] 1.3 `onStartCommand` `ACTION_STOP_ACTIVE` branch → `stopEverything()` id-agnostically; `stopAlarm(id)` keeps id-scoped wrapper (foreign-id mismatch cancels only that id's notification). [kotlin-static] → NA-1a, NA-1b, NA-1c, Decision 1. Accept: branch present, ignores extras id.
|
||||
- [x] 1.4 **(feedback item 1)** Fix `stopPendingIntent(alarmId)`: change dispatched `action` from `ACTION_STOP` to `ACTION_STOP_ACTIVE` so the notification Stop button routes to the id-agnostic stop. [kotlin-static] → SS-4a, NA-1a. Accept: grep confirms `action = ACTION_STOP_ACTIVE` inside `stopPendingIntent`.
|
||||
|
||||
## Phase 2: Native Auto-Silence + Durable Firing Record (Kotlin, static-edit)
|
||||
|
||||
- [x] 2.1 `AlarmScheduler.kt`: add `AUTO_SILENCE_MILLIS` (10 min), `recordFiring(id)`/`clearFiringRecord(id)`/`firingRecordAgeMillis(id)` on `KEY_FIRING_IDS`+`firing_<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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user