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');
|
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;
|
const channels = notifier.split('= NotificationChannel(').length - 1;
|
||||||
expect(channels).toBe(2);
|
expect(channels).toBe(3);
|
||||||
expect(notifier).toContain('CHANNEL_VOLUME');
|
expect(notifier).toContain('CHANNEL_VOLUME');
|
||||||
expect(notifier).toContain('CHANNEL_SERVICE');
|
expect(notifier).toContain('CHANNEL_SERVICE');
|
||||||
// The volume warning is informational; only an alarm may take over the screen,
|
expect(notifier).toContain('CHANNEL_FIRING');
|
||||||
// and that is scheduled, not notified.
|
const fsi = notifier.split('setFullScreenIntent').length - 1;
|
||||||
expect(notifier).not.toContain('setFullScreenIntent');
|
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).toContain('Alarm volume is at ');
|
||||||
expect(notifier).not.toMatch(/Alarm ringing/);
|
|
||||||
expect(notifier).not.toMatch(/Alarm armed"\)/);
|
|
||||||
expect(notifier).toContain('CATEGORY_RECOMMENDATION');
|
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', () => {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
import com.facebook.react.ReactActivity
|
import com.facebook.react.ReactActivity
|
||||||
import com.facebook.react.ReactActivityDelegate
|
import com.facebook.react.ReactActivityDelegate
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
@@ -38,6 +39,7 @@ class AlarmActivity : ReactActivity() {
|
|||||||
// Order matters: the flags must be set before the window is shown.
|
// Order matters: the flags must be set before the window is shown.
|
||||||
showOverLockScreen()
|
showOverLockScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
current = WeakReference(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showOverLockScreen() {
|
private fun showOverLockScreen() {
|
||||||
@@ -67,11 +69,30 @@ class AlarmActivity : ReactActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
if (current?.get() === this) current = null
|
||||||
AlarmPlayer.stop(this)
|
AlarmPlayer.stop(this)
|
||||||
|
// The ringing notification dies with the ring it announced. NOT in
|
||||||
|
// AlarmPlayer.stop: play() calls that first, which would retract the
|
||||||
|
// full-screen intent that raised this activity.
|
||||||
|
AlarmNotifier.cancelFiring(this)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* The takeover that is on screen, if any.
|
||||||
|
*
|
||||||
|
* The JS "Back to alerts" action pops the route but cannot dismiss an
|
||||||
|
* Android activity, so without this the alarm window would stay on top
|
||||||
|
* for good — visible over the lock screen, holding the screen awake, in a
|
||||||
|
* task that is excluded from Recents. That is the reported "no way back to
|
||||||
|
* the main menu". [AlarmModule.finishTakeover] ends it.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var current: WeakReference<AlarmActivity>? = null
|
||||||
|
|
||||||
|
fun currentActivity(): AlarmActivity? = current?.get()
|
||||||
|
|
||||||
const val EXTRA_ALERT_ID = "alarm.alertId"
|
const val EXTRA_ALERT_ID = "alarm.alertId"
|
||||||
const val EXTRA_MESSAGE = "alarm.message"
|
const val EXTRA_MESSAGE = "alarm.message"
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ class AlarmForegroundService : Service() {
|
|||||||
|
|
||||||
when (action) {
|
when (action) {
|
||||||
"trigger" -> {
|
"trigger" -> {
|
||||||
|
// Raise the screen first: SystemUI launches the takeover from
|
||||||
|
// this notification's full-screen intent, which is the only
|
||||||
|
// launch path Android 14+ does not block from the background
|
||||||
|
// (the alarm's own PendingIntent is refused — BAL_BLOCK).
|
||||||
|
AlarmNotifier.showFiring(this, id, message)
|
||||||
val volumeRaised = AlarmPlayer.play(this, id)
|
val volumeRaised = AlarmPlayer.play(this, id)
|
||||||
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
|
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
|
||||||
// A real alarm, not a notification: setAlarmClock is what an
|
// A real alarm, not a notification: setAlarmClock is what an
|
||||||
@@ -233,6 +238,7 @@ class AlarmForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
"disarm" -> {
|
"disarm" -> {
|
||||||
AlarmPlayer.stop(this)
|
AlarmPlayer.stop(this)
|
||||||
|
AlarmNotifier.cancelFiring(this)
|
||||||
AlarmNotifier.cancelVolumeWarning(this)
|
AlarmNotifier.cancelVolumeWarning(this)
|
||||||
// Only the firing entry dies here: a disarm ends this ring,
|
// Only the firing entry dies here: a disarm ends this ring,
|
||||||
// not the subscription. The standing entry (alarm-app status
|
// not the subscription. The standing entry (alarm-app status
|
||||||
@@ -259,6 +265,7 @@ class AlarmForegroundService : Service() {
|
|||||||
stopping = true
|
stopping = true
|
||||||
disconnect()
|
disconnect()
|
||||||
AlarmPlayer.stop(this)
|
AlarmPlayer.stop(this)
|
||||||
|
AlarmNotifier.cancelFiring(this)
|
||||||
AlarmNotifier.cancelVolumeWarning(this)
|
AlarmNotifier.cancelVolumeWarning(this)
|
||||||
// Explicit stop (user asked): idle, not "timed out". Leaving the arm
|
// Explicit stop (user asked): idle, not "timed out". Leaving the arm
|
||||||
// record or the standing entry behind would keep an alarm icon for an
|
// record or the standing entry behind would keep an alarm icon for an
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
|||||||
fun stopAlarm(promise: Promise) {
|
fun stopAlarm(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
AlarmPlayer.stop(reactContext)
|
AlarmPlayer.stop(reactContext)
|
||||||
|
AlarmNotifier.cancelFiring(reactContext)
|
||||||
AlarmNotifier.cancelVolumeWarning(reactContext)
|
AlarmNotifier.cancelVolumeWarning(reactContext)
|
||||||
promise.resolve(result(ok = true))
|
promise.resolve(result(ok = true))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -240,6 +241,21 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the full-screen takeover, returning the user to the app they were in.
|
||||||
|
*
|
||||||
|
* A no-op unless the alarm activity is alive: when the alarm fires while the
|
||||||
|
* app is in the foreground the takeover is a JS route, not an activity.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun finishTakeover(promise: Promise) {
|
||||||
|
val activity = AlarmActivity.currentActivity()
|
||||||
|
if (activity != null) {
|
||||||
|
activity.finish()
|
||||||
|
}
|
||||||
|
promise.resolve(result(ok = activity != null))
|
||||||
|
}
|
||||||
|
|
||||||
/** Opens this app's Android settings page (used by the camera-denied state). */
|
/** Opens this app's Android settings page (used by the camera-denied state). */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun openAppSettings(promise: Promise) {
|
fun openAppSettings(promise: Promise) {
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ object AlarmNotifier {
|
|||||||
/** Foreground-service presence. Deliberately the quietest channel. */
|
/** Foreground-service presence. Deliberately the quietest channel. */
|
||||||
const val CHANNEL_SERVICE = "alarm_armed"
|
const val CHANNEL_SERVICE = "alarm_armed"
|
||||||
|
|
||||||
|
/** The ringing alarm itself, the only channel with a full-screen intent. */
|
||||||
|
const val CHANNEL_FIRING = "alarm_firing"
|
||||||
|
|
||||||
fun ensureChannels(context: Context) {
|
fun ensureChannels(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
@@ -199,6 +202,22 @@ object AlarmNotifier {
|
|||||||
lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET
|
lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET
|
||||||
}
|
}
|
||||||
nm.createNotificationChannel(service)
|
nm.createNotificationChannel(service)
|
||||||
|
|
||||||
|
// The ringing alarm. High importance so SystemUI launches the
|
||||||
|
// full-screen intent; silent because the siren is our own audio stream
|
||||||
|
// (a channel sound would double it).
|
||||||
|
val firing = NotificationChannel(
|
||||||
|
CHANNEL_FIRING,
|
||||||
|
"Alarm ringing",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Shows the alarm screen when an alert you follow is triggered"
|
||||||
|
setShowBadge(true)
|
||||||
|
enableVibration(false)
|
||||||
|
setSound(null, null)
|
||||||
|
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(firing)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,6 +253,61 @@ object AlarmNotifier {
|
|||||||
nm.cancel(NOTIFICATION_ID_VOLUME)
|
nm.cancel(NOTIFICATION_ID_VOLUME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ringing alarm's notification, carrying a **full-screen intent** for the
|
||||||
|
* takeover screen.
|
||||||
|
*
|
||||||
|
* This is the platform-sanctioned way to raise an activity from the
|
||||||
|
* background, and it is not optional: starting the activity from the alarm's
|
||||||
|
* own PendingIntent is refused on Android 14+ (`Background activity launch
|
||||||
|
* blocked! … BAL_BLOCK`, measured on an Android 15 emulator — the alarm rang
|
||||||
|
* with no screen). A notification full-screen intent is launched by
|
||||||
|
* SystemUI, which is exempt from that rule.
|
||||||
|
*
|
||||||
|
* It is also why the FSI grant matters: without `canUseFullScreenIntent()`
|
||||||
|
* the system shows a heads-up instead of taking over the screen, so the
|
||||||
|
* caller records that the device may only get the siren.
|
||||||
|
*/
|
||||||
|
fun showFiring(context: Context, alertId: String, message: String) {
|
||||||
|
ensureChannels(context)
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
val open = android.app.PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
NOTIFICATION_ID_FIRING,
|
||||||
|
AlarmActivity.intent(context, alertId, message),
|
||||||
|
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val builder = android.app.Notification.Builder(context, CHANNEL_FIRING)
|
||||||
|
.setContentTitle("Alarm")
|
||||||
|
.setContentText(message.ifBlank { "An alert you follow was triggered" })
|
||||||
|
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||||
|
.setCategory(android.app.Notification.CATEGORY_ALARM)
|
||||||
|
.setVisibility(android.app.Notification.VISIBILITY_PUBLIC)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setFullScreenIntent(open, true)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
if (!nm.canUseFullScreenIntent()) {
|
||||||
|
Log.w(
|
||||||
|
"AlarmNotifier",
|
||||||
|
"full-screen intent not granted: the takeover will show as a heads-up only"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
nm.notify(NOTIFICATION_ID_FIRING, builder.build())
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelFiring(context: Context) {
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
nm.cancel(NOTIFICATION_ID_FIRING)
|
||||||
|
}
|
||||||
|
|
||||||
/** Removes the persistent "Alarm armed" notification. */
|
/** Removes the persistent "Alarm armed" notification. */
|
||||||
fun cancelArmed(context: Context) {
|
fun cancelArmed(context: Context) {
|
||||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
@@ -242,4 +316,5 @@ object AlarmNotifier {
|
|||||||
|
|
||||||
const val NOTIFICATION_ID_VOLUME = 4711
|
const val NOTIFICATION_ID_VOLUME = 4711
|
||||||
const val NOTIFICATION_ID_SERVICE = 4712
|
const val NOTIFICATION_ID_SERVICE = 4712
|
||||||
|
const val NOTIFICATION_ID_FIRING = 4713
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ import { useAlarm } from '../state/AlarmContext';
|
|||||||
import { useAuth } from '../state/AuthContext';
|
import { useAuth } from '../state/AuthContext';
|
||||||
import { AppText } from '../components/ui';
|
import { AppText } from '../components/ui';
|
||||||
import * as api from '../services/alarmApi';
|
import * as api from '../services/alarmApi';
|
||||||
import { openSoundSettings, playAlarm, stopAlarm, isAlarmNativeAvailable } from '../services/alarmNative';
|
import {
|
||||||
|
finishTakeover,
|
||||||
|
isAlarmNativeAvailable,
|
||||||
|
openSoundSettings,
|
||||||
|
playAlarm,
|
||||||
|
stopAlarm,
|
||||||
|
} from '../services/alarmNative';
|
||||||
import { absoluteLocal, parseServerTime, relativeSince } from '../lib/time';
|
import { absoluteLocal, parseServerTime, relativeSince } from '../lib/time';
|
||||||
import type { RootStackParamList } from '../navigation';
|
import type { RootStackParamList } from '../navigation';
|
||||||
import {
|
import {
|
||||||
@@ -176,6 +182,10 @@ export default function LiveAlarmScreen() {
|
|||||||
clearLiveEvent();
|
clearLiveEvent();
|
||||||
if (navigation.canGoBack()) navigation.goBack();
|
if (navigation.canGoBack()) navigation.goBack();
|
||||||
else navigation.navigate('Home');
|
else navigation.navigate('Home');
|
||||||
|
// The route pop is not enough: when the alarm was raised as its own window
|
||||||
|
// (the background case), that window has to close or the user lands back in
|
||||||
|
// a full-screen alarm activity that never lets go of the screen.
|
||||||
|
void finishTakeover();
|
||||||
}, [clearLiveEvent, navigation]);
|
}, [clearLiveEvent, navigation]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface AlarmServiceState {
|
|||||||
interface AlarmNativeModule {
|
interface AlarmNativeModule {
|
||||||
playAlarm(alertId: string, message: string): Promise<AlarmNativeResult>;
|
playAlarm(alertId: string, message: string): Promise<AlarmNativeResult>;
|
||||||
stopAlarm(): Promise<AlarmNativeResult>;
|
stopAlarm(): Promise<AlarmNativeResult>;
|
||||||
|
finishTakeover(): Promise<AlarmNativeResult>;
|
||||||
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
||||||
stopService(): Promise<AlarmNativeResult>;
|
stopService(): Promise<AlarmNativeResult>;
|
||||||
getPermissions(): Promise<AlarmPermissions>;
|
getPermissions(): Promise<AlarmPermissions>;
|
||||||
@@ -101,6 +102,25 @@ export async function stopAlarm(): Promise<AlarmNativeResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the full-screen alarm window, if this JS tree happens to be running in
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* Popping a route leaves the Android activity behind: it keeps its
|
||||||
|
* show-when-locked / keep-screen-on flags, sits on top of the app the user
|
||||||
|
* returns to, and lives in a task that is excluded from Recents. So "Back to
|
||||||
|
* alerts" has to close the window as well as the route. A no-op when the alarm
|
||||||
|
* was shown as a route inside the main activity (`ok: false`).
|
||||||
|
*/
|
||||||
|
export async function finishTakeover(): Promise<AlarmNativeResult> {
|
||||||
|
if (!native) return UNAVAILABLE;
|
||||||
|
try {
|
||||||
|
return await native.finishTakeover();
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, reason: e instanceof Error ? e.message : 'finish-failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the socket alive while armed: a foreground service with a persistent
|
* Keeps the socket alive while armed: a foreground service with a persistent
|
||||||
* notification, so the trigger still lands when the app is backgrounded.
|
* notification, so the trigger still lands when the app is backgrounded.
|
||||||
|
|||||||
Reference in New Issue
Block a user