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).
205 lines
9.9 KiB
TypeScript
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');
|
|
});
|
|
});
|