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:
@@ -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<AlarmActivity>? = null
|
||||
|
||||
fun currentActivity(): AlarmActivity? = current?.get()
|
||||
|
||||
const val EXTRA_ALERT_ID = "alarm.alertId"
|
||||
const val EXTRA_MESSAGE = "alarm.message"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user