Files
alarm-client/android/app/src/main/java/com/dcntrlized/alarm/AlarmActivity.kt
T
Admin 06afd92647 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.
2026-10-19 20:40:38 +01:00

116 lines
4.7 KiB
Kotlin

package com.dcntrlized.alarm
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
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
import com.facebook.react.defaults.DefaultReactActivityDelegate
/**
* The full-screen alarm takeover.
*
* Launched by the `PendingIntent` of an `AlarmManager.setAlarmClock` entry (see
* [AlarmScheduler]), so it appears over the lock screen and turns the screen on
* — an alarm launch, not a notification tap. It reuses the main JS bundle and is
* pointed at the `LiveAlarm` route via the intent extras, so the takeover is the
* same React screen whether the app was foreground, background or dead.
*
* Manifest requirements that make this work (see AndroidManifest.xml):
* `launchMode=singleTask`, `excludeFromRecents=true`, `showWhenLocked` and
* `turnScreenOn` on the activity. `USE_FULL_SCREEN_INTENT` is still declared
* because it is the permission the platform uses to decide whether this app may
* take over the screen at all.
*/
class AlarmActivity : ReactActivity() {
override fun getMainComponentName(): String = "AlarmClient"
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
override fun onCreate(savedInstanceState: Bundle?) {
// Order matters: the flags must be set before the window is shown.
showOverLockScreen()
super.onCreate(savedInstanceState)
current = WeakReference(this)
}
private fun showOverLockScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Dismiss the keyguard where the OS permits it (a secure lock screen is
// still honoured — we never try to bypass a PIN).
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && km?.isKeyguardLocked == true) {
km.requestDismissKeyguard(this, null)
}
}
/** Hardware back is consumed: the takeover's only exits are in-app actions. */
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
// Intentionally swallowed — see the Live alarm takeover spec.
}
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"
fun intent(context: Context, alertId: String, message: String): Intent =
Intent(context, AlarmActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
)
putExtra(EXTRA_ALERT_ID, alertId)
putExtra(EXTRA_MESSAGE, message)
}
/** The alert id this launch is for, if any — read by the JS bridge. */
fun alertIdOf(activity: Activity?): String? =
activity?.intent?.getStringExtra(EXTRA_ALERT_ID)
}
}