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.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
export {};
|
||||
|
||||
/**
|
||||
* The takeover must reach the screen from the background.
|
||||
*
|
||||
* Measured on an Android 15 emulator: the alarm rang with the app backgrounded,
|
||||
* but `AlarmActivity` was refused by the platform —
|
||||
* `Background activity launch blocked! … BAL_BLOCK` — because an activity launched
|
||||
* from the alarm's own PendingIntent is subject to the Android 14+ background
|
||||
* launch rules. The siren was audible and nothing was on screen.
|
||||
*
|
||||
* The platform's sanctioned path is a notification **full-screen intent**, which
|
||||
* SystemUI launches. These assertions pin that structure, because it cannot be
|
||||
* exercised in a Jest run (no Android runtime): the firing notification must
|
||||
* carry the FSI, the alarm category and the takeover's own intent, and silencing
|
||||
* the alarm must retract it.
|
||||
*/
|
||||
declare const __dirname: string;
|
||||
const fs = require('fs') as { readFileSync(p: string, enc: string): string };
|
||||
const path = require('path') as { join(...parts: string[]): string };
|
||||
|
||||
const NATIVE = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'android',
|
||||
'app',
|
||||
'src',
|
||||
'main',
|
||||
'java',
|
||||
'com',
|
||||
'dcntrlized',
|
||||
'alarm',
|
||||
);
|
||||
const player = fs.readFileSync(path.join(NATIVE, 'AlarmPlayer.kt'), 'utf8');
|
||||
const service = fs.readFileSync(path.join(NATIVE, 'AlarmForegroundService.kt'), 'utf8');
|
||||
|
||||
describe('background takeover launch', () => {
|
||||
it('posts a full-screen-intent notification for a trigger', () => {
|
||||
expect(player).toContain('fun showFiring(');
|
||||
expect(player).toContain('setFullScreenIntent(open, true)');
|
||||
expect(player).toContain('Notification.CATEGORY_ALARM');
|
||||
expect(player).toContain('AlarmActivity.intent(context, alertId, message)');
|
||||
});
|
||||
|
||||
it('raises it from the service that received the frame', () => {
|
||||
const at = service.indexOf('"trigger" ->');
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const branch = service.slice(at, at + 700);
|
||||
expect(branch).toContain('AlarmNotifier.showFiring(');
|
||||
expect(branch.indexOf('showFiring')).toBeLessThan(branch.indexOf('AlarmPlayer.play'));
|
||||
});
|
||||
|
||||
it('retracts it when the alarm is silenced — but not from AlarmPlayer.stop', () => {
|
||||
// play() calls stop() first, so cancelling there retracted the full-screen
|
||||
// intent on every single trigger (measured: NotificationService "Cannot find
|
||||
// enqueued record for key 4713" 120 ms after the trigger).
|
||||
const stop = player.slice(player.indexOf('fun stop(context: Context)'));
|
||||
expect(stop.slice(0, 1200)).not.toContain('cancelFiring');
|
||||
expect(service).toContain('AlarmNotifier.cancelFiring(this)');
|
||||
// A disarm and an explicit stop both retract it.
|
||||
expect(service.split('AlarmNotifier.cancelFiring(this)').length - 1).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('closes its own window when the user leaves — a route pop is not enough', () => {
|
||||
// Measured on the emulator: after acknowledging, the app returned to its
|
||||
// previous screen but the AlarmActivity window stayed on top for good
|
||||
// (show-when-locked, keep-screen-on, excludeFromRecents) — the reported
|
||||
// "no way back to the main menu".
|
||||
const activity = fs.readFileSync(path.join(NATIVE, 'AlarmActivity.kt'), 'utf8');
|
||||
const bridge = fs.readFileSync(path.join(NATIVE, 'AlarmModule.kt'), 'utf8');
|
||||
const screen = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
expect(activity).toContain('fun currentActivity()');
|
||||
expect(activity).toContain('current = null');
|
||||
const finishAt = bridge.indexOf('fun finishTakeover(');
|
||||
expect(finishAt).toBeGreaterThan(-1);
|
||||
expect(bridge.slice(finishAt, finishAt + 400)).toContain('.finish()');
|
||||
const leaveAt = screen.indexOf('const leave = useCallback(');
|
||||
expect(screen.slice(leaveAt, leaveAt + 600)).toContain('finishTakeover()');
|
||||
});
|
||||
|
||||
it('tells the truth when the FSI grant is missing', () => {
|
||||
expect(player).toContain('canUseFullScreenIntent()');
|
||||
expect(player).toContain('heads-up only');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user