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