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
|
||||
|
||||
Reference in New Issue
Block a user