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:
@@ -56,11 +56,14 @@ describe('dismissal memory', () => {
|
||||
expect(d.suppress('a', undefined, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a live socket frame clear the memory — a new trigger must ring', () => {
|
||||
it('does not let a live socket frame undo a dismissal', () => {
|
||||
// Measured: after dismissing, a queued native takeover replayed the trigger
|
||||
// down the socket and the siren came back. The alert is triggered once, so
|
||||
// a second "live" frame for a dismissed alert is the same alarm.
|
||||
const d = createDismissals();
|
||||
d.mark('a', EVENT);
|
||||
expect(d.suppress('a', EVENT, true)).toBe(false);
|
||||
expect(d.suppress('a', EVENT, false)).toBe(false);
|
||||
expect(d.suppress('a', EVENT, true)).toBe(true);
|
||||
expect(d.suppress('a', EVENT, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('seeds from the durable ack queue so a replay before the flush is silent', () => {
|
||||
@@ -74,6 +77,65 @@ describe('dismissal memory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a dismissal must survive the app being killed', () => {
|
||||
it('queues the ack even before the device identity is loaded', () => {
|
||||
// Measured against the service: a device-token ack with no deviceId is
|
||||
// accepted (the credential identifies the subscription). `acknowledgeAlarm`
|
||||
// used to `return` when `deviceId` was still null, silently dropping the
|
||||
// dismissal — the alarm then owed the ledger again and rang on reopen.
|
||||
const context = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const at = context.indexOf('const acknowledgeAlarm = useCallback');
|
||||
const body = context.slice(at, at + 1600);
|
||||
expect(body).not.toContain('if (!deviceId) return;');
|
||||
expect(body).toContain('enqueueAck(');
|
||||
expect(body).toContain('deviceId: deviceId ?? undefined');
|
||||
});
|
||||
|
||||
it('does not raise an alarm before the queued dismissals are known', () => {
|
||||
const context = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const at = context.indexOf('const handleTrigger = useCallback');
|
||||
const body = context.slice(at, at + 900);
|
||||
expect(body).toContain('dismissalsReadyRef.current');
|
||||
// …and the armed service must not start listening before that either.
|
||||
const armAt = context.indexOf('const armedAttemptRef');
|
||||
const arm = context.slice(armAt, armAt + 900);
|
||||
expect(arm).toContain('if (!dismissalsReady) return;');
|
||||
});
|
||||
|
||||
it('unsubscribes the device as part of acknowledging', () => {
|
||||
// Owner ruling: a subscriber that acknowledged is done with the alarm, so
|
||||
// the acknowledgement drops the subscription instead of leaving a live
|
||||
// address that rings again on the next launch.
|
||||
const context = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const at = context.indexOf('const acknowledgeAlarm = useCallback');
|
||||
const body = context.slice(at, at + 2600);
|
||||
expect(body).toContain('api.removeSubscription(');
|
||||
expect(body).toContain("markEnded(alertId, 'left')");
|
||||
// The ack settles the ledger *before* the row goes, so a failed removal can
|
||||
// never leave the debt behind.
|
||||
expect(body.indexOf('flushAcks(')).toBeLessThan(body.indexOf('api.removeSubscription('));
|
||||
});
|
||||
|
||||
it('flushes a queued ack that has no device id of its own', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'services', 'pendingAcks.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(source).toContain('deviceId?: string;');
|
||||
expect(source).toContain('flushAcks(fallbackDeviceId?: string)');
|
||||
expect(source).toContain('ack.deviceId ?? fallbackDeviceId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissal reaches the service', () => {
|
||||
const screen = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'),
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user