Files
alarm-client/__tests__/nativeDelivery.test.ts
Admin 0771a69d7d 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).
2026-10-20 00:48:33 +01:00

205 lines
9.9 KiB
TypeScript

/* The app's tsconfig carries only jest types, so read sources through jest's
own real-module loader rather than widening the compiler config for a test. */
declare const __dirname: string;
const nodeFs = jest.requireActual('fs') as {
readFileSync(path: string, encoding: string): string;
};
const nodePath = jest.requireActual('path') as { join(...parts: string[]): string };
/**
* Source-level contract checks for the native alarm delivery path.
*
* Kotlin behaviour cannot be executed in this Jest run (no Android runtime, no
* device), so these assertions pin the *structure* the design depends on:
* delivery goes through `AlarmManager.setAlarmClock`, there is no
* notification-based delivery path, the standing entry is cancelled when the
* device stops following an alert, and the boot receiver re-arms it.
*
* They are deliberately narrow. They prove the calls exist; they cannot prove
* that Android accepts them, nor that scheduling lifts the full-screen-intent
* restriction. Those are listed as unverified in the report.
*/
const NATIVE = nodePath.join(__dirname, '..', 'android', 'app', 'src', 'main', 'java', 'com', 'dcntrlized', 'alarm');
const MANIFEST = nodePath.join(__dirname, '..', 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
function native(file: string): string {
return nodeFs.readFileSync(nodePath.join(NATIVE, file), 'utf8');
}
describe('native alarm delivery contract', () => {
const scheduler = native('AlarmScheduler.kt');
const service = native('AlarmForegroundService.kt');
const notifier = native('AlarmPlayer.kt');
const receiver = native('AlarmWakeReceiver.kt');
const bridge = native('AlarmModule.kt');
const player = native('AlarmPlayer.kt');
const manifest = nodeFs.readFileSync(MANIFEST, 'utf8');
it('schedules a real alarm clock with AlarmManager.setAlarmClock', () => {
expect(scheduler).toContain('setAlarmClock(');
expect(scheduler).toContain('AlarmClockInfo(');
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/);
// A failed schedule is recorded, never replaced by a notification.
expect(service).toContain('markDeliveryFailed');
});
it('holds a standing alarm-clock entry while armed and re-arms after reboot', () => {
expect(service).toContain('AlarmScheduler.holdStanding(');
expect(receiver).toContain('ACTION_BOOT_COMPLETED');
expect(receiver).toContain('rearmAfterBoot');
expect(receiver).toContain('ACTION_STANDING');
expect(receiver).toContain('AlarmScheduler.holdStanding(');
});
it('cancels the entries and forgets the arm record on stop', () => {
expect(service).toContain('AlarmScheduler.cancel(');
expect(service).toContain('AlarmArmStore.clear(');
});
it('keeps the standing entry across a disarm — only the firing entry dies', () => {
const disarmAt = service.indexOf('"disarm"');
expect(disarmAt).toBeGreaterThan(-1);
const disarm = service.slice(disarmAt, disarmAt + 500);
expect(disarm).toContain('AlarmScheduler.cancelFire(');
// A disarm ends the ring, not the subscription: cancelling the standing
// entry here would drop the "armed" indicator and the re-arm point.
expect(disarm).not.toContain('AlarmScheduler.cancel(');
// cancelFire must not touch the standing entry either.
const cancelFireAt = scheduler.indexOf('fun cancelFire(');
const cancelFireBody = scheduler.slice(cancelFireAt, cancelFireAt + 800);
expect(cancelFireBody).not.toContain('REQ_STANDING');
expect(cancelFireBody).toContain('REQ_FIRE');
});
// The old contract said "no notification ever announces an alarm" — that was
// falsified on an Android 15 emulator: the alarm's own PendingIntent cannot
// start the takeover from the background (`BAL_BLOCK`), so the ringing alarm
// needs the one notification the platform does launch for you, a full-screen
// intent. Delivery is still the alarm clock + the service's own audio stream;
// the notification is only the launch path for the screen.
it('has three channels, and exactly one full-screen-intent notification', () => {
const channels = notifier.split('= NotificationChannel(').length - 1;
expect(channels).toBe(3);
expect(notifier).toContain('CHANNEL_VOLUME');
expect(notifier).toContain('CHANNEL_SERVICE');
expect(notifier).toContain('CHANNEL_FIRING');
const fsi = notifier.split('setFullScreenIntent').length - 1;
expect(fsi).toBe(1);
// The firing channel is silent: the siren is our own audio stream, and a
// channel sound on top of it would double the alarm.
const firingAt = notifier.indexOf('CHANNEL_FIRING,');
expect(notifier.slice(firingAt, firingAt + 400)).toContain('setSound(null, null)');
});
it('keeps the volume warning informational and never arms from a notification', () => {
expect(notifier).toContain('Alarm volume is at ');
expect(notifier).toContain('CATEGORY_RECOMMENDATION');
// The volume warning must not carry the takeover: only the firing
// notification may take over the screen.
const warningAt = notifier.indexOf('fun showVolumeWarning');
expect(notifier.slice(warningAt, warningAt + 1200)).not.toContain('setFullScreenIntent');
});
it('refuses to arm a device that cannot deliver a real alarm', () => {
expect(bridge).toContain('exact-alarms-not-granted');
expect(bridge).toContain('full-screen-intent-not-granted');
});
it('uses the specialUse foreground-service type with a real subtype', () => {
// dataSync's 6h/24h cumulative cap is why the primary type changed; the
// subtype text is the reason shown for a specialUse service.
expect(manifest).toContain('android:foregroundServiceType="dataSync|specialUse"');
expect(manifest).toContain('PROPERTY_SPECIAL_USE_FGS_SUBTYPE');
expect(manifest).toContain('android.permission.FOREGROUND_SERVICE_SPECIAL_USE');
expect(manifest).not.toContain('FOREGROUND_SERVICE_MEDIA_PLAYBACK');
expect(service).toContain('FOREGROUND_SERVICE_TYPE_SPECIAL_USE');
// Kept as defence in depth even though specialUse has no time limit today.
expect(service).toContain('override fun onTimeout(');
});
it('schedules close to now instead of rounding up to a future bucket', () => {
// The answer to "31st second or next minute" is the 31st second: the
// trigger time is now + a small margin, never a rounded tick.
const match = /FIRE_DELAY_MS\s*=\s*(\d+)L/.exec(scheduler);
expect(match).not.toBeNull();
const fireDelay = Number(match ? match[1] : NaN);
expect(fireDelay).toBeGreaterThan(0);
expect(fireDelay).toBeLessThanOrEqual(1000);
expect(scheduler).toContain('System.currentTimeMillis() + FIRE_DELAY_MS');
});
it('contains no repeating, inexact or delayed scheduling', () => {
// No bucket quantization anywhere in the firing path. The socket reconnect
// backoff in the service is the only Handler use and is not scheduling an
// alarm (asserted below by scoping this to the scheduler/player/activity).
const activity = native('AlarmActivity.kt');
for (const src of [scheduler, player, activity]) {
expect(src).not.toContain('setRepeating');
expect(src).not.toContain('setInexactRepeating');
expect(src).not.toContain('setWindow');
expect(src).not.toContain('postDelayed');
expect(src).not.toMatch(/Handler\(/);
}
});
it('rings until the device user dismisses it — no auto-stop anywhere', () => {
// User ruling: a lost owner must not mean an alarm that expires on a timer.
for (const file of [
'AlarmScheduler.kt',
'AlarmPlayer.kt',
'AlarmActivity.kt',
'AlarmForegroundService.kt',
'AlarmModule.kt',
]) {
expect(native(file)).not.toMatch(/autoStop/i);
}
expect(player).not.toMatch(/stopTimer|expire|countdown/i);
});
it('declares the exact-alarm and boot permissions it relies on', () => {
expect(manifest).toContain('android.permission.USE_EXACT_ALARM');
expect(manifest).toContain('android.permission.SCHEDULE_EXACT_ALARM');
expect(manifest).toContain('android.permission.USE_FULL_SCREEN_INTENT');
expect(manifest).toContain('android.permission.RECEIVE_BOOT_COMPLETED');
expect(manifest).toContain('.AlarmWakeReceiver');
});
});