diff --git a/__tests__/nativeDelivery.test.ts b/__tests__/nativeDelivery.test.ts
new file mode 100644
index 0000000..2ec54b7
--- /dev/null
+++ b/__tests__/nativeDelivery.test.ts
@@ -0,0 +1,161 @@
+/* The app's tsconfig carries only jest types, so read sources through jest's
+ own real-module loader rather than widening the compiler config for a test. */
+declare const __dirname: string;
+const nodeFs = jest.requireActual('fs') as {
+ readFileSync(path: string, encoding: string): string;
+};
+const nodePath = jest.requireActual('path') as { join(...parts: string[]): string };
+
+/**
+ * Source-level contract checks for the native alarm delivery path.
+ *
+ * Kotlin behaviour cannot be executed in this Jest run (no Android runtime, no
+ * device), so these assertions pin the *structure* the design depends on:
+ * delivery goes through `AlarmManager.setAlarmClock`, there is no
+ * notification-based delivery path, the standing entry is cancelled when the
+ * device stops following an alert, and the boot receiver re-arms it.
+ *
+ * They are deliberately narrow. They prove the calls exist; they cannot prove
+ * that Android accepts them, nor that scheduling lifts the full-screen-intent
+ * restriction. Those are listed as unverified in the report.
+ */
+
+const NATIVE = nodePath.join(__dirname, '..', 'android', 'app', 'src', 'main', 'java', 'com', 'dcntrlized', 'alarm');
+const MANIFEST = nodePath.join(__dirname, '..', 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
+
+function native(file: string): string {
+ return nodeFs.readFileSync(nodePath.join(NATIVE, file), 'utf8');
+}
+
+describe('native alarm delivery contract', () => {
+ const scheduler = native('AlarmScheduler.kt');
+ const service = native('AlarmForegroundService.kt');
+ const notifier = native('AlarmPlayer.kt');
+ const receiver = native('AlarmWakeReceiver.kt');
+ const bridge = native('AlarmModule.kt');
+ const player = native('AlarmPlayer.kt');
+ const manifest = nodeFs.readFileSync(MANIFEST, 'utf8');
+
+ it('schedules a real alarm clock with AlarmManager.setAlarmClock', () => {
+ expect(scheduler).toContain('setAlarmClock(');
+ expect(scheduler).toContain('AlarmClockInfo(');
+ expect(scheduler).toContain('AlarmActivity.intent(');
+ expect(scheduler).toContain('canScheduleExactAlarms');
+ });
+
+ it('fires the alarm from the trigger frame through the scheduler, not startActivity', () => {
+ expect(service).toContain('AlarmScheduler.fireNow(');
+ expect(service).not.toMatch(/startActivity\(AlarmActivity\.intent/);
+ // A failed schedule is recorded, never replaced by a notification.
+ expect(service).toContain('markDeliveryFailed');
+ });
+
+ it('holds a standing alarm-clock entry while armed and re-arms after reboot', () => {
+ expect(service).toContain('AlarmScheduler.holdStanding(');
+ expect(receiver).toContain('ACTION_BOOT_COMPLETED');
+ expect(receiver).toContain('rearmAfterBoot');
+ expect(receiver).toContain('ACTION_STANDING');
+ expect(receiver).toContain('AlarmScheduler.holdStanding(');
+ });
+
+ it('cancels the entries and forgets the arm record on stop', () => {
+ expect(service).toContain('AlarmScheduler.cancel(');
+ expect(service).toContain('AlarmArmStore.clear(');
+ });
+
+ it('keeps the standing entry across a disarm — only the firing entry dies', () => {
+ const disarmAt = service.indexOf('"disarm"');
+ expect(disarmAt).toBeGreaterThan(-1);
+ const disarm = service.slice(disarmAt, disarmAt + 500);
+ expect(disarm).toContain('AlarmScheduler.cancelFire(');
+ // A disarm ends the ring, not the subscription: cancelling the standing
+ // entry here would drop the "armed" indicator and the re-arm point.
+ expect(disarm).not.toContain('AlarmScheduler.cancel(');
+ // cancelFire must not touch the standing entry either.
+ const cancelFireAt = scheduler.indexOf('fun cancelFire(');
+ const cancelFireBody = scheduler.slice(cancelFireAt, cancelFireAt + 800);
+ expect(cancelFireBody).not.toContain('REQ_STANDING');
+ expect(cancelFireBody).toContain('REQ_FIRE');
+ });
+
+ it('has exactly two notification channels and no full-screen-intent notification', () => {
+ const channels = notifier.split('= NotificationChannel(').length - 1;
+ expect(channels).toBe(2);
+ 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');
+ });
+
+ it('only warns about volume, never about a ringing alarm', () => {
+ expect(notifier).toContain('Alarm volume is at ');
+ expect(notifier).not.toMatch(/Alarm ringing/);
+ expect(notifier).not.toMatch(/Alarm armed"\)/);
+ expect(notifier).toContain('CATEGORY_RECOMMENDATION');
+ });
+
+ it('refuses to arm a device that cannot deliver a real alarm', () => {
+ expect(bridge).toContain('exact-alarms-not-granted');
+ expect(bridge).toContain('full-screen-intent-not-granted');
+ });
+
+ it('uses the specialUse foreground-service type with a real subtype', () => {
+ // dataSync's 6h/24h cumulative cap is why the primary type changed; the
+ // subtype text is the reason shown for a specialUse service.
+ expect(manifest).toContain('android:foregroundServiceType="dataSync|specialUse"');
+ expect(manifest).toContain('PROPERTY_SPECIAL_USE_FGS_SUBTYPE');
+ expect(manifest).toContain('android.permission.FOREGROUND_SERVICE_SPECIAL_USE');
+ expect(manifest).not.toContain('FOREGROUND_SERVICE_MEDIA_PLAYBACK');
+ expect(service).toContain('FOREGROUND_SERVICE_TYPE_SPECIAL_USE');
+ // Kept as defence in depth even though specialUse has no time limit today.
+ expect(service).toContain('override fun onTimeout(');
+ });
+
+ it('schedules close to now instead of rounding up to a future bucket', () => {
+ // The answer to "31st second or next minute" is the 31st second: the
+ // trigger time is now + a small margin, never a rounded tick.
+ const match = /FIRE_DELAY_MS\s*=\s*(\d+)L/.exec(scheduler);
+ expect(match).not.toBeNull();
+ const fireDelay = Number(match ? match[1] : NaN);
+ expect(fireDelay).toBeGreaterThan(0);
+ expect(fireDelay).toBeLessThanOrEqual(1000);
+ expect(scheduler).toContain('System.currentTimeMillis() + FIRE_DELAY_MS');
+ });
+
+ it('contains no repeating, inexact or delayed scheduling', () => {
+ // No bucket quantization anywhere in the firing path. The socket reconnect
+ // backoff in the service is the only Handler use and is not scheduling an
+ // alarm (asserted below by scoping this to the scheduler/player/activity).
+ const activity = native('AlarmActivity.kt');
+ for (const src of [scheduler, player, activity]) {
+ expect(src).not.toContain('setRepeating');
+ expect(src).not.toContain('setInexactRepeating');
+ expect(src).not.toContain('setWindow');
+ expect(src).not.toContain('postDelayed');
+ expect(src).not.toMatch(/Handler\(/);
+ }
+ });
+
+ it('rings until the device user dismisses it — no auto-stop anywhere', () => {
+ // User ruling: a lost owner must not mean an alarm that expires on a timer.
+ for (const file of [
+ 'AlarmScheduler.kt',
+ 'AlarmPlayer.kt',
+ 'AlarmActivity.kt',
+ 'AlarmForegroundService.kt',
+ 'AlarmModule.kt',
+ ]) {
+ expect(native(file)).not.toMatch(/autoStop/i);
+ }
+ expect(player).not.toMatch(/stopTimer|expire|countdown/i);
+ });
+
+ it('declares the exact-alarm and boot permissions it relies on', () => {
+ expect(manifest).toContain('android.permission.USE_EXACT_ALARM');
+ expect(manifest).toContain('android.permission.SCHEDULE_EXACT_ALARM');
+ expect(manifest).toContain('android.permission.USE_FULL_SCREEN_INTENT');
+ expect(manifest).toContain('android.permission.RECEIVE_BOOT_COMPLETED');
+ expect(manifest).toContain('.AlarmWakeReceiver');
+ });
+});
diff --git a/android/app/build.gradle b/android/app/build.gradle
index bdc79c6..975024b 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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"
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6154da5..56e4163 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,24 +6,51 @@
-
+
-
+
-
+
+
+
+
+
-
+
+ android:foregroundServiceType="dataSync|specialUse">
+
+
+
+
+
+
+
+
+
+
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 5d0a683..c2b7312 100644
--- a/android/app/src/main/java/com/dcntrlized/alarm/AlarmActivity.kt
+++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmActivity.kt
@@ -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. */
diff --git a/android/app/src/main/java/com/dcntrlized/alarm/AlarmArmStore.kt b/android/app/src/main/java/com/dcntrlized/alarm/AlarmArmStore.kt
new file mode 100644
index 0000000..b22f08b
--- /dev/null
+++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmArmStore.kt
@@ -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()
+ }
+}
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 f75825e..335f373 100644
--- a/android/app/src/main/java/com/dcntrlized/alarm/AlarmForegroundService.kt
+++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmForegroundService.kt
@@ -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 ``.
+ * Manifest: `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SPECIAL_USE` +
+ * `FOREGROUND_SERVICE_DATA_SYNC`, and
+ * `foregroundServiceType="dataSync|specialUse"` on this `` — 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"
diff --git a/android/app/src/main/java/com/dcntrlized/alarm/AlarmListeningState.kt b/android/app/src/main/java/com/dcntrlized/alarm/AlarmListeningState.kt
new file mode 100644
index 0000000..378a908
--- /dev/null
+++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmListeningState.kt
@@ -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()
+ }
+}
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 280979f..705dc3a 100644
--- a/android/app/src/main/java/com/dcntrlized/alarm/AlarmModule.kt
+++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmModule.kt
@@ -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)
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 0516254..5826b7e 100644
--- a/android/app/src/main/java/com/dcntrlized/alarm/AlarmPlayer.kt
+++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmPlayer.kt
@@ -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
}
diff --git a/android/app/src/main/java/com/dcntrlized/alarm/AlarmScheduler.kt b/android/app/src/main/java/com/dcntrlized/alarm/AlarmScheduler.kt
new file mode 100644
index 0000000..2a512aa
--- /dev/null
+++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmScheduler.kt
@@ -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
+ )
+}
diff --git a/android/app/src/main/java/com/dcntrlized/alarm/AlarmWakeReceiver.kt b/android/app/src/main/java/com/dcntrlized/alarm/AlarmWakeReceiver.kt
new file mode 100644
index 0000000..6af96c4
--- /dev/null
+++ b/android/app/src/main/java/com/dcntrlized/alarm/AlarmWakeReceiver.kt
@@ -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"
+ }
+}
diff --git a/src/services/alarmNative.ts b/src/services/alarmNative.ts
index 02f7419..37393bc 100644
--- a/src/services/alarmNative.ts
+++ b/src/services/alarmNative.ts
@@ -5,8 +5,8 @@ import { NativeModules, Platform } from 'react-native';
*
* Every call is defensive: on iOS, in a JS-only test run, or on a build where
* the native module failed to register, the promise resolves to a "unavailable"
- * result instead of throwing. The UI must degrade to in-app sound + a
- * high-priority notification, and say so, rather than break.
+ * result instead of throwing, and the UI must say what it can actually do
+ * (in-app sound only) rather than invent a delivery path.
*/
export interface AlarmNativeResult {
ok: boolean;
@@ -22,7 +22,6 @@ export interface AlarmNativeResult {
export interface AlarmLaunchAlert {
alertId: string;
message: string;
- autoStopSeconds: number;
}
export interface AlarmPermissions {
@@ -34,14 +33,32 @@ export interface AlarmPermissions {
canForceVolume: boolean;
}
+/**
+ * Whether the armed foreground service is actually listening.
+ *
+ * `stopped-timeout` is the important one: Android's background limits (the
+ * a platform time limit, e.g. the `dataSync` 6-hour cap on API 35+) stopped the
+ * socket without the user asking,
+ * so the app must not keep claiming the device is armed.
+ */
+export interface AlarmServiceState {
+ state: 'running' | 'idle' | 'stopped' | 'stopped-timeout' | 'delivery-failed' | 'unknown';
+ /** Epoch millis of the last state change, or 0 when unknown. */
+ since: number;
+ /** The alert the service was holding, when it recorded one. */
+ alertId?: string;
+}
+
interface AlarmNativeModule {
- playAlarm(alertId: string, message: string, autoStopSeconds: number): Promise;
+ playAlarm(alertId: string, message: string): Promise;
stopAlarm(): Promise;
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise;
stopService(): Promise;
getPermissions(): Promise;
requestPermission(name: string): Promise;
getLaunchAlert(): Promise;
+ getServiceState(): Promise;
+ randomToken(chars: number): Promise;
isIgnoringBatteryOptimizations(): Promise;
openSoundSettings(): Promise;
openAppSettings(): Promise;
@@ -66,11 +83,10 @@ const PERMISSIONS_UNAVAILABLE: AlarmPermissions = {
export async function playAlarm(
alertId: string,
message = '',
- autoStopSeconds = 0,
): Promise {
if (!native) return UNAVAILABLE;
try {
- return await native.playAlarm(alertId, message, autoStopSeconds);
+ return await native.playAlarm(alertId, message);
} catch (e) {
return { ok: false, reason: e instanceof Error ? e.message : 'play-failed' };
}
@@ -145,6 +161,34 @@ export async function requestPermission(name: string): Promise
}
}
+/**
+ * Reads whether the armed foreground service is really listening. On a build
+ * without the native module (iOS, tests) this reports `unknown`, which the UI
+ * treats as "cannot confirm" — never as "covered".
+ */
+export async function getServiceState(): Promise {
+ if (!native) return { state: 'unknown', since: 0 };
+ try {
+ return await native.getServiceState();
+ } catch {
+ return { state: 'unknown', since: 0 };
+ }
+}
+
+/**
+ * Cryptographically random lowercase hex token (`SecureRandom` on Android).
+ * Returns `null` when the native module is unavailable — callers must fail
+ * loudly rather than fall back to a non-cryptographic source.
+ */
+export async function randomToken(chars: number): Promise {
+ if (!native) return null;
+ try {
+ return await native.randomToken(chars);
+ } catch {
+ return null;
+ }
+}
+
/** Opens the system volume UI — the takeover's volume-blocked recovery action. */
export async function openSoundSettings(): Promise {
if (!native) return UNAVAILABLE;