[feat](client): deliver a trigger as a real alarm-clock takeover

A notification is not a delivery path: Android 14 restricts full-screen intents to apps it classifies as alarm apps, and setAlarmClock is both the strongest alarm API and the evidence for that classification. Schedule the takeover through AlarmManager instead of startActivity, keep a standing alarm-clock entry while armed, restore it after reboot (the wake also re-arms the socket), and persist the honest listening state so a platform stop is visible. Run the socket as specialUse on API 34+ (dataSync is capped at 6h/24h on 35+) and keep onTimeout as a guard. Restore the Lead release build placeholder so a plain-HTTP instance is reachable, and allow the app to arm only when exact alarms and full-screen intent are actually granted.
This commit is contained in:
2026-10-19 02:34:02 +01:00
parent 5fa1e8e83e
commit a804223139
12 changed files with 959 additions and 109 deletions
+6
View File
@@ -101,6 +101,12 @@ android {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
// A self-hosted instance may legitimately be plain HTTP on a LAN
// (`http://192.168.x.x:8080`), and the dev default is
// `http://127.0.0.1:8080` via `adb reverse`. The RN template leaves
// this false outside debug, which would make a release build unable
// to reach any non-TLS instance at all.
manifestPlaceholders = [usesCleartextTraffic: "true"]
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
+55 -9
View File
@@ -6,24 +6,51 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Full-screen takeover over the lock screen (Android 14+ gates this per-app). -->
<!--
Full-screen takeover over the lock screen. Restricted since Android 14
(API 34): auto-granted to apps the system classifies as call/alarm apps and
revoked by default otherwise. The app becomes classifiable by actually
behaving like an alarm app — AlarmManager.setAlarmClock, see
AlarmScheduler. Deliberately not removed: it is still the permission that
governs the takeover.
-->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Exact alarms: auto-stop and any scheduled re-arm must not be deferred. -->
<!--
Exact alarms. USE_EXACT_ALARM (API 33+) is granted without user action for
alarm-clock apps, which is exactly why off-Play distribution matters here;
SCHEDULE_EXACT_ALARM covers API 31/32 where the user still grants it.
-->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Battery exemption prompt (optional; the health checklist reports the result). -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Foreground service: holds the armed socket while the app is backgrounded. -->
<!-- Restore the standing alarm-clock entry after a reboot. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!--
Foreground service: holds the armed socket while the app is backgrounded.
specialUse (API 34+) is the accurate type and carries no time limit;
dataSync is used on API 29-33 only. dataSync's API 35+ 6h/24h cumulative
cap cannot be escaped by re-arming, which is why the primary type is
specialUse — the app is not distributed through Play, so it is available.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- QR scanning -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<!-- A self-hosted instance may legitimately be plain HTTP on a LAN
(`http://192.168.x.x:8080`), and the dev default is
`http://127.0.0.1:8080` through `adb reverse`. The RN template's
`${usesCleartextTraffic}` placeholder resolves to false outside debug,
which would leave a release build unable to reach any non-TLS
instance at all. Set explicitly so a release APK is not silently
HTTP-incapable. -->
<application
android:name=".MainApplication"
android:label="@string/app_name"
@@ -31,7 +58,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:usesCleartextTraffic="true"
android:supportsRtl="true">
<activity
@@ -70,14 +97,33 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" />
<!--
Armed-socket holder. dataSync covers "keep a network connection open for
the user"; mediaPlayback is declared too because the same service can be
playing the alarm when a trigger foregrounds it.
Armed-socket holder. The subtype text is what the platform shows to a
reviewer/user for a specialUse service, so it states the real reason.
dataSync is declared only for API 29-33, where specialUse does not exist
and dataSync has no time cap.
-->
<service
android:name=".AlarmForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|mediaPlayback" />
android:foregroundServiceType="dataSync|specialUse">
<property
android:name="android:PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="holds a persistent connection so a user-subscribed alert can be delivered within milliseconds" />
</service>
<!--
Standing alarm-clock entry + boot restore. exported=false still receives
system broadcasts (same app / system only), so this cannot be poked from
outside.
-->
<receiver
android:name=".AlarmWakeReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -15,14 +15,17 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate
/**
* The full-screen alarm takeover.
*
* Launched by a full-screen intent from the alarm notification, so it appears
* over the lock screen and turns the screen on. 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.
* 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, and `USE_FULL_SCREEN_INTENT`.
* `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() {
@@ -71,9 +74,8 @@ class AlarmActivity : ReactActivity() {
companion object {
const val EXTRA_ALERT_ID = "alarm.alertId"
const val EXTRA_MESSAGE = "alarm.message"
const val EXTRA_AUTO_STOP = "alarm.autoStopSeconds"
fun intent(context: Context, alertId: String, message: String, autoStopSeconds: Int): Intent =
fun intent(context: Context, alertId: String, message: String): Intent =
Intent(context, AlarmActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
@@ -83,7 +85,6 @@ class AlarmActivity : ReactActivity() {
)
putExtra(EXTRA_ALERT_ID, alertId)
putExtra(EXTRA_MESSAGE, message)
putExtra(EXTRA_AUTO_STOP, autoStopSeconds)
}
/** The alert id this launch is for, if any — read by the JS bridge. */
@@ -0,0 +1,45 @@
package com.dcntrlized.alarm
import android.content.Context
/**
* The alert this device is currently armed for.
*
* Persisted so the standing alarm-clock entry and the boot receiver can restore
* themselves after the process is killed or the phone reboots: without the
* `wsUrl` there is nothing to reconnect to, and an alarm app that forgets its
* alarms across a reboot is not a real alarm.
*
* Not secret material — the subscribe token is inside `wsUrl`, so the file is
* private to the app (the app already holds that token in JS storage).
*/
object AlarmArmStore {
private const val PREFS = "alarm.arm.v1"
private const val KEY_ALERT = "alertId"
private const val KEY_WS = "wsUrl"
private const val KEY_SINCE = "since"
data class Armed(val alertId: String, val wsUrl: String, val sinceMillis: Long)
fun save(context: Context, alertId: String, wsUrl: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_ALERT, alertId)
.putString(KEY_WS, wsUrl)
.putLong(KEY_SINCE, System.currentTimeMillis())
.apply()
}
fun current(context: Context): Armed? {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val alertId = prefs.getString(KEY_ALERT, null)
val wsUrl = prefs.getString(KEY_WS, null)
if (alertId.isNullOrEmpty() || wsUrl.isNullOrEmpty()) return null
return Armed(alertId, wsUrl, prefs.getLong(KEY_SINCE, 0L))
}
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
}
@@ -7,6 +7,7 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.annotation.RequiresApi
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -24,13 +25,40 @@ import java.util.concurrent.TimeUnit
* the React Native JS runtime being alive — so a trigger still lands when the
* app has been backgrounded or the activity was destroyed.
*
* On a trigger frame it: plays the alarm through [AlarmPlayer] (max-volume
* `STREAM_ALARM` + vibration), posts the high-priority notification, and
* launches [AlarmActivity] as a full-screen intent so the takeover appears over
* the lock screen.
* On a trigger frame it plays the alarm through [AlarmPlayer] (max-volume
* `STREAM_ALARM` + vibration) and schedules the takeover through
* [AlarmScheduler] — a real `AlarmManager.setAlarmClock` entry, never a
* notification. A notification is only ever posted when the alarm stream could
* not be raised, to tell the user their volume is not at maximum.
*
* Manifest: `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_DATA_SYNC`, and the
* `foregroundServiceType="dataSync"` attribute on this `<service>`.
* Manifest: `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SPECIAL_USE` +
* `FOREGROUND_SERVICE_DATA_SYNC`, and
* `foregroundServiceType="dataSync|specialUse"` on this `<service>` — see the
* type note below for which one is used on which API level.
*
* ## Service type: `specialUse` (product decision)
*
* The service holds a connection for a user-subscribed alert, which is not
* "data sync" in the platform's sense. The types were weighed explicitly:
* - `dataSync` is capped at **6 hours cumulative per 24-hour period** on API
* 35+. The budget is cumulative, so re-arming cannot escape it — it only
* buys one more cycle — and the cap turns an always-on alarm into a part-time
* one, which contradicts "always run a real alarm".
* - `mediaPlayback` is only honest while audio is actually playing; we are
* silent most of the time.
* - `specialUse` is the type that describes what we do, and it is available to
* us precisely because this app is not distributed through Play. It has no
* time limit, so the subscription survives as long as the alert exists.
*
* API levels: `specialUse` was introduced in Android 14 (API 34), so it is
* passed on 34+; on API 29–33 we pass `dataSync`, where no cap exists (the cap
* is API 35+). The manifest declares both types for that reason.
*
* [onTimeout] is kept as **defence in depth**: if a future API level extends
* foreground-service timeouts to `specialUse` (or an OEM enforces one), the
* target-SDK-36 contract still requires stopping ourselves or the system
* throws. The standing alarm-clock wake also re-arms the socket, which is the
* belt-and-braces path if a stop happens anyway.
*/
class AlarmForegroundService : Service() {
@@ -59,8 +87,47 @@ class AlarmForegroundService : Service() {
return START_REDELIVER_INTENT
}
/**
* Android 15+ (API 35+) foreground-service time limit handler.
*
* Two jobs, in this order:
* 1. **Stop ourselves.** If the app targets API 35+ and this method returns
* without stopping the service, the system throws and crashes the app.
* 2. **Tell the truth.** Cancel the "Alarm armed" notification (it is no
* longer true), post a visible "stopped listening" notification, and
* persist the state so the UI can say the same thing on next open.
*
* It receives the `startId` of the timed-out start request and the
* foreground-service type that timed out. `specialUse` has no time limit
* today, so in practice this is a guard for dataSync (API 29-33) and for any
* future platform limit — it must keep existing because the target-SDK-36
* contract requires stopping ourselves on timeout.
*/
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
override fun onTimeout(startId: Int, fgsType: Int) {
Log.w(TAG, "foreground service timed out (fgsType=$fgsType, startId=$startId)")
stopping = true
disconnect()
AlarmPlayer.stop(this)
AlarmListeningState.markTimedOut(this)
AlarmNotifier.cancelArmed(this)
isRunning = false
// No notification here on purpose: the user ruling is that the only
// notification about an alarm is the volume warning. The timeout is
// surfaced in-app (alert screen + Settings) and in the persisted state.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf(startId)
}
private fun startForegroundInternal() {
AlarmNotifier.ensureChannels(this)
// Record the honest state *after* startForeground succeeds below: if the
// OS rejects the start, we throw out of here and must not claim to run.
val open = PendingIntent.getActivity(
this,
0,
@@ -75,7 +142,7 @@ class AlarmForegroundService : Service() {
)
val notification = android.app.Notification.Builder(this, AlarmNotifier.CHANNEL_SERVICE)
.setContentTitle("Alarm armed")
.setContentText("Listening for triggers on $alertId")
.setContentText("Listening for triggers")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setOngoing(true)
.setContentIntent(open)
@@ -83,14 +150,24 @@ class AlarmForegroundService : Service() {
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
AlarmNotifier.NOTIFICATION_ID_SERVICE,
notification,
// specialUse exists from API 34; before that use dataSync (no cap on
// those levels) so the type is always a declared one.
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
}
startForeground(AlarmNotifier.NOTIFICATION_ID_SERVICE, notification, type)
} else {
startForeground(AlarmNotifier.NOTIFICATION_ID_SERVICE, notification)
}
AlarmListeningState.markRunning(this, alertId)
isRunning = true
AlarmArmStore.save(this, alertId, wsUrl)
// Standing alarm-clock entry: the status-bar "armed" indicator, the
// re-arm point if the service is ever stopped, and the alarm-app
// classification evidence.
AlarmScheduler.holdStanding(this, alertId)
}
private fun connect() {
@@ -135,7 +212,6 @@ class AlarmForegroundService : Service() {
val eventId = json.optString("eventId", "")
val action = json.optString("action", "")
val message = json.optString("message", "")
val autoStop = json.optInt("autoStopSeconds", 0)
// Acknowledge so the server can account for delivery.
if (eventId.isNotEmpty()) {
@@ -144,18 +220,24 @@ class AlarmForegroundService : Service() {
when (action) {
"trigger" -> {
val volumeRaised = AlarmPlayer.play(this, id, autoStop)
val volumeRaised = AlarmPlayer.play(this, id)
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
// Full-screen intent: the takeover covers the lock screen.
try {
startActivity(AlarmActivity.intent(this, id, message, autoStop))
} catch (e: Exception) {
Log.w(TAG, "could not start AlarmActivity: ${e.message}")
// A real alarm, not a notification: setAlarmClock is what an
// alarm app does and what earns the full-screen classification.
if (!AlarmScheduler.fireNow(this, id, message)) {
// Never degrade silently. The record is read by the UI as
// "this device could not deliver the alarm".
AlarmListeningState.markDeliveryFailed(this)
Log.w(TAG, "could not schedule the alarm clock for $id")
}
}
"disarm" -> {
AlarmPlayer.stop(this)
AlarmNotifier.cancelAlarm(this)
AlarmNotifier.cancelVolumeWarning(this)
// Only the firing entry dies here: a disarm ends this ring,
// not the subscription. The standing entry (alarm-app status
// + dataSync re-arm point) must survive until stop/unsubscribe.
AlarmScheduler.cancelFire(this)
}
}
} catch (e: Exception) {
@@ -177,7 +259,14 @@ class AlarmForegroundService : Service() {
stopping = true
disconnect()
AlarmPlayer.stop(this)
AlarmNotifier.cancelAlarm(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
// alert this device no longer follows.
AlarmScheduler.cancel(this)
AlarmArmStore.clear(this)
AlarmListeningState.markIdle(this)
isRunning = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@@ -189,6 +278,7 @@ class AlarmForegroundService : Service() {
override fun onDestroy() {
stopping = true
isRunning = false
disconnect()
super.onDestroy()
}
@@ -196,6 +286,10 @@ class AlarmForegroundService : Service() {
companion object {
private const val TAG = "AlarmService"
const val ACTION_STOP = "com.dcntrlized.alarm.STOP"
/** Visible to in-process watchdog/receiver code; not persisted. */
@Volatile
var isRunning: Boolean = false
private set
const val EXTRA_ALERT_ID = "alarm.alertId"
const val EXTRA_WS_URL = "alarm.wsUrl"
@@ -0,0 +1,90 @@
package com.dcntrlized.alarm
import android.content.Context
/**
* Durable record of whether this device is actually listening for triggers.
*
* Why this exists: a foreground service can be stopped by the OS without the
* user asking — the `dataSync` 6-hour cap on API 35+ did exactly that, and even
* the `specialUse` type we now use will be subject to any future platform time
* limit. When that happens the system calls
* [AlarmForegroundService.onTimeout] and the service stops — the socket is
* gone, but a stale "armed" claim would keep telling the user otherwise. That
* is the worst possible failure for this app: the user believes they are
* covered and silently is not.
*
* The service therefore writes its real state here, and the JS layer reads it
* back through `AlarmModule.getServiceState()` so the UI can say
* "this device stopped listening" instead of pretending to be armed.
*
* Stored in SharedPreferences (not in memory) because the service can be killed
* while the app is not running; the next app open must still find the record.
*/
object AlarmListeningState {
private const val PREFS = "alarm.listening.v1"
private const val KEY_STATE = "state"
private const val KEY_SINCE = "since"
private const val KEY_ALERT = "alertId"
/** The armed socket is open (or being reconnected) in the foreground service. */
const val STATE_RUNNING = "running"
/** No service is meant to be running: the user disarmed, left or stopped it. */
const val STATE_IDLE = "idle"
/** The OS hit a foreground-service time limit; listening stopped without the user asking. */
const val STATE_TIMED_OUT = "stopped-timeout"
/**
* The socket delivered a trigger but the alarm could not be scheduled (exact
* alarms revoked at runtime, or `setAlarmClock` refused). The device is
* listening yet unable to actually ring — a blocking error, not a degraded
* mode.
*/
const val STATE_DELIVERY_FAILED = "delivery-failed"
data class Snapshot(val state: String, val sinceMillis: Long, val alertId: String?)
fun markRunning(context: Context, alertId: String) {
write(context, STATE_RUNNING, alertId)
}
fun markIdle(context: Context) {
write(context, STATE_IDLE, null)
}
/**
* Records a system-imposed stop. Deliberately *not* [STATE_IDLE]: idle means
* "nothing was supposed to be listening", which would let the UI claim the
* user is covered when the system stopped us.
*/
fun markTimedOut(context: Context) {
val previous = snapshot(context)
write(context, STATE_TIMED_OUT, previous.alertId)
}
fun markDeliveryFailed(context: Context) {
val previous = snapshot(context)
write(context, STATE_DELIVERY_FAILED, previous.alertId)
}
fun snapshot(context: Context): Snapshot {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return Snapshot(
state = prefs.getString(KEY_STATE, null) ?: STATE_IDLE,
sinceMillis = prefs.getLong(KEY_SINCE, 0L),
alertId = prefs.getString(KEY_ALERT, null)
)
}
private fun write(context: Context, state: String, alertId: String?) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_STATE, state)
.putLong(KEY_SINCE, System.currentTimeMillis())
.apply { if (alertId == null) remove(KEY_ALERT) else putString(KEY_ALERT, alertId) }
.apply()
}
}
@@ -33,9 +33,9 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
override fun getName(): String = NAME
@ReactMethod
fun playAlarm(alertId: String, message: String, autoStopSeconds: Double, promise: Promise) {
fun playAlarm(alertId: String, message: String, promise: Promise) {
try {
val volumeRaised = AlarmPlayer.play(reactContext, alertId, autoStopSeconds.toInt())
val volumeRaised = AlarmPlayer.play(reactContext, alertId)
promise.resolve(result(ok = true, volumeRaised = volumeRaised))
} catch (e: Exception) {
promise.resolve(result(ok = false, reason = e.message ?: "play-failed"))
@@ -46,7 +46,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
fun stopAlarm(promise: Promise) {
try {
AlarmPlayer.stop(reactContext)
AlarmNotifier.cancelAlarm(reactContext)
AlarmNotifier.cancelVolumeWarning(reactContext)
promise.resolve(result(ok = true))
} catch (e: Exception) {
promise.resolve(result(ok = false, reason = e.message ?: "stop-failed"))
@@ -65,6 +65,17 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
promise.resolve(result(ok = false, reason = "notifications-not-granted"))
return
}
// A device that cannot fire a real alarm must not look armed. These
// are blocking errors, not warnings: there is no notification-only
// fallback by design.
if (!AlarmScheduler.canScheduleExactAlarms(reactContext)) {
promise.resolve(result(ok = false, reason = "exact-alarms-not-granted"))
return
}
if (!hasFullScreenIntent()) {
promise.resolve(result(ok = false, reason = "full-screen-intent-not-granted"))
return
}
AlarmForegroundService.start(reactContext, alertId, wsUrl)
promise.resolve(result(ok = true))
} catch (e: Exception) {
@@ -140,10 +151,6 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
val map = Arguments.createMap()
map.putString("alertId", alertId)
map.putString("message", intent?.getStringExtra(AlarmActivity.EXTRA_MESSAGE) ?: "")
map.putInt(
"autoStopSeconds",
intent?.getIntExtra(AlarmActivity.EXTRA_AUTO_STOP, 0) ?: 0
)
intent?.removeExtra(AlarmActivity.EXTRA_ALERT_ID)
promise.resolve(map)
} catch (e: Exception) {
@@ -156,6 +163,60 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
promise.resolve(isBatteryUnrestricted())
}
/**
* Whether the armed foreground service is really listening.
*
* `state` is one of `running` | `idle` | `stopped` | `stopped-timeout` |
* `delivery-failed`. The last two mean the OS stopped us or the alarm could
* not be scheduled: the UI must report "not covered" rather than trusting a
* stale in-memory flag, because the service can be killed while the app is
* not running.
*/
@ReactMethod
fun getServiceState(promise: Promise) {
try {
val snapshot = AlarmListeningState.snapshot(reactContext)
val map = Arguments.createMap()
map.putString("state", snapshot.state)
map.putDouble("since", snapshot.sinceMillis.toDouble())
snapshot.alertId?.let { map.putString("alertId", it) }
promise.resolve(map)
} catch (e: Exception) {
val map = Arguments.createMap()
map.putString("state", "unknown")
map.putDouble("since", 0.0)
promise.resolve(map)
}
}
/**
* Cryptographically random lowercase hex token, exactly [chars] characters.
*
* Backed by `java.security.SecureRandom` — **not** `Math.random()`. The JS
* layer uses this for the per-install device id, which is the user's only
* pseudonymous identity on the alarm service; a predictable id would let
* anyone enumerate or impersonate subscriptions. No hardware identifier is
* read on this path: the value is pure randomness, stored locally.
*/
@ReactMethod
fun randomToken(chars: Double, promise: Promise) {
try {
val length = chars.toInt().coerceIn(8, 64)
val bytes = ByteArray((length + 1) / 2)
java.security.SecureRandom().nextBytes(bytes)
val hex = "0123456789abcdef"
val builder = StringBuilder(length)
for (b in bytes) {
val v = b.toInt() and 0xff
builder.append(hex[v ushr 4])
if (builder.length < length) builder.append(hex[v and 0x0f])
}
promise.resolve(builder.toString())
} catch (e: Exception) {
promise.resolve(null)
}
}
@ReactMethod
fun openFullScreenIntentSettings(promise: Promise) {
openSettings(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT)
@@ -37,7 +37,7 @@ object AlarmPlayer {
private val VIBRATION_PATTERN = longArrayOf(0L, 700L, 400L)
@Synchronized
fun play(context: Context, alertId: String, autoStopSeconds: Int): Boolean {
fun play(context: Context, alertId: String): Boolean {
stop(context)
val volumeRaised = raiseAlarmStream(context)
@@ -62,7 +62,13 @@ object AlarmPlayer {
}
startVibration(context)
AlarmNotifier.showAlarmNotification(context, alertId, volumeRaised, autoStopSeconds)
// The only notification this app posts about a firing alarm: the volume
// warning. Everything else is the real alarm (sound + takeover).
if (!volumeRaised) {
AlarmNotifier.showVolumeWarning(context, alertId, alarmVolumePercent(context))
} else {
AlarmNotifier.cancelVolumeWarning(context)
}
return volumeRaised
}
@@ -106,6 +112,22 @@ object AlarmPlayer {
}
}
/**
* Current alarm-stream volume as a percentage, for the honest warning copy
* ("Alarm volume is at 40%"). Returns 100 when the value cannot be read, so
* the warning never invents a number it does not have.
*/
fun alarmVolumePercent(context: Context): Int {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return 100
return try {
val max = am.getStreamMaxVolume(AudioManager.STREAM_ALARM)
if (max <= 0) return 100
(am.getStreamVolume(AudioManager.STREAM_ALARM) * 100 / max).coerceIn(0, 100)
} catch (e: Exception) {
100
}
}
private fun startVibration(context: Context) {
val v: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
@@ -128,101 +150,96 @@ object AlarmPlayer {
}
}
/** Notification channels + the alarm notification, including the volume fallback. */
/**
* The only two notifications this app is allowed to post.
*
* Product ruling: a notification is never a delivery path. An alert fires as a
* real alarm (sound + takeover through [AlarmScheduler]). Notifications exist
* only for
* 1. the **volume warning** — "your alarm volume is not at maximum", which the
* user explicitly allows and which tells them something true and useful, and
* 2. the **foreground-service notification** — mandatory while the armed
* socket is held; given the quietest treatment the platform offers
* (IMPORTANCE_MIN, no sound, no vibration, no badge, no heads-up) because
* it is a legal requirement, not a delivery mechanism.
*/
object AlarmNotifier {
const val CHANNEL_ALARM = "alarm_trigger"
/** Volume warning. High importance so it is seen, but it is not the alarm. */
const val CHANNEL_VOLUME = "alarm_volume_warning"
/** Foreground-service presence. Deliberately the quietest channel. */
const val CHANNEL_SERVICE = "alarm_armed"
fun ensureChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = context.getSystemService(NotificationManager::class.java) ?: return
val alarm = NotificationChannel(
CHANNEL_ALARM,
"Alarm triggers",
val volume = NotificationChannel(
CHANNEL_VOLUME,
"Alarm volume",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Fires at full volume when an alert is triggered"
enableVibration(true)
description = "Tells you when the alarm volume is not at maximum, so the alert can be heard"
enableVibration(false)
setShowBadge(true)
// The alarm is the payload: bypass DND where the user allows it.
setBypassDnd(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
nm.createNotificationChannel(alarm)
nm.createNotificationChannel(volume)
val service = NotificationChannel(
CHANNEL_SERVICE,
"Armed alert",
NotificationManager.IMPORTANCE_LOW
NotificationManager.IMPORTANCE_MIN
).apply {
description = "Keeps the connection to the alarm service open while armed"
description = "Required while the app holds the armed connection; it makes no sound"
setShowBadge(false)
enableVibration(false)
setSound(null, null)
lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET
}
nm.createNotificationChannel(service)
}
/**
* High-priority alarm notification. When `volumeRaised` is false the
* notification carries a "Raise your volume" action, because the OS refused
* the forced alarm stream and the user has to do it.
* "Alarm volume is at 40% — raise it to maximum so this alert can be heard."
* No full-screen intent, no "an alarm is ringing": this is informational.
*/
fun showAlarmNotification(
context: Context,
alertId: String,
volumeRaised: Boolean,
autoStopSeconds: Int
) {
fun showVolumeWarning(context: Context, alertId: String, volumePercent: Int) {
ensureChannels(context)
val nm = context.getSystemService(NotificationManager::class.java) ?: return
val open = android.app.PendingIntent.getActivity(
val raise = android.app.PendingIntent.getActivity(
context,
alertId.hashCode(),
AlarmActivity.intent(context, alertId, "", autoStopSeconds),
alertId.hashCode() + 1,
android.content.Intent(android.provider.Settings.ACTION_SOUND_SETTINGS),
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
val builder = android.app.Notification.Builder(context, CHANNEL_ALARM)
.setContentTitle("Alarm")
.setContentText(if (volumeRaised) "Alarm ringing" else "Raise your volume to hear the alarm")
val builder = android.app.Notification.Builder(context, CHANNEL_VOLUME)
.setContentTitle("Alarm volume is at $volumePercent%")
.setContentText("Raise it to maximum so this alert can be heard.")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setCategory(android.app.Notification.CATEGORY_ALARM)
.setPriority(android.app.Notification.PRIORITY_MAX)
.setAutoCancel(false)
.setOngoing(true)
.setVisibility(android.app.Notification.VISIBILITY_PUBLIC)
.setContentIntent(open)
.setFullScreenIntent(open, true)
if (!volumeRaised) {
val raise = android.app.PendingIntent.getActivity(
context,
alertId.hashCode() + 1,
android.content.Intent(android.provider.Settings.ACTION_SOUND_SETTINGS),
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(
android.app.Notification.Action.Builder(
null,
"Raise your volume",
raise
).build()
)
}
.setCategory(android.app.Notification.CATEGORY_RECOMMENDATION)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(raise)
try {
nm.notify(NOTIFICATION_ID_ALARM, builder.build())
nm.notify(NOTIFICATION_ID_VOLUME, builder.build())
} catch (e: SecurityException) {
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
}
}
fun cancelAlarm(context: Context) {
fun cancelVolumeWarning(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java) ?: return
nm.cancel(NOTIFICATION_ID_ALARM)
nm.cancel(NOTIFICATION_ID_VOLUME)
}
const val NOTIFICATION_ID_ALARM = 4711
/** Removes the persistent "Alarm armed" notification. */
fun cancelArmed(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java) ?: return
nm.cancel(NOTIFICATION_ID_SERVICE)
}
const val NOTIFICATION_ID_VOLUME = 4711
const val NOTIFICATION_ID_SERVICE = 4712
}
@@ -0,0 +1,234 @@
package com.dcntrlized.alarm
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
/**
* Schedules a **real alarm** through [AlarmManager.setAlarmClock].
*
* ## Why not just `startActivity`
*
* Android 14 (API 34) made `USE_FULL_SCREEN_INTENT` a restricted permission:
* the system grants it automatically only to apps it classifies as call/alarm
* apps, and revokes it for everyone else. The platform's own answer to "what is
* an alarm app" is exact-alarm behaviour, and `setAlarmClock` is the strongest
* form of it — it is the API the Clock app uses. Firing the alarm this way is
* therefore both the delivery mechanism *and* the thing that earns the
* classification. A notification is never a delivery path in this app: the only
* notification we post about a firing alert is the volume warning.
*
* It is also more robust than starting the activity directly: the alarm lives in
* `AlarmManager`, so it still fires if the JS layer or our service died between
* the trigger frame arriving and the alarm going off.
*
* ## Standing entry
*
* While an alert is armed we also keep a recurring alarm-clock entry
* ([holdStanding]). Two reasons: the classification above may be evaluated at a
* moment other than "while posting the notification", and a standing entry is
* the honest status-bar "this device is armed" indicator. The same wake is the
* re-arm point if the service is ever stopped by the platform.
*
* **Unverified on hardware:** that a standing alarm clock actually causes
* `canUseFullScreenIntent()` to return true (or that scheduling lifts the
* restriction at all). That chain comes from platform documentation, not from a
* measurement on a real API 34/35/36 device.
*/
object AlarmScheduler {
private const val TAG = "AlarmScheduler"
/**
* Trigger-to-alarm scheduling margin.
*
* `setAlarmClock` is exact: there is no bucket to round up to, so the only
* question is how far in the future the trigger time must be. `now + 0` can
* land in the past by the time the binder call is serviced (clock ticks and
* some ROMs treat a non-future trigger as already fired), so we keep a
* small margin. 250 ms is above any plausible service latency and far below
* a perceptible delay — and it is deliberately *not* a fixed 1 s "so the OS
* notices": that was an arbitrary round-up, not a platform requirement.
*
* Trial on hardware is still the final word on the smallest safe value; the
* budget we report is socket (p50 8 ms, max 23 ms measured) + this margin +
* activity launch.
*/
private const val FIRE_DELAY_MS = 250L
/** Standing self-check cadence. Also the re-arm point if the service stops. */
private const val STANDING_INTERVAL_MS = 30 * 60 * 1000L
/** After a reboot, re-arm almost immediately (a boot is already a state change). */
private const val BOOT_DELAY_MS = 60 * 1000L
private const val REQ_FIRE = 4201
private const val REQ_STANDING = 4202
private const val REQ_SHOW = 4203
const val ACTION_STANDING = "com.dcntrlized.alarm.STANDING"
const val EXTRA_ALERT_ID = "alarm.scheduler.alertId"
/**
* `USE_EXACT_ALARM` (declared in the manifest) makes this true without user
* action on API 33+, which is the point of off-Play distribution. On API
* 31/32 only `SCHEDULE_EXACT_ALARM` applies and the user grant is required.
*/
fun canScheduleExactAlarms(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
return am.canScheduleExactAlarms()
}
/**
* Schedules the takeover [FIRE_DELAY_MS] from now. Returns false (never
* falls back to a notification) when the OS refuses: the caller records a
* delivery failure that the UI must surface.
*/
fun fireNow(context: Context, alertId: String, message: String): Boolean {
if (!canScheduleExactAlarms(context)) {
Log.w(TAG, "cannot fire: exact alarms not allowed")
return false
}
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
return try {
val operation = PendingIntent.getActivity(
context,
REQ_FIRE,
AlarmActivity.intent(context, alertId, message),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
am.setAlarmClock(
AlarmManager.AlarmClockInfo(
System.currentTimeMillis() + FIRE_DELAY_MS,
showIntent(context)
),
operation
)
Log.i(TAG, "alarm clock scheduled for $alertId")
true
} catch (e: SecurityException) {
// The documented failure when exact alarms are denied.
Log.w(TAG, "setAlarmClock denied: ${e.message}")
false
} catch (e: Exception) {
Log.w(TAG, "setAlarmClock failed: ${e.message}")
false
}
}
/**
* (Re)creates the standing alarm-clock entry while an alert is armed.
*
* The `operation` is a broadcast to [AlarmWakeReceiver], which re-arms the
* entry and restarts the armed service when it is not running — so the
* entry is self-perpetuating and survives the process being killed.
*/
fun holdStanding(
context: Context,
alertId: String,
delayMs: Long = STANDING_INTERVAL_MS
): Boolean {
if (!canScheduleExactAlarms(context)) {
Log.w(TAG, "cannot hold standing entry: exact alarms not allowed")
return false
}
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
return try {
val operation = PendingIntent.getBroadcast(
context,
REQ_STANDING,
Intent(context, AlarmWakeReceiver::class.java)
.setAction(ACTION_STANDING)
.putExtra(EXTRA_ALERT_ID, alertId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
am.setAlarmClock(
AlarmManager.AlarmClockInfo(System.currentTimeMillis() + delayMs, showIntent(context)),
operation
)
true
} catch (e: Exception) {
Log.w(TAG, "standing setAlarmClock failed: ${e.message}")
false
}
}
/** Rebuilds the standing entry after a reboot without restarting the socket. */
fun rearmAfterBoot(context: Context): Boolean {
val armed = AlarmArmStore.current(context) ?: return false
return holdStanding(context, armed.alertId, BOOT_DELAY_MS)
}
/**
* Cancels only the one-shot firing entry.
*
* Used on **disarm**: if a trigger frame scheduled the takeover a second ago
* and the owner disarms in that window, the alarm must not ring afterwards.
* The standing entry deliberately survives, because a disarm ends the
* ringing, not the subscription — the device is still armed and can still be
* triggered, so its alarm-app status must not disappear.
*/
fun cancelFire(context: Context) {
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
try {
am.cancel(
PendingIntent.getActivity(
context,
REQ_FIRE,
AlarmActivity.intent(context, "", ""),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
} catch (e: Exception) {
Log.w(TAG, "cancelFire failed: ${e.message}")
}
}
/**
* Removes both alarm-clock entries. Called on explicit stop, unsubscribe and
* alert deletion — otherwise the user keeps a permanent alarm icon for an
* alert they no longer follow.
*/
fun cancel(context: Context) {
cancelFire(context)
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
try {
// PendingIntent identity ignores extras, so this matches the entry
// created in holdStanding. cancel() on an absent alarm is a no-op.
am.cancel(
PendingIntent.getBroadcast(
context,
REQ_STANDING,
Intent(context, AlarmWakeReceiver::class.java).setAction(ACTION_STANDING),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
} catch (e: Exception) {
Log.w(TAG, "cancel failed: ${e.message}")
}
}
/** True when the standing "armed" entry is present in the OS. */
fun hasStanding(context: Context): Boolean = try {
PendingIntent.getBroadcast(
context,
REQ_STANDING,
Intent(context, AlarmWakeReceiver::class.java).setAction(ACTION_STANDING),
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
) != null
} catch (e: Exception) {
false
}
private fun showIntent(context: Context): PendingIntent =
PendingIntent.getActivity(
context,
REQ_SHOW,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
@@ -0,0 +1,51 @@
package com.dcntrlized.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* Keeps the standing alarm-clock entry alive and re-arms the armed service.
*
* Two responsibilities:
* - **After a reboot** (`BOOT_COMPLETED`/`LOCKED_BOOT_COMPLETED`): rebuild the
* standing alarm-clock entry, so the OS still sees this app as holding an
* alarm. Starting a foreground service straight from boot is restricted on
* Android 12+, so we only schedule here — the exact-alarm broadcast a minute
* later is allowed to start the service, and that path does it.
* - **On the standing wake**: re-arm the entry (it is one-shot, so it must
* chain itself) and restart the armed socket if it is not running. This is
* also the re-arm point if the platform stops the service.
*
* Everything here is best-effort and recorded honestly: a missing entry is
* visible as a delivery problem in the app, never silently assumed away.
*/
class AlarmWakeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_LOCKED_BOOT_COMPLETED,
"android.intent.action.QUICKBOOT_POWERON" -> {
val armed = AlarmArmStore.current(context) ?: return
val ok = AlarmScheduler.rearmAfterBoot(context)
Log.i(TAG, "boot: standing entry re-armed=$ok for ${armed.alertId}")
}
AlarmScheduler.ACTION_STANDING -> {
val armed = AlarmArmStore.current(context) ?: return
// Chain the next wake before doing anything that could fail.
AlarmScheduler.holdStanding(context, armed.alertId)
if (!AlarmForegroundService.isRunning) {
Log.i(TAG, "standing wake: restarting armed service for ${armed.alertId}")
AlarmForegroundService.start(context, armed.alertId, armed.wsUrl)
}
}
}
}
private companion object {
const val TAG = "AlarmWakeReceiver"
}
}