diff --git a/__tests__/nativeDelivery.test.ts b/__tests__/nativeDelivery.test.ts index 2ec54b7..136e8e1 100644 --- a/__tests__/nativeDelivery.test.ts +++ b/__tests__/nativeDelivery.test.ts @@ -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', () => { diff --git a/__tests__/takeoverLaunch.test.ts b/__tests__/takeoverLaunch.test.ts new file mode 100644 index 0000000..c41c787 --- /dev/null +++ b/__tests__/takeoverLaunch.test.ts @@ -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'); + }); +}); diff --git a/android/app/src/main/java/com/dcntrlized/alarm/AlarmActivity.kt b/android/app/src/main/java/com/dcntrlized/alarm/AlarmActivity.kt index c2b7312..989dcca 100644 --- a/android/app/src/main/java/com/dcntrlized/alarm/AlarmActivity.kt +++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmActivity.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.view.WindowManager +import java.lang.ref.WeakReference import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate 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. showOverLockScreen() super.onCreate(savedInstanceState) + current = WeakReference(this) } private fun showOverLockScreen() { @@ -67,11 +69,30 @@ class AlarmActivity : ReactActivity() { } override fun onDestroy() { + if (current?.get() === this) current = null 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() } 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? = null + + fun currentActivity(): AlarmActivity? = current?.get() + const val EXTRA_ALERT_ID = "alarm.alertId" const val EXTRA_MESSAGE = "alarm.message" diff --git a/android/app/src/main/java/com/dcntrlized/alarm/AlarmForegroundService.kt b/android/app/src/main/java/com/dcntrlized/alarm/AlarmForegroundService.kt index 335f373..b52e03f 100644 --- a/android/app/src/main/java/com/dcntrlized/alarm/AlarmForegroundService.kt +++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmForegroundService.kt @@ -220,6 +220,11 @@ class AlarmForegroundService : Service() { when (action) { "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) Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)") // A real alarm, not a notification: setAlarmClock is what an @@ -233,6 +238,7 @@ class AlarmForegroundService : Service() { } "disarm" -> { AlarmPlayer.stop(this) + AlarmNotifier.cancelFiring(this) AlarmNotifier.cancelVolumeWarning(this) // Only the firing entry dies here: a disarm ends this ring, // not the subscription. The standing entry (alarm-app status @@ -259,6 +265,7 @@ class AlarmForegroundService : Service() { stopping = true disconnect() AlarmPlayer.stop(this) + AlarmNotifier.cancelFiring(this) AlarmNotifier.cancelVolumeWarning(this) // Explicit stop (user asked): idle, not "timed out". Leaving the arm // record or the standing entry behind would keep an alarm icon for an diff --git a/android/app/src/main/java/com/dcntrlized/alarm/AlarmModule.kt b/android/app/src/main/java/com/dcntrlized/alarm/AlarmModule.kt index 705dc3a..2e45929 100644 --- a/android/app/src/main/java/com/dcntrlized/alarm/AlarmModule.kt +++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmModule.kt @@ -46,6 +46,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) : fun stopAlarm(promise: Promise) { try { AlarmPlayer.stop(reactContext) + AlarmNotifier.cancelFiring(reactContext) AlarmNotifier.cancelVolumeWarning(reactContext) promise.resolve(result(ok = true)) } 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). */ @ReactMethod fun openAppSettings(promise: Promise) { diff --git a/android/app/src/main/java/com/dcntrlized/alarm/AlarmPlayer.kt b/android/app/src/main/java/com/dcntrlized/alarm/AlarmPlayer.kt index 5826b7e..077e704 100644 --- a/android/app/src/main/java/com/dcntrlized/alarm/AlarmPlayer.kt +++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmPlayer.kt @@ -171,6 +171,9 @@ object AlarmNotifier { /** Foreground-service presence. Deliberately the quietest channel. */ 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) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val nm = context.getSystemService(NotificationManager::class.java) ?: return @@ -199,6 +202,22 @@ object AlarmNotifier { lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET } 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) } + /** + * 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. */ fun cancelArmed(context: Context) { val nm = context.getSystemService(NotificationManager::class.java) ?: return @@ -242,4 +316,5 @@ object AlarmNotifier { const val NOTIFICATION_ID_VOLUME = 4711 const val NOTIFICATION_ID_SERVICE = 4712 + const val NOTIFICATION_ID_FIRING = 4713 } diff --git a/src/screens/LiveAlarmScreen.tsx b/src/screens/LiveAlarmScreen.tsx index 6a24167..cb6d425 100644 --- a/src/screens/LiveAlarmScreen.tsx +++ b/src/screens/LiveAlarmScreen.tsx @@ -15,7 +15,13 @@ import { useAlarm } from '../state/AlarmContext'; import { useAuth } from '../state/AuthContext'; import { AppText } from '../components/ui'; 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 type { RootStackParamList } from '../navigation'; import { @@ -176,6 +182,10 @@ export default function LiveAlarmScreen() { clearLiveEvent(); if (navigation.canGoBack()) navigation.goBack(); 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]); /** diff --git a/src/services/alarmNative.ts b/src/services/alarmNative.ts index 37393bc..ec07a08 100644 --- a/src/services/alarmNative.ts +++ b/src/services/alarmNative.ts @@ -52,6 +52,7 @@ export interface AlarmServiceState { interface AlarmNativeModule { playAlarm(alertId: string, message: string): Promise; stopAlarm(): Promise; + finishTakeover(): Promise; startService(alertId: string, subscribeToken: string, wsUrl: string): Promise; stopService(): Promise; getPermissions(): Promise; @@ -101,6 +102,25 @@ export async function stopAlarm(): Promise { } } +/** + * 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 { + 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 * notification, so the trigger still lands when the app is backgrounded.