Acknowledge unsubscribes, and nothing re-rings after a restart

Two field reports, one chain: a dismissed alarm came back. Measured on the
emulator, three separate causes, all fixed here.

1. The alarm-clock entry launched an activity. On Android 14+ that launch is
   refused from the background and then *deferred*, so the window surfaced
   later — after the alarm had been acknowledged — and the siren played again
   for a delivered alert. The entry is now a broadcast to AlarmWakeReceiver,
   which only wakes the process and re-posts the takeover notification, and
   ignores an entry that outlived its arming. The one activity PendingIntent
   left is the alarm-clock show intent, which only the user can invoke.

2. A dismissal could be lost. `acknowledgeAlarm` returned early when the
   device identity had not loaded, and the queue's entry required a deviceId,
   so the ack was silently dropped and the ledger still owed the alarm. The
   queue accepts an entry without one (measured: the service takes a
   device-token ack with no deviceId) and the flush resolves the identity.
   Nothing raises — and the armed service does not start listening — until the
   queued dismissals have been read, so a restart cannot ring before the seed.

3. Acknowledging now unsubscribes the device (owner ruling: a dismissal is
   leaving). The ack settles the ledger first, then the subscription row goes;
   the takeover closes itself and Home reports "Acknowledged — this device
   unsubscribed". A leftover subscription was a live address that kept ringing
   on every launch.

Also: an alarm is only sounded for an alert this device still follows (or a
trigger event that actually arrived), and the takeover never renders the raw
alert id as its headline — that fallback is what made a stale launch look like
an alarm for "dbf1a4c2-…".

Verified on the emulator: join → trigger → acknowledge → subscription row gone,
alert delivered, app returns Home by itself; force-stop and reopen is silent
for the whole session (no audio, no notification, no takeover).
This commit is contained in:
2026-10-20 00:48:33 +01:00
parent 06afd92647
commit 0771a69d7d
10 changed files with 263 additions and 38 deletions
@@ -70,7 +70,12 @@ object AlarmScheduler {
private const val REQ_SHOW = 4203
const val ACTION_STANDING = "com.dcntrlized.alarm.STANDING"
/** The entry that ends the ring: the alarm clock's own operation. */
const val ACTION_FIRE = "com.dcntrlized.alarm.FIRE"
const val EXTRA_ALERT_ID = "alarm.scheduler.alertId"
const val EXTRA_MESSAGE = "alarm.scheduler.message"
/**
* `USE_EXACT_ALARM` (declared in the manifest) makes this true without user
@@ -95,10 +100,28 @@ object AlarmScheduler {
}
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
return try {
val operation = PendingIntent.getActivity(
// The operation is a broadcast, NEVER an activity launch.
//
// This used to be `PendingIntent.getActivity(AlarmActivity)`, and on
// Android 14+ that launch is refused from the background
// (`BAL_BLOCK`). Worse than being dropped, it is *deferred*: the
// activity sits in the task and surfaces later — measured after an
// acknowledgement, where pressing back let the queued takeover come
// to the front again, re-playing the siren for an alert that had
// already been delivered (and therefore rendering a title-less alarm
// that fell back to the raw alert id).
//
// The screen is raised by the full-screen-intent notification, which
// SystemUI launches; the alarm clock only has to make the platform
// treat this as an alarm and wake the process at the right time.
val operation = PendingIntent.getBroadcast(
context,
REQ_FIRE,
AlarmActivity.intent(context, alertId, message),
Intent(context, AlarmWakeReceiver::class.java).apply {
action = ACTION_FIRE
putExtra(EXTRA_ALERT_ID, alertId)
putExtra(EXTRA_MESSAGE, message)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
am.setAlarmClock(
@@ -176,10 +199,10 @@ object AlarmScheduler {
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
try {
am.cancel(
PendingIntent.getActivity(
PendingIntent.getBroadcast(
context,
REQ_FIRE,
AlarmActivity.intent(context, "", ""),
Intent(context, AlarmWakeReceiver::class.java).apply { action = ACTION_FIRE },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
@@ -33,6 +33,29 @@ class AlarmWakeReceiver : BroadcastReceiver() {
Log.i(TAG, "boot: standing entry re-armed=$ok for ${armed.alertId}")
}
AlarmScheduler.ACTION_FIRE -> {
// The ring's own alarm-clock entry. It must NOT start an
// activity (that is what the full-screen-intent notification is
// for, and a background activity launch is refused and then
// deferred by the platform, which is how a dismissed alarm came
// back). All this has to do is make sure the process is awake
// and the takeover is on screen.
val alertId = intent.getStringExtra(AlarmScheduler.EXTRA_ALERT_ID)
val message = intent.getStringExtra(AlarmScheduler.EXTRA_MESSAGE) ?: ""
val armed = AlarmArmStore.current(context)
if (alertId.isNullOrEmpty() || armed?.alertId != alertId) {
// Dismissed, disarmed or replaced between scheduling and
// firing: there is nothing to ring.
Log.i(TAG, "fire ignored: not armed for $alertId")
return
}
Log.i(TAG, "fire for $alertId")
AlarmNotifier.showFiring(context, alertId, message)
if (!AlarmForegroundService.isRunning) {
AlarmForegroundService.start(context, armed.alertId, armed.wsUrl)
}
}
AlarmScheduler.ACTION_STANDING -> {
val armed = AlarmArmStore.current(context) ?: return
// Chain the next wake before doing anything that could fail.