/* 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'); }); it('has exactly two notification channels and no full-screen-intent notification', () => { const channels = notifier.split('= NotificationChannel(').length - 1; expect(channels).toBe(2); expect(notifier).toContain('CHANNEL_VOLUME'); expect(notifier).toContain('CHANNEL_SERVICE'); // The volume warning is informational; only an alarm may take over the screen, // and that is scheduled, not notified. expect(notifier).not.toContain('setFullScreenIntent'); }); it('only warns about volume, never about a ringing alarm', () => { expect(notifier).toContain('Alarm volume is at '); expect(notifier).not.toMatch(/Alarm ringing/); expect(notifier).not.toMatch(/Alarm armed"\)/); expect(notifier).toContain('CATEGORY_RECOMMENDATION'); }); 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'); }); });