Files
alarm-client/__tests__/nativeDelivery.test.ts
T
Admin 06afd92647 Reach the screen in the background, and leave it again
Measured on an Android 15 emulator with the app backgrounded and the display
asleep: the siren played, the platform refused the takeover
("Background activity launch blocked! … BAL_BLOCK"), and the alarm was
destroyed mid-ring by its own receipt ack.

Takeover:
- the foreground service posts an alarm_firing notification whose
  full-screen intent raises AlarmActivity — the only background launch
  Android 14+ permits. Verified with the display asleep: wakefulness
  Asleep → Awake, topResumedActivity=.AlarmActivity, the FSI on the record.
- the cancel lives on the four real silences (disarm, stop, JS ack, activity
  destroy), not in AlarmPlayer.stop: play() calls stop() first, which
  retracted the notification ~120 ms after posting it.
- "Back to alerts" now finishes the alarm window (AlarmModule
  finishTakeover). A route pop is not an activity: the window stayed on top
  for good with show-when-locked, keep-screen-on and no Recents entry — the
  reported "no way back to the main menu".

The native-delivery contract test said "no notification ever announces an
alarm"; that was falsified on device, so it now pins one FSI notification and
a silent firing channel instead.

Verified end to end on the emulator (two runs, one with the screen off):
join without an account, armed service, background trigger → wake + takeover
+ siren, acknowledge → silence, notification retracted, alert delivered,
takeover closed by itself, Home reachable, reopening silent.
2026-10-19 20:40:38 +01:00

174 lines
8.4 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('AlarmActivity.intent(');
expect(scheduler).toContain('canScheduleExactAlarms');
});
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');
});
});