[feat](client): deliver a trigger as a real alarm-clock takeover

A notification is not a delivery path: Android 14 restricts full-screen intents to apps it classifies as alarm apps, and setAlarmClock is both the strongest alarm API and the evidence for that classification. Schedule the takeover through AlarmManager instead of startActivity, keep a standing alarm-clock entry while armed, restore it after reboot (the wake also re-arms the socket), and persist the honest listening state so a platform stop is visible. Run the socket as specialUse on API 34+ (dataSync is capped at 6h/24h on 35+) and keep onTimeout as a guard. Restore the Lead release build placeholder so a plain-HTTP instance is reachable, and allow the app to arm only when exact alarms and full-screen intent are actually granted.
This commit is contained in:
2026-10-19 02:34:02 +01:00
parent 5fa1e8e83e
commit a804223139
12 changed files with 959 additions and 109 deletions
+161
View File
@@ -0,0 +1,161 @@
/* 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');
});
});