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.
116 lines
4.7 KiB
Kotlin
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)
|
|
}
|
|
}
|