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:
@@ -78,21 +78,33 @@ describe('native alarm delivery contract', () => {
|
||||
expect(cancelFireBody).toContain('REQ_FIRE');
|
||||
});
|
||||
|
||||
it('has exactly two notification channels and no full-screen-intent notification', () => {
|
||||
// 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(2);
|
||||
expect(channels).toBe(3);
|
||||
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');
|
||||
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('only warns about volume, never about a ringing alarm', () => {
|
||||
it('keeps the volume warning informational and never arms from a notification', () => {
|
||||
expect(notifier).toContain('Alarm volume is at ');
|
||||
expect(notifier).not.toMatch(/Alarm ringing/);
|
||||
expect(notifier).not.toMatch(/Alarm armed"\)/);
|
||||
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', () => {
|
||||
|
||||
@@ -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