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);
|
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();
|
const d = createDismissals();
|
||||||
d.mark('a', EVENT);
|
d.mark('a', EVENT);
|
||||||
expect(d.suppress('a', EVENT, true)).toBe(false);
|
expect(d.suppress('a', EVENT, true)).toBe(true);
|
||||||
expect(d.suppress('a', EVENT, false)).toBe(false);
|
expect(d.suppress('a', EVENT, false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds from the durable ack queue so a replay before the flush is silent', () => {
|
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', () => {
|
describe('dismissal reaches the service', () => {
|
||||||
const screen = fs.readFileSync(
|
const screen = fs.readFileSync(
|
||||||
path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'),
|
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', () => {
|
it('schedules a real alarm clock with AlarmManager.setAlarmClock', () => {
|
||||||
expect(scheduler).toContain('setAlarmClock(');
|
expect(scheduler).toContain('setAlarmClock(');
|
||||||
expect(scheduler).toContain('AlarmClockInfo(');
|
expect(scheduler).toContain('AlarmClockInfo(');
|
||||||
expect(scheduler).toContain('AlarmActivity.intent(');
|
|
||||||
expect(scheduler).toContain('canScheduleExactAlarms');
|
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', () => {
|
it('fires the alarm from the trigger frame through the scheduler, not startActivity', () => {
|
||||||
expect(service).toContain('AlarmScheduler.fireNow(');
|
expect(service).toContain('AlarmScheduler.fireNow(');
|
||||||
expect(service).not.toMatch(/startActivity\(AlarmActivity\.intent/);
|
expect(service).not.toMatch(/startActivity\(AlarmActivity\.intent/);
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ object AlarmScheduler {
|
|||||||
private const val REQ_SHOW = 4203
|
private const val REQ_SHOW = 4203
|
||||||
|
|
||||||
const val ACTION_STANDING = "com.dcntrlized.alarm.STANDING"
|
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_ALERT_ID = "alarm.scheduler.alertId"
|
||||||
|
const val EXTRA_MESSAGE = "alarm.scheduler.message"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `USE_EXACT_ALARM` (declared in the manifest) makes this true without user
|
* `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
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
|
||||||
return try {
|
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,
|
context,
|
||||||
REQ_FIRE,
|
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
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
am.setAlarmClock(
|
am.setAlarmClock(
|
||||||
@@ -176,10 +199,10 @@ object AlarmScheduler {
|
|||||||
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
||||||
try {
|
try {
|
||||||
am.cancel(
|
am.cancel(
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
REQ_FIRE,
|
REQ_FIRE,
|
||||||
AlarmActivity.intent(context, "", ""),
|
Intent(context, AlarmWakeReceiver::class.java).apply { action = ACTION_FIRE },
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
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}")
|
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 -> {
|
AlarmScheduler.ACTION_STANDING -> {
|
||||||
val armed = AlarmArmStore.current(context) ?: return
|
val armed = AlarmArmStore.current(context) ?: return
|
||||||
// Chain the next wake before doing anything that could fail.
|
// Chain the next wake before doing anything that could fail.
|
||||||
|
|||||||
+17
-12
@@ -11,10 +11,14 @@
|
|||||||
* - a dismissal that could not name one (the native takeover launch carries
|
* - a dismissal that could not name one (the native takeover launch carries
|
||||||
* only the alert) is recorded as `*`: it suppresses the trigger current at
|
* 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
|
* dismissal time, and is pinned to that concrete event as soon as a status
|
||||||
* report reveals it, so a later trigger is not swallowed;
|
* report reveals it;
|
||||||
* - a live socket frame is a genuinely new trigger from the owner (replays of
|
* - a dismissal is **not** undone by the transport. It used to be: a live
|
||||||
* acknowledged deliveries never reach the client), so it clears the memory
|
* socket frame cleared the memory on the theory that a live frame must be a
|
||||||
* for that alert instead of being suppressed.
|
* 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
|
* 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.
|
* queue, and correctness must not depend on storage having finished writing.
|
||||||
@@ -31,8 +35,9 @@ export interface DismissalMemory {
|
|||||||
/**
|
/**
|
||||||
* Should a trigger raise be suppressed?
|
* Should a trigger raise be suppressed?
|
||||||
*
|
*
|
||||||
* `live` frames are new triggers and clear the memory; status/check-in
|
* Suppressed when this device dismissed the alert — whether the raise came
|
||||||
* raises are suppressed when this device already dismissed that event.
|
* 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;
|
suppress(alertId: string, eventId: string | undefined, live: boolean): boolean;
|
||||||
forget(alertId: string): void;
|
forget(alertId: string): void;
|
||||||
@@ -53,14 +58,14 @@ export function createDismissals(): DismissalMemory {
|
|||||||
seen.delete(alertId);
|
seen.delete(alertId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const suppress = (alertId: string, eventId: string | undefined, live: boolean) => {
|
// `live` is accepted for call-site symmetry and deliberately ignored: the
|
||||||
if (live) {
|
// transport a frame arrived on says nothing about whether a human dismissed
|
||||||
forget(alertId);
|
// the alarm (see the header comment).
|
||||||
return false;
|
const suppress = (alertId: string, eventId: string | undefined, _live: boolean) => {
|
||||||
}
|
|
||||||
const set = seen.get(alertId);
|
const set = seen.get(alertId);
|
||||||
if (!set) return false;
|
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;
|
if (!set.has('*')) return false;
|
||||||
// Pin the anonymous dismissal to the event it actually covered; a later
|
// Pin the anonymous dismissal to the event it actually covered; a later
|
||||||
// trigger carries a different id and must ring.
|
// trigger carries a different id and must ring.
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ const ENDING_COPY: Record<EndingReason, { title: string; body: string }> = {
|
|||||||
title: 'This alert was deleted',
|
title: 'This alert was deleted',
|
||||||
body: 'The alert and its tokens are gone.',
|
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() {
|
export default function AlertDetailScreen() {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const ENDING_COPY: Record<NonNullable<ReturnType<typeof useAlarm>['lastEnding']>
|
|||||||
expired: 'Expired — its lifetime ran out.',
|
expired: 'Expired — its lifetime ran out.',
|
||||||
disarmed: 'Ended — you disarmed it.',
|
disarmed: 'Ended — you disarmed it.',
|
||||||
deleted: 'Deleted.',
|
deleted: 'Deleted.',
|
||||||
|
left: 'Acknowledged — this device unsubscribed.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
|
|||||||
@@ -161,10 +161,23 @@ export default function LiveAlarmScreen() {
|
|||||||
};
|
};
|
||||||
}, [fade, shake, reduceMotion]);
|
}, [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
|
// 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
|
// refuses the alarm stream, the only thing that can still reach the user is
|
||||||
// the volume warning — the screen says so instead of pretending.
|
// the volume warning — the screen says so instead of pretending.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!live) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = await playAlarm(alertId, message ?? '');
|
const res = await playAlarm(alertId, message ?? '');
|
||||||
@@ -176,7 +189,7 @@ export default function LiveAlarmScreen() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
stopAlarm();
|
stopAlarm();
|
||||||
};
|
};
|
||||||
}, [alertId, message]);
|
}, [alertId, message, live]);
|
||||||
|
|
||||||
const leave = useCallback(() => {
|
const leave = useCallback(() => {
|
||||||
clearLiveEvent();
|
clearLiveEvent();
|
||||||
@@ -277,7 +290,7 @@ export default function LiveAlarmScreen() {
|
|||||||
{/* The alert name is the headline: at the moment of maximum stress this
|
{/* The alert name is the headline: at the moment of maximum stress this
|
||||||
is the one piece of information the user needs (D-5). */}
|
is the one piece of information the user needs (D-5). */}
|
||||||
<AppText style={styles.name} numberOfLines={2}>
|
<AppText style={styles.name} numberOfLines={2}>
|
||||||
{alert?.name ?? alertId}
|
{alert?.name ?? 'Alarm'}
|
||||||
</AppText>
|
</AppText>
|
||||||
<AppText style={styles.owner}>{attribution}</AppText>
|
<AppText style={styles.owner}>{attribution}</AppText>
|
||||||
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
|
<AppText style={styles.elapsed} accessibilityLabel={absoluteLabel}>
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ export interface PendingAck {
|
|||||||
* could not be named would otherwise replay forever.
|
* could not be named would otherwise replay forever.
|
||||||
*/
|
*/
|
||||||
eventId?: string;
|
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. */
|
/** Subscriber credential for the alert (rev 10); absent for accounts. */
|
||||||
deviceToken?: string;
|
deviceToken?: string;
|
||||||
}
|
}
|
||||||
@@ -50,7 +56,8 @@ function parse(raw: string | null): PendingAck[] {
|
|||||||
typeof (v as PendingAck).alertId === 'string' &&
|
typeof (v as PendingAck).alertId === 'string' &&
|
||||||
((v as PendingAck).eventId === undefined ||
|
((v as PendingAck).eventId === undefined ||
|
||||||
typeof (v as PendingAck).eventId === 'string') &&
|
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 ||
|
((v as PendingAck).deviceToken === undefined ||
|
||||||
typeof (v as PendingAck).deviceToken === 'string'),
|
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
|
* Send every queued ack. Stops on the first transport/server failure (the queue
|
||||||
* is retried later); drops entries on success and on 404 ("settled").
|
* 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();
|
const list = await loadAcks();
|
||||||
for (const ack of list) {
|
for (const ack of list) {
|
||||||
try {
|
try {
|
||||||
await ackAlarm(ack);
|
await ackAlarm({ ...ack, deviceId: ack.deviceId ?? fallbackDeviceId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 404: the alert is gone. 401 for a device token: the alert was destroyed
|
// 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
|
// by the very ack that preceded this retry, so the credential no longer
|
||||||
|
|||||||
+68
-12
@@ -26,7 +26,13 @@ const ADMIN_ALERTS_KEY = 'alarm.adminAlerts.v1';
|
|||||||
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
|
const SUBSCRIBED_ALERTS_KEY = 'alarm.subscribedAlerts.v1';
|
||||||
|
|
||||||
/** Why an alert no longer exists. Each ending has its own explicit copy. */
|
/** 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 {
|
export interface LiveAlarmEvent {
|
||||||
alertId: string;
|
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.
|
* dismissal time and is replaced by the concrete id as soon as one is known.
|
||||||
*/
|
*/
|
||||||
const dismissals = useRef(createDismissals());
|
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) => {
|
const markDismissed = useCallback((alertId: string, eventId?: string | null) => {
|
||||||
dismissals.current.mark(alertId, eventId);
|
dismissals.current.mark(alertId, eventId);
|
||||||
@@ -148,7 +163,8 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAcks()
|
loadAcks()
|
||||||
.then(list => dismissals.current.seed(list))
|
.then(list => dismissals.current.seed(list))
|
||||||
.catch(() => {});
|
.catch(() => {})
|
||||||
|
.finally(() => setDismissalsReady(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// A per-install device id: stable across launches, unique per device. It
|
// 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]);
|
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 clearLastEnding = useCallback(() => setLastEnding(null), []);
|
||||||
|
|
||||||
const handleTrigger = useCallback(
|
const handleTrigger = useCallback(
|
||||||
(frame: { alertId: string; eventId?: string; message?: string; serverTime?: string }, live: boolean) => {
|
(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"
|
// Dismissed on this device: the commanded status keeps saying "triggered"
|
||||||
// until the owner disarms or everybody acks, so a status-driven raise for
|
// until the owner disarms or everybody acks, so a status-driven raise for
|
||||||
// the same event is not news. See lib/dismissals.ts.
|
// 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);
|
const armedAttemptRef = useRef<{ alertId: string; at: number } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAlarmNativeAvailable) return;
|
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;
|
if (!resolved || !online || !activeAlert?.subscribeToken) return;
|
||||||
// The service holds one credential and cannot refresh an expiring access
|
// The service holds one credential and cannot refresh an expiring access
|
||||||
// token, so this path is the device-token one by construction. An
|
// token, so this path is the device-token one by construction. An
|
||||||
@@ -404,6 +437,7 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
activeAlert,
|
activeAlert,
|
||||||
backgroundListening?.alertId,
|
backgroundListening?.alertId,
|
||||||
backgroundListening?.state,
|
backgroundListening?.state,
|
||||||
|
dismissalsReady,
|
||||||
online,
|
online,
|
||||||
refreshBackgroundListening,
|
refreshBackgroundListening,
|
||||||
resolved,
|
resolved,
|
||||||
@@ -551,7 +585,6 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
*/
|
*/
|
||||||
const acknowledgeAlarm = useCallback(
|
const acknowledgeAlarm = useCallback(
|
||||||
async (alertId: string, eventId: string | undefined) => {
|
async (alertId: string, eventId: string | undefined) => {
|
||||||
if (!deviceId) return;
|
|
||||||
// A dismissal whose event is unknown (the native takeover launch carries
|
// A dismissal whose event is unknown (the native takeover launch carries
|
||||||
// only the alert) still names the trigger the status loop last reported;
|
// only the alert) still names the trigger the status loop last reported;
|
||||||
// otherwise the service clears this subscription's whole outstanding set.
|
// 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
|
// A subscriber acks with the device token it joined with; an account acks
|
||||||
// with its session token (the api client picks the right header).
|
// with its session token (the api client picks the right header).
|
||||||
const deviceToken = subscribedAlerts.find(a => a.alertId === alertId)?.deviceToken;
|
const deviceToken = subscribedAlerts.find(a => a.alertId === alertId)?.deviceToken;
|
||||||
await enqueueAck({ alertId, eventId: known, deviceId, deviceToken });
|
// The device id is optional: a device-token ack identifies the
|
||||||
await flushAcks();
|
// 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
|
// Retry the durable queue on every plausible reconnect point, plus a slow
|
||||||
// interval so a queue that could not flush at dismiss cannot linger.
|
// interval so a queue that could not flush at dismiss cannot linger.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
flushAcks().catch(() => {});
|
flushAcks(deviceId ?? undefined).catch(() => {});
|
||||||
const sub = AppState.addEventListener('change', next => {
|
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 () => {
|
return () => {
|
||||||
sub.remove();
|
sub.remove();
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [deviceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (socketState === 'open') flushAcks().catch(() => {});
|
if (socketState === 'open') flushAcks(deviceId ?? undefined).catch(() => {});
|
||||||
}, [socketState]);
|
}, [socketState, deviceId]);
|
||||||
|
|
||||||
const value: AlarmContextValue = {
|
const value: AlarmContextValue = {
|
||||||
adminAlerts,
|
adminAlerts,
|
||||||
|
|||||||
Reference in New Issue
Block a user