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;