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