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

Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes,
only uninstall silenced it) plus systematic hardening of every stop path.

Native (Kotlin):
- Verified stop: stopActiveAlarm now derives its result from the real
  post-teardown state (companion instance + synchronous stopEverything +
  activeRingingId check) instead of reporting unconditional success.
- Atomic teardown: every stop path (stop action, notification button,
  snooze, missed, onDestroy, startForeground failure) funnels through one
  stopEverything() covering audio, wakelock, notification, foreground
  state and firing-record cleanup; player.release() guarded.
- Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a
  FIRED->MISSED transition with a localized missed-alarm notification;
  repeating alarms keep their native rearm, deleted alarms never produce
  ghost MISSED notifications.
- Durable firing record with onStartCommand re-validation (resurrection
  guard) and boot-time stale cleanup; firing records cleared on every
  refuse/mismatch/cancel path.
- New notification-only dismissal channel (dismissAlarmNotificationOnly)
  so UI-level dedup can never kill a live ring's audio.

Flutter (Dart):
- Stop/disable/edit/delete of a ringing alarm always attempt to silence
  it; on native-query failure the stop falls back toward silence via the
  id-scoped legacy stop.
- Verified-stop results surface failures: the ringing screen keeps
  dismiss-by-design on success, but on a verified failure it stays up
  with a persistent force-stop banner (guarded against double-dismiss)
  and auto-dismisses if the ring ends externally (missed/notification).
- Missed events sync alarm bookkeeping without opening the ringing UI.
- 4 new l10n keys translated across all 13 locales (ARB guard green).

550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds
(2 deterministic + 1 refuter-corroborated critical fixed); formal
gentle-ai receipt waived by maintainer authorization (correction scope
legitimately exceeded the frozen genesis paths). On-device QA checklist
in openspec/changes/alarm-system-overhaul/tasks.md pending before
archive.
This commit is contained in:
Javier Bautista Fernández
2026-07-22 23:52:36 +02:00
parent 0f9a6a1719
commit 29f7d54e85
50 changed files with 2461 additions and 24 deletions
@@ -28,6 +28,8 @@ object AlarmNotificationStrings {
const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate"
const val KEY_OPEN_FOLDER = "openFolderTitle"
const val KEY_OPEN_RECORDING = "openRecordingTitle"
const val KEY_MISSED_TITLE = "missedTitle"
const val KEY_MISSED_TEMPLATE = "missedTemplate"
/** Persists the localized strings pushed by Flutter. Blank values are removed. */
fun save(context: Context, values: Map<String, Any?>) {
@@ -53,6 +55,15 @@ object AlarmNotificationStrings {
get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm")
fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder")
fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording")
fun missedTitle(context: Context) = get(context, KEY_MISSED_TITLE, "Missed alarm")
fun missedText(context: Context, name: String): String =
format(
// "10 minutes" mirrors AlarmScheduler.AUTO_SILENCE_MILLIS
// (READ-3/READ-4) -- keep both, and the alarmMissedNotificationText
// entry of ALL 13 lib/l10n/app_*.arb files, in sync.
get(context, KEY_MISSED_TEMPLATE, "{name} was silenced automatically after 10 minutes."),
name
)
fun preNoticeText(context: Context, minutes: Long): String =
format(get(context, KEY_PRE_NOTICE_TEMPLATE, "Starts in {minutes} min"), minutes)
@@ -63,6 +74,9 @@ object AlarmNotificationStrings {
private fun format(template: String, minutes: Long): String =
template.replace("{minutes}", minutes.toString())
private fun format(template: String, name: String): String =
template.replace("{name}", name)
private fun get(context: Context, key: String, fallback: String): String =
prefs(context).getString(key, null)?.takeIf { it.isNotBlank() } ?: fallback
@@ -285,6 +285,12 @@ class AlarmScheduler(private val context: Context) {
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
// Durable firing record + bounded auto-silence (Decision 3/4): written
// BEFORE the service/audio starts (this receiver path runs first) so a
// process death mid-ring still proves the ring was in flight, and the
// 10-minute MISSED transition is armed even if the FGS later dies.
recordFiring(id)
armAutoSilence(id)
val firedAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
saveHandledOccurrence(id, firedAt)
val next = spec.copy(
@@ -301,6 +307,7 @@ class AlarmScheduler(private val context: Context) {
}
fun skipNext(id: String) {
cancelAutoSilence(id)
val spec = readSpec(id) ?: return
val next = spec.copy(
snoozeUntilMillis = null,
@@ -322,6 +329,7 @@ class AlarmScheduler(private val context: Context) {
* back to Flutter (single source of truth), or null if the spec is gone.
*/
fun snooze(id: String, minutes: Int): NativeSnoozeResult? {
cancelAutoSilence(id)
val spec = readSpec(id) ?: return null
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
@@ -665,6 +673,12 @@ class AlarmScheduler(private val context: Context) {
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelSnoozeCountdown(id)
cancelPreNoticeCountdown(id)
cancelAutoSilence(id)
// Deleted-alarm ghost MISSED fix (RES-3): a firing record surviving a
// delete would otherwise let the already-cancelled auto-silence path
// (or a stray boot cleanup) resurrect a MISSED transition for an
// alarm the user removed.
clearFiringRecord(id)
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
@@ -679,12 +693,142 @@ class AlarmScheduler(private val context: Context) {
)
}
/** Durable "firing since T" record (Decision 4): written before audio starts. */
fun recordFiring(id: String) {
val ids = prefs().getStringSet(KEY_FIRING_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(id)
prefs().edit()
.putStringSet(KEY_FIRING_IDS, ids)
.putLong("$KEY_FIRING_PREFIX$id", System.currentTimeMillis())
.apply()
}
/** Clears the firing record: only on a confirmed stop or a completed MISSED transition. */
fun clearFiringRecord(id: String) {
val ids = prefs().getStringSet(KEY_FIRING_IDS, emptySet()).orEmpty().toMutableSet()
ids.remove(id)
prefs().edit()
.putStringSet(KEY_FIRING_IDS, ids)
.remove("$KEY_FIRING_PREFIX$id")
.apply()
}
/** Milliseconds since [id] started firing, or null if no record exists. */
fun firingRecordAgeMillis(id: String): Long? {
val firedAt = prefs().getLong("$KEY_FIRING_PREFIX$id", 0L)
if (firedAt <= 0L) return null
return System.currentTimeMillis() - firedAt
}
/** Boot/restart cleanup: any firing record past the auto-silence bound is stale. */
fun cleanupStaleFiringRecords() {
for (id in prefs().getStringSet(KEY_FIRING_IDS, emptySet()).orEmpty().toList()) {
val age = firingRecordAgeMillis(id) ?: continue
if (age > AUTO_SILENCE_MILLIS) {
Log.w(tag, "alarm.firingRecord stale at boot id=$id ageMs=$age; treating as missed")
onAlarmMissed(id)
}
}
}
/**
* Arms the AlarmManager-driven MISSED transition (Decision 3): fires
* [AUTO_SILENCE_MILLIS] after the current instant, surviving process
* death (unlike an in-service Handler.postDelayed).
*/
fun armAutoSilence(id: String) {
val pending = PluriWaveAlarmReceiver.pendingMissedIntent(
appContext,
id,
PendingIntent.FLAG_UPDATE_CURRENT
)
try {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + AUTO_SILENCE_MILLIS,
pending
)
Log.d(tag, "alarm.autoSilence armed id=$id")
} catch (error: SecurityException) {
Log.w(tag, "alarm.autoSilence arm SecurityException id=$id", error)
}
}
/** Cancels a pending MISSED transition; a safe no-op if none is armed. */
fun cancelAutoSilence(id: String) {
cancelPending(
"autoSilence",
PluriWaveAlarmReceiver.pendingMissedIntent(appContext, id, PendingIntent.FLAG_NO_CREATE)
)
}
/**
* FIRED→MISSED transition (Decision 3): stops the service if [id] is
* still ringing, posts a missed-alarm notification, clears the firing
* record. Deliberately does NOT rearm -- [onAlarmFired] already rearmed
* the next occurrence (repeating) or left it disabled (one-shot) at fire
* time, so a re-arm here would double-schedule.
*/
fun onAlarmMissed(id: String) {
val spec = readSpec(id)
Log.d(tag, "alarm.missed id=$id")
if (spec == null) {
// Deleted-alarm ghost MISSED fix (RES-3): the alarm was removed
// (cancelAlarm) after this transition was armed -- clear the
// stale firing record only, never post a notification or emit a
// Dart event for an alarm that no longer exists.
Log.d(tag, "alarm.missed spec gone id=$id; clearing firing record only")
clearFiringRecord(id)
return
}
if (PluriWaveAlarmService.activeRingingId == id) {
PluriWaveAlarmService.stop(appContext, id)
}
postMissedNotification(id, spec.title)
clearFiringRecord(id)
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to id,
"alarmTitle" to spec.title,
"alarmAction" to MainActivity.ALARM_ACTION_MISSED,
"occurrenceAtMillis" to (spec.snoozeOriginMillis ?: spec.triggerAtMillis)
)
)
}
private fun postMissedNotification(id: String, title: String) {
ensurePreNoticeChannel()
val notification = NotificationCompat.Builder(appContext, PluriWaveAlarmReceiver.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_pluriwave)
.setColor(NotificationBrand.CYAN)
.setContentTitle(AlarmNotificationStrings.missedTitle(appContext))
.setContentText(AlarmNotificationStrings.missedText(appContext, title))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setSilent(true)
.setAutoCancel(true)
.build()
try {
NotificationManagerCompat.from(appContext).notify(
PluriWaveAlarmReceiver.notificationIdForAlarm(id),
notification
)
} catch (error: SecurityException) {
Log.e(tag, "alarm.missed notify SecurityException id=$id", error)
}
}
fun canScheduleExactAlarms(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
alarmManager.canScheduleExactAlarms()
}
fun reschedulePersistedAlarms() {
// Boot/restart cleanup (NA "Boot/Restart Cleanup of Stale Firing
// Records"): a firing record older than the auto-silence bound left
// over from a killed process must be treated as missed BEFORE any
// alarm is rescheduled, so a reboot mid-ring never resurrects audio.
cleanupStaleFiringRecords()
for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) {
val spec = readSpec(id) ?: continue
try {
@@ -1088,7 +1232,20 @@ class AlarmScheduler(private val context: Context) {
private const val KEY_ALARM_PREFIX = "scheduled_alarm_"
private const val KEY_HANDLED_IDS = "handled_alarm_ids"
private const val KEY_HANDLED_PREFIX = "handled_alarm_"
private const val KEY_FIRING_IDS = "firing_alarm_ids"
private const val KEY_FIRING_PREFIX = "firing_"
private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L
/**
* Bounded auto-silence window (Decision 3): matches the wakelock cap.
* READ-3/READ-4 cross-reference: the "10 minutes" wording is
* duplicated as user-facing text in
* [AlarmNotificationStrings.missedText]'s fallback template AND in
* the `alarmMissedNotificationText` entry of ALL 13 lib/l10n/app_*.arb
* files -- change this constant AND those strings together if the
* window ever changes.
*/
const val AUTO_SILENCE_MILLIS = 10 * 60 * 1000L
private const val SCHEDULE_UNICA = "unica"
private const val SCHEDULE_DIAS_SEMANA = "diasSemana"
@@ -152,6 +152,16 @@ class MainActivity : AudioServiceActivity() {
result.success(null)
}
}
"dismissAlarmNotificationOnly" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel dismissAlarmNotificationOnly id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
alarmScheduler.dismissFireNotification(id)
result.success(null)
}
}
"stopNativeAlarmSound" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id")
@@ -162,6 +172,30 @@ class MainActivity : AudioServiceActivity() {
result.success(null)
}
}
"getActiveRingingAlarmId" -> {
result.success(PluriWaveAlarmService.activeRingingId)
}
"stopActiveAlarm" -> {
try {
// Verified stop (feedback item 1, RISK-1/RES-1/REL-2): the
// id is snapshotted BEFORE stopping, and "stopped" now
// reflects stopActiveVerified's post-teardown check
// instead of a literal true decided before teardown ran.
val activeId = PluriWaveAlarmService.activeRingingId
val stopped = PluriWaveAlarmService.stopActiveVerified(this)
Log.d(tag, "alarm.channel stopActiveAlarm activeId=$activeId stopped=$stopped")
result.success(
mapOf(
"stopped" to stopped,
"wasRinging" to (activeId != null),
"activeAlarmId" to activeId
)
)
} catch (error: Throwable) {
Log.e(tag, "alarm.channel stopActiveAlarm failed", error)
result.error("STOP_FAILED", error.message, null)
}
}
"diagnostics" -> {
Log.d(tag, "alarm.channel diagnostics")
result.success(
@@ -1192,6 +1226,9 @@ class MainActivity : AudioServiceActivity() {
/** alarmAction reported when a pending snooze was cancelled natively. */
const val ALARM_ACTION_SNOOZE_CANCELLED = "snoozeCancelled"
/** alarmAction reported when a fired alarm auto-silenced unattended (Decision 3). */
const val ALARM_ACTION_MISSED = "missed"
@Volatile
private var activeInstance: MainActivity? = null
@@ -95,6 +95,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
ACTION_SNOOZE_COUNTDOWN -> {
AlarmScheduler(context).handleSnoozeCountdownTick(alarmId)
}
ACTION_MISSED -> {
AlarmScheduler(context).onAlarmMissed(alarmId)
}
ACTION_SNOOZE_AGAIN -> {
val snoozed = AlarmScheduler(context).snoozeAgain(alarmId, snoozeMinutes)
if (snoozed != null) {
@@ -242,8 +245,6 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
manager.createNotificationChannel(channel)
}
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
@@ -257,6 +258,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
const val ACTION_SNOOZE_COUNTDOWN = "es.freetimelab.pluriwave.alarm.SNOOZE_COUNTDOWN"
const val ACTION_SNOOZE_AGAIN = "es.freetimelab.pluriwave.alarm.SNOOZE_AGAIN"
const val ACTION_CANCEL_SNOOZE = "es.freetimelab.pluriwave.alarm.CANCEL_SNOOZE"
const val ACTION_MISSED = "es.freetimelab.pluriwave.alarm.MISSED"
const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction"
@@ -273,5 +275,26 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
/**
* Shared PendingIntent requestCode formula (READ-3/READ-4): kept in
* ONE place so instance call sites (showPreNoticeNotification, which
* resolve this unqualified via companion-member lookup) and
* companion-object call sites ([pendingMissedIntent]) can never
* diverge into two different formulas for the same alarm id.
*/
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
/** Shared PendingIntent factory for the MISSED transition alarm (Decision 3). */
fun pendingMissedIntent(context: Context, alarmId: String, flags: Int): PendingIntent =
PendingIntent.getBroadcast(
context,
requestCode(alarmId, 4),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_MISSED
putExtra(EXTRA_ALARM_ID, alarmId)
},
flags or PendingIntent.FLAG_IMMUTABLE
)
}
}
@@ -48,6 +48,26 @@ class PluriWaveAlarmService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
/**
* Paired-write helper (feedback item, READ-6): the instance-scoped
* [activeAlarmId] and the same-process companion [activeRingingId] must
* always move together -- setting one without the other would let
* [stopActiveVerified] read a stale/wrong ring state. Used at every write
* site instead of assigning each field separately.
*/
private fun setActiveIds(id: String?) {
activeAlarmId = id
activeRingingId = id
}
override fun onCreate() {
super.onCreate()
// Same-process companion instance (feedback item 1, RISK-1/RES-1/REL-2):
// lets stopActiveVerified() call stopEverything() SYNCHRONOUSLY instead
// of trusting an async startService dispatch to have completed.
instance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
@@ -58,6 +78,14 @@ class PluriWaveAlarmService : Service() {
stopAlarm(requestedId)
return START_NOT_STICKY
}
ACTION_STOP_ACTIVE -> {
// Id-agnostic fail-safe stop (Decision 1): silences whatever is
// ringing regardless of the id the caller passed (or omitted).
// Used by the ringing UI and the notification Stop action so a
// stop request can never silently no-op a live ring.
stopEverything()
return START_NOT_STICKY
}
ACTION_SNOOZE -> {
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
if (requestedId != null) {
@@ -92,9 +120,31 @@ class PluriWaveAlarmService : Service() {
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
if (activeAlarmId != null) {
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
// Orphaned firing record fix (RES-2): the newcomer's own firing
// record + auto-silence were already armed by onAlarmFired before
// this refusal, so they must be cleared here or a false MISSED
// fires 10 minutes later for an alarm that never actually rang.
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
return
}
activeAlarmId = alarmId
// onStartCommand re-validation (Decision 4): a redelivered/resurrected
// start for a firing record older than the auto-silence bound must
// never resume audio -- treat it as an already-missed ring instead.
val scheduler = AlarmScheduler(this)
val firingAge = scheduler.firingRecordAgeMillis(alarmId)
if (firingAge != null && firingAge > AlarmScheduler.AUTO_SILENCE_MILLIS) {
Log.w(TAG, "alarm.service startAlarm stale firing record id=$alarmId ageMs=$firingAge; treating as missed")
scheduler.onAlarmMissed(alarmId)
stopSelf()
return
}
// Durable firing record (Decision 4): written before MediaPlayer.start()
// (via startAudio below) so a process death mid-ring leaves proof the
// ring was in flight for the re-validation above / boot cleanup.
scheduler.recordFiring(alarmId)
setActiveIds(alarmId)
// Anchor the fade curve at RING start, not audio start (design D2):
// every source in the 3-stage fallback chain shares this ONE clock,
// so a source that begins mid-fade (e.g. after a station timeout)
@@ -135,7 +185,15 @@ class PluriWaveAlarmService : Service() {
} catch (error: Throwable) {
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
releaseWakeLock()
activeAlarmId = null
// Second documented clear site (feedback item, READ-5): this
// branch never reaches stopEverything(), so without the same
// cleanup below the receiver-armed auto-silence timer + durable
// firing record for alarmId would survive and fire a ghost
// MISSED notification ~10 minutes later for a ring that never
// actually started.
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
setActiveIds(null)
stopSelf()
return
}
@@ -405,8 +463,27 @@ class PluriWaveAlarmService : Service() {
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
)
// Orphaned firing record fix (RES-2): this mismatched id is not
// being torn down by stopEverything() below (that only tears down
// activeAlarmId), so its own firing record + auto-silence must be
// cleared here to avoid a false MISSED 10 minutes later.
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
return
}
stopEverything()
}
/**
* Atomic full teardown (Decision 2, NA "Atomic Stop Coupling"): every stop
* entry point (ACTION_STOP id-match/null, ACTION_STOP_ACTIVE, ACTION_SNOOZE
* via [stopAlarm], onDestroy via [stopAlarm]) funnels through this ONE
* method so no path can perform a partial teardown. Id-agnostic by design:
* it always tears down whatever [activeAlarmId] currently is.
*/
private fun stopEverything() {
val stoppingId = activeAlarmId
cancelStationFallback()
cancelFadeLoop()
try {
@@ -414,15 +491,25 @@ class PluriWaveAlarmService : Service() {
} catch (error: Throwable) {
Log.w(TAG, "alarm.service stop player failed", error)
}
player?.release()
try {
player?.release()
} catch (error: Throwable) {
// Non-atomic release fix (RES-4): a throw here must not abort the
// rest of the teardown below (state reset, wakelock, firing-record
// clear, stopForeground, stopSelf all still need to run).
Log.w(TAG, "alarm.service release player failed", error)
}
player = null
activeAlarmId = null
setActiveIds(null)
releaseWakeLock()
abandonAlarmAudioFocus()
if (alarmId != null) {
if (stoppingId != null) {
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(stoppingId)
)
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(stoppingId)
scheduler.cancelAutoSilence(stoppingId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -480,7 +567,10 @@ class PluriWaveAlarmService : Service() {
this,
requestCode(alarmId, 21),
Intent(this, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP
// Fail-safe fix (feedback item 1, SS-4a/NA-1a): the notification
// Stop action must route through the id-agnostic stop so it can
// never no-op a live ring; the extra id is kept only for logs.
action = ACTION_STOP_ACTIVE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
@@ -595,6 +685,7 @@ class PluriWaveAlarmService : Service() {
override fun onDestroy() {
stopAlarm(activeAlarmId)
if (instance === this) instance = null
super.onDestroy()
}
@@ -608,8 +699,33 @@ class PluriWaveAlarmService : Service() {
private const val KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3"
private const val NOTIFICATION_ID = 92841
const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
const val ACTION_STOP_ACTIVE = "es.freetimelab.pluriwave.alarm.STOP_ACTIVE_NATIVE"
const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
/**
* Same-process companion snapshot (Decision 1): `MainActivity` reads
* this synchronously (no service round-trip) to build a verified stop
* result. Always written together with the instance-scoped
* [activeAlarmId] through the paired [setActiveIds] helper (feedback
* item, READ-6) -- always the id ACTUALLY ringing, never a
* caller-supplied one. Set in [startAlarm]; cleared in TWO documented
* sites -- [stopEverything] (confirmed stop/teardown) AND
* [startAlarm]'s own startForeground-failure catch (feedback item,
* READ-5), which never reaches [stopEverything] but must still clear
* the ids for the ring that never actually started.
*/
@Volatile
var activeRingingId: String? = null
/**
* Same-process companion reference (feedback item 1, RISK-1/RES-1/REL-2):
* set in [onCreate], cleared in [onDestroy]. Lets [stopActiveVerified]
* call [stopEverything] synchronously instead of trusting an async
* startService dispatch to have completed before reporting a result.
*/
@Volatile
private var instance: PluriWaveAlarmService? = null
private const val STATION_START_TIMEOUT_MILLIS = 15_000L
private const val FADE_TICK_MILLIS = 50L
private const val FADE_RANGE_DB = 40.0f
@@ -668,6 +784,47 @@ class PluriWaveAlarmService : Service() {
}
}
/** Id-agnostic fail-safe stop (Decision 1): silences whatever is ringing. */
fun stopActive(context: Context) {
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP_ACTIVE
}
try {
context.startService(intent)
Log.d(TAG, "alarm.service stopActive action requested")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service stopActive request failed", error)
try {
context.stopService(intent)
} catch (fallbackError: Throwable) {
Log.e(TAG, "alarm.service stopActive fallback failed", fallbackError)
}
}
}
/**
* Same-process VERIFIED stop (feedback item 1, RISK-1/RES-1/REL-2):
* fixes the hollow verification where [stopActive]'s async
* startService dispatch made the result a literal `true` decided
* before teardown ran. When a live [instance] exists, invokes
* [stopEverything] on it SYNCHRONOUSLY (the MethodChannel caller and
* this service both run on the main thread of the SAME process, so
* no round trip is needed) and returns whether teardown actually
* cleared [activeRingingId]. Falls back to the async [stopActive]
* dispatch only when no instance is alive -- nothing can be ringing
* without a live instance, so [activeRingingId] is already null and
* the fallback trivially succeeds.
*/
fun stopActiveVerified(context: Context): Boolean {
val current = instance
if (current != null) {
current.stopEverything()
return activeRingingId == null
}
stopActive(context)
return activeRingingId == null
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager