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
+32 -1
View File
@@ -39,10 +39,41 @@ describe('native alarm delivery contract', () => {
it('schedules a real alarm clock with AlarmManager.setAlarmClock', () => {
expect(scheduler).toContain('setAlarmClock(');
expect(scheduler).toContain('AlarmClockInfo(');
expect(scheduler).toContain('AlarmActivity.intent(');
expect(scheduler).toContain('canScheduleExactAlarms');
});
it('never makes the alarm clock entry launch an activity', () => {
// Measured on Android 15: a background activity launch from the alarm
// clock's PendingIntent is refused (BAL_BLOCK) and then *deferred*, so the
// window surfaced later — after the alarm had been acknowledged — and the
// siren came back for a delivered alert. The screen is raised by the
// full-screen-intent notification; the entry itself must only wake the
// process.
const fireAt = scheduler.indexOf('fun fireNow(');
const fire = scheduler
.slice(fireAt, fireAt + 2000)
.split('\n')
.filter(line => !line.trimStart().startsWith('//'))
.join('\n');
expect(fire).toContain('PendingIntent.getBroadcast(');
expect(fire).toContain('AlarmWakeReceiver::class.java');
expect(fire).not.toContain('PendingIntent.getActivity(');
expect(fire).not.toContain('AlarmActivity.intent(');
// The one activity PendingIntent left is the alarm-clock *show* intent,
// which only the user can invoke by tapping the status-bar alarm icon.
const showAt = scheduler.indexOf('private fun showIntent(');
const show = scheduler.slice(showAt);
expect(show).toContain('PendingIntent.getActivity(');
const code = scheduler
.split('\n')
.filter(line => !line.trimStart().startsWith('//'))
.join('\n');
expect(code.split('PendingIntent.getActivity(').length - 1).toBe(1);
// The receiver must ignore an entry that outlived the arming.
expect(receiver).toContain('AlarmScheduler.ACTION_FIRE');
expect(receiver).toContain('armed?.alertId != alertId');
});
it('fires the alarm from the trigger frame through the scheduler, not startActivity', () => {
expect(service).toContain('AlarmScheduler.fireNow(');
expect(service).not.toMatch(/startActivity\(AlarmActivity\.intent/);