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
+65 -3
View File
@@ -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'),
+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/);
@@ -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.
+17 -12
View File
@@ -11,10 +11,14 @@
* - a dismissal that could not name one (the native takeover launch carries
* only the alert) is recorded as `*`: it suppresses the trigger current at
* dismissal time, and is pinned to that concrete event as soon as a status
* report reveals it, so a later trigger is not swallowed;
* - a live socket frame is a genuinely new trigger from the owner (replays of
* acknowledged deliveries never reach the client), so it clears the memory
* for that alert instead of being suppressed.
* report reveals it;
* - a dismissal is **not** undone by the transport. It used to be: a live
* socket frame cleared the memory on the theory that a live frame must be a
* new trigger. That is wrong on this service — an alert is triggered once
* and then disarmed or delivered, so a live frame for a dismissed alert is
* the same alarm arriving down another pipe (measured: after dismissing, the
* deferred native takeover replayed a frame and the siren came back). The
* only thing that clears the memory is the alert ending (`forget`).
*
* Pure and in-memory on purpose: the durable part of a dismissal is the ack
* queue, and correctness must not depend on storage having finished writing.
@@ -31,8 +35,9 @@ export interface DismissalMemory {
/**
* Should a trigger raise be suppressed?
*
* `live` frames are new triggers and clear the memory; status/check-in
* raises are suppressed when this device already dismissed that event.
* Suppressed when this device dismissed the alert — whether the raise came
* from a status report or the socket (`live` only changes where the frame
* came from, never whether the user already dismissed it).
*/
suppress(alertId: string, eventId: string | undefined, live: boolean): boolean;
forget(alertId: string): void;
@@ -53,14 +58,14 @@ export function createDismissals(): DismissalMemory {
seen.delete(alertId);
};
const suppress = (alertId: string, eventId: string | undefined, live: boolean) => {
if (live) {
forget(alertId);
return false;
}
// `live` is accepted for call-site symmetry and deliberately ignored: the
// transport a frame arrived on says nothing about whether a human dismissed
// the alarm (see the header comment).
const suppress = (alertId: string, eventId: string | undefined, _live: boolean) => {
const set = seen.get(alertId);
if (!set) return false;
if (eventId && set.has(eventId)) return true;
if (!eventId) return true;
if (set.has(eventId)) return true;
if (!set.has('*')) return false;
// Pin the anonymous dismissal to the event it actually covered; a later
// trigger carries a different id and must ring.
+4
View File
@@ -103,6 +103,10 @@ const ENDING_COPY: Record<EndingReason, { title: string; body: string }> = {
title: 'This alert was deleted',
body: 'The alert and its tokens are gone.',
},
left: {
title: 'You left this alert',
body: 'This device acknowledged the alarm and unsubscribed, so it will not ring here again. The alert may still be live for everyone else.',
},
};
export default function AlertDetailScreen() {
+1
View File
@@ -64,6 +64,7 @@ const ENDING_COPY: Record<NonNullable<ReturnType<typeof useAlarm>['lastEnding']>
expired: 'Expired — its lifetime ran out.',
disarmed: 'Ended — you disarmed it.',
deleted: 'Deleted.',
left: 'Acknowledged — this device unsubscribed.',
};
export default function HomeScreen() {
+15 -2
View File
@@ -161,10 +161,23 @@ export default function LiveAlarmScreen() {
};
}, [fade, shake, reduceMotion]);
/**
* Is there an alarm to sound at all?
*
* A route can be opened for an alert that no longer exists: the native
* launch carries only an id, and on the way out of an alarm the platform can
* still hand the window a stale intent. Ringing for it is the "siren comes
* back after dismissing, showing the alert id" failure, so playback waits for
* evidence that the alarm is live — either the alert this device follows, or
* a trigger event that actually arrived.
*/
const live = !!alert || event?.action === 'trigger';
// Native takeover: max-volume STREAM_ALARM playback + vibration. When the OS
// refuses the alarm stream, the only thing that can still reach the user is
// the volume warning — the screen says so instead of pretending.
useEffect(() => {
if (!live) return;
let cancelled = false;
(async () => {
const res = await playAlarm(alertId, message ?? '');
@@ -176,7 +189,7 @@ export default function LiveAlarmScreen() {
cancelled = true;
stopAlarm();
};
}, [alertId, message]);
}, [alertId, message, live]);
const leave = useCallback(() => {
clearLiveEvent();
@@ -277,7 +290,7 @@ export default function LiveAlarmScreen() {
{/* The alert name is the headline: at the moment of maximum stress this
is the one piece of information the user needs (D-5). */}
<AppText style={styles.name} numberOfLines={2}>
{alert?.name ?? alertId}
{alert?.name ?? 'Alarm'}
</AppText>
<AppText style={styles.owner}>{attribution}</AppText>
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
+11 -4
View File
@@ -25,7 +25,13 @@ export interface PendingAck {
* could not be named would otherwise replay forever.
*/
eventId?: string;
deviceId: string;
/**
* The caller's device id. Absent when the dismissal happened before the
* identity was loaded — the service accepts a device-token ack without it
* (measured), and dropping the entry instead is how a dismissed alarm came
* back after the app was closed.
*/
deviceId?: string;
/** Subscriber credential for the alert (rev 10); absent for accounts. */
deviceToken?: string;
}
@@ -50,7 +56,8 @@ function parse(raw: string | null): PendingAck[] {
typeof (v as PendingAck).alertId === 'string' &&
((v as PendingAck).eventId === undefined ||
typeof (v as PendingAck).eventId === 'string') &&
typeof (v as PendingAck).deviceId === 'string' &&
((v as PendingAck).deviceId === undefined ||
typeof (v as PendingAck).deviceId === 'string') &&
((v as PendingAck).deviceToken === undefined ||
typeof (v as PendingAck).deviceToken === 'string'),
);
@@ -88,11 +95,11 @@ export async function dropAck(alertId: string, eventId?: string): Promise<void>
* Send every queued ack. Stops on the first transport/server failure (the queue
* is retried later); drops entries on success and on 404 ("settled").
*/
export async function flushAcks(): Promise<void> {
export async function flushAcks(fallbackDeviceId?: string): Promise<void> {
const list = await loadAcks();
for (const ack of list) {
try {
await ackAlarm(ack);
await ackAlarm({ ...ack, deviceId: ack.deviceId ?? fallbackDeviceId });
} catch (e) {
// 404: the alert is gone. 401 for a device token: the alert was destroyed
// by the very ack that preceded this retry, so the credential no longer
+68 -12
View File
@@ -26,7 +26,13 @@ const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
/** Why an alert no longer exists. Each ending has its own explicit copy. */
export type EndingReason = 'delivered' | 'expired' | 'disarmed' | 'deleted';
export type EndingReason =
| 'delivered'
| 'expired'
| 'disarmed'
| 'deleted'
/** This device acknowledged and left the alert (a dismissal *is* leaving). */
| 'left';
export interface LiveAlarmEvent {
alertId: string;
@@ -138,6 +144,15 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
* dismissal time and is replaced by the concrete id as soon as one is known.
*/
const dismissals = useRef(createDismissals());
/**
* Whether the durable queue has been read into [dismissals].
*
* A raise must wait for it: a dismissal that is still queued (the app was
* killed before the ack reached the service) is the only record that this
* device already dismissed, and raising before the seed lands rings the alarm
* the user just dismissed — measured as "reopening the app retriggers it".
*/
const [dismissalsReady, setDismissalsReady] = useState(false);
const markDismissed = useCallback((alertId: string, eventId?: string | null) => {
dismissals.current.mark(alertId, eventId);
@@ -148,7 +163,8 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
loadAcks()
.then(list => dismissals.current.seed(list))
.catch(() => {});
.catch(() => {})
.finally(() => setDismissalsReady(true));
}, []);
// A per-install device id: stable across launches, unique per device. It
@@ -242,10 +258,23 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
);
const endingFor = useCallback((alertId: string) => endings[alertId] ?? null, [endings]);
// Read by `handleTrigger`, which must not depend on the render that scheduled
// it: an alert this device saw end can never be raised again.
const endingsRef = useRef(endings);
endingsRef.current = endings;
const dismissalsReadyRef = useRef(dismissalsReady);
dismissalsReadyRef.current = dismissalsReady;
const clearLastEnding = useCallback(() => setLastEnding(null), []);
const handleTrigger = useCallback(
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string }, live: boolean) => {
// Ended on this device (delivered, disarmed, expired). The alert is gone,
// so any frame that still names it is the tail of that alarm — the
// service's own status keeps answering for up to a poll, and a queued
// native launch can replay it. Raising it again is how a dismissed alarm
// came back with no title to show.
if (!dismissalsReadyRef.current) return;
if (endingsRef.current[frame.alertId]) return;
// Dismissed on this device: the commanded status keeps saying "triggered"
// until the owner disarms or everybody acks, so a status-driven raise for
// the same event is not news. See lib/dismissals.ts.
@@ -362,6 +391,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
const armedAttemptRef = useRef<{ alertId: string; at: number } | null>(null);
useEffect(() => {
if (!isAlarmNativeAvailable) return;
// Never start listening before the dismissals this device already made are
// known: the service's own check-in rings on whatever the ledger still owes,
// and an unsent dismissal would make it ring for an alarm just dismissed.
if (!dismissalsReady) return;
if (!resolved || !online || !activeAlert?.subscribeToken) return;
// The service holds one credential and cannot refresh an expiring access
// token, so this path is the device-token one by construction. An
@@ -404,6 +437,7 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
activeAlert,
backgroundListening?.alertId,
backgroundListening?.state,
dismissalsReady,
online,
refreshBackgroundListening,
resolved,
@@ -551,7 +585,6 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
*/
const acknowledgeAlarm = useCallback(
async (alertId: string, eventId: string | undefined) => {
if (!deviceId) return;
// A dismissal whose event is unknown (the native takeover launch carries
// only the alert) still names the trigger the status loop last reported;
// otherwise the service clears this subscription's whole outstanding set.
@@ -560,29 +593,52 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
// A subscriber acks with the device token it joined with; an account acks
// with its session token (the api client picks the right header).
const deviceToken = subscribedAlerts.find(a => a.alertId === alertId)?.deviceToken;
await enqueueAck({ alertId, eventId: known, deviceId, deviceToken });
await flushAcks();
// The device id is optional: a device-token ack identifies the
// subscription by its credential alone (measured against the service), and
// this used to `return` when the identity had not loaded yet — dropping the
// ack that keeps the alarm from ringing again after a restart.
await enqueueAck({ alertId, eventId: known, deviceId: deviceId ?? undefined, deviceToken });
await flushAcks(deviceId ?? undefined);
// Dismissing is leaving: a subscriber that acknowledged has no reason to
// stay on the alert, and every leftover subscription is a live address
// that can ring again — the reported "it fires again when I reopen the
// app unless I unsubscribe". The ack settles the debt first (so the
// ledger is clean even if the removal fails), then the row goes.
const subscription = subscribedAlerts.find(a => a.alertId === alertId);
if (subscription?.deviceToken) {
try {
await api.removeSubscription(
{ alertId, subscriptionId: subscription.subscriptionId ?? '' },
subscription.deviceToken,
);
} catch {
// Best effort: the debt is settled either way, so a failure only
// leaves a stale row (visible in "Subscribed devices") that the user
// can still remove from the alert. Never resurrect the alarm over it.
}
markEnded(alertId, 'left').catch(() => {});
}
},
[deviceId, lastStatus?.eventId, markDismissed, subscribedAlerts],
[deviceId, lastStatus?.eventId, markDismissed, markEnded, subscribedAlerts],
);
// Retry the durable queue on every plausible reconnect point, plus a slow
// interval so a queue that could not flush at dismiss cannot linger.
useEffect(() => {
flushAcks().catch(() => {});
flushAcks(deviceId ?? undefined).catch(() => {});
const sub = AppState.addEventListener('change', next => {
if (next === 'active') flushAcks().catch(() => {});
if (next === 'active') flushAcks(deviceId ?? undefined).catch(() => {});
});
const timer = setInterval(() => flushAcks().catch(() => {}), 30_000);
const timer = setInterval(() => flushAcks(deviceId ?? undefined).catch(() => {}), 30_000);
return () => {
sub.remove();
clearInterval(timer);
};
}, []);
}, [deviceId]);
useEffect(() => {
if (socketState === 'open') flushAcks().catch(() => {});
}, [socketState]);
if (socketState === 'open') flushAcks(deviceId ?? undefined).catch(() => {});
}, [socketState, deviceId]);
const value: AlarmContextValue = {
adminAlerts,