From 820c82cc2d1f3e9c01c4f833c86265b55859097e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 21:49:01 +0000 Subject: [PATCH] Re-arm alarms on every app start, and report when Android has dropped them Third report of an alarm set hours ahead not ringing while one set minutes ahead works. That difference points at the schedule being lost rather than the ring path failing, and there was a real gap behind it. AlarmManager entries survive ordinary process death but not a force-stop, and force-stopping idle apps is what OEM battery managers do. Nothing tells an app it happened. rescheduleAll() was only ever called from BootReceiver, so a dropped schedule stayed dropped until the next reboot while the alarm list went on showing the alarm as enabled - a silent inconsistency whose only symptom is not waking up. HaAlarmClockApp.onCreate now re-arms everything on every process start. Rescheduling is idempotent, so this is just a repair pass that any run of the app performs. Adds ScheduleHealth, which compares the app's next trigger against AlarmManager.getNextAlarmClock() - the same source as the status-bar alarm icon - and surfaces the result in Settings with a Re-arm button, so a dropped schedule is visible instead of silent. That API reports the next alarm clock across all apps, so it is conclusive in only two directions; when another app has a sooner alarm the check reports that rather than guessing. Also documents the limit honestly: an app cannot make itself force-stop-proof, and once Android force-stops it nothing of its own runs again until something launches it. The README now describes the redundancy that does close that gap - a Home Assistant backup automation firing when the alarm time passes with no ring reported, on hardware the phone's battery manager has no say over. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7x8v5c5Lu29PBWy1LdfCe --- README.md | 60 ++++++++++++++++++- .../haalarmclock/HaAlarmClockApp.kt | 10 ++++ .../haalarmclock/alarm/ScheduleHealth.kt | 54 +++++++++++++++++ .../ui/settings/SettingsScreen.kt | 58 ++++++++++++++++++ 4 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/targetcrafter/haalarmclock/alarm/ScheduleHealth.kt diff --git a/README.md b/README.md index 5fa3382..1ca5805 100644 --- a/README.md +++ b/README.md @@ -597,9 +597,63 @@ happen) diagnosable: ringing service, so testing can't push "an alarm is ringing" to Home Assistant and set off whatever you've automated against it. -None of this can fully rule out OEM-specific background-kill behavior the OS doesn't -expose any API to detect or work around — the battery optimization exemption above is -the app's best available defense against that class of failure. +- **Alarms are re-armed on every process start, not just after a reboot.** `AlarmManager` + entries survive ordinary process death, but *not* a force-stop — and force-stopping idle + apps is exactly what OEM battery managers do. Nothing tells an app it happened, so the + alarm list would go on showing an alarm as enabled while the OS held no record of it. + `HaAlarmClockApp.onCreate` now re-arms everything (idempotent — rescheduling replaces the + existing entry), so any run of the app repairs a dropped schedule. +- **Settings reports whether Android actually holds the next alarm** (`ScheduleHealth`), + by comparing the app's next trigger against `AlarmManager.getNextAlarmClock()` — the same + source as the status-bar alarm icon. A dropped schedule becomes something you can see, + with a "Re-arm" button, instead of something you discover by not waking up. Because that + API reports the next alarm clock across *all* apps, it's conclusive in only two + directions; when another app has a sooner alarm the check says so rather than guessing. + +### The limit of what the app can guarantee + +Being honest about this matters more than reassurance: **an app cannot make itself +force-stop-proof.** Once Android force-stops an app, its alarms are cancelled, and nothing +belonging to it runs again — no service, no receiver, no `WorkManager` job, no watchdog — +until something launches it. Every defense above raises the odds; none makes it certain. + +What genuinely closes the gap is redundancy *outside* the phone, which this project happens +to be well placed for: Home Assistant already knows when the next alarm is due +(`sensor._next_alarm`) and whether the phone reported ringing +(`binary_sensor._ringing`). A backup automation that fires if the alarm time passes +without the phone ever reporting a ring gives you a second, independent alarm on hardware +the phone's battery manager has no say over: + +```yaml +- alias: Backup alarm if the phone didn't ring + trigger: + - platform: time + at: + entity_id: sensor.pixel_8_alarm_clock_next_alarm + offset: "00:02:00" # two minutes after it should have gone off + condition: + - condition: state + entity_id: binary_sensor.pixel_8_alarm_clock_ringing + state: "off" # the phone never reported ringing + action: + - service: media_player.volume_set + target: + entity_id: media_player.bedroom_speaker + data: + volume_level: 0.7 + - service: tts.speak + target: + entity_id: tts.piper + data: + media_player_entity_id: media_player.bedroom_speaker + message: Your phone alarm did not go off. + - service: light.turn_on + target: + entity_id: light.bedroom +``` + +For an alarm that has to work every time, that redundancy is worth more than any further +hardening inside the app. ## Security notes diff --git a/app/src/main/java/com/targetcrafter/haalarmclock/HaAlarmClockApp.kt b/app/src/main/java/com/targetcrafter/haalarmclock/HaAlarmClockApp.kt index 1c86e3f..1ee8ca8 100644 --- a/app/src/main/java/com/targetcrafter/haalarmclock/HaAlarmClockApp.kt +++ b/app/src/main/java/com/targetcrafter/haalarmclock/HaAlarmClockApp.kt @@ -81,6 +81,16 @@ class HaAlarmClockApp : Application() { override fun onCreate() { super.onCreate() createNotificationChannels() + // Re-arm everything on every process start, not just after a reboot (BootReceiver). An + // AlarmManager entry is dropped when an app is force-stopped — which is exactly what OEM + // battery managers do to apps they decide are idle — and nothing notifies the app. Without + // this, the alarm list would go on showing an alarm as enabled while the OS held no record + // of it, and the only symptom would be not waking up. Re-arming is idempotent: scheduling + // the same alarm again just replaces its existing entry. + applicationScope.launch { + repository.rescheduleAll() + timerRepository.rescheduleAll() + } // Keeps home-screen clock widgets' "next alarm" line and colors live even when HA sync is // off and no other component is otherwise watching the alarms table. applicationScope.launch { diff --git a/app/src/main/java/com/targetcrafter/haalarmclock/alarm/ScheduleHealth.kt b/app/src/main/java/com/targetcrafter/haalarmclock/alarm/ScheduleHealth.kt new file mode 100644 index 0000000..c118ea8 --- /dev/null +++ b/app/src/main/java/com/targetcrafter/haalarmclock/alarm/ScheduleHealth.kt @@ -0,0 +1,54 @@ +package com.targetcrafter.haalarmclock.alarm + +import android.app.AlarmManager +import android.content.Context +import androidx.core.content.getSystemService +import com.targetcrafter.haalarmclock.data.Alarm + +/** + * Whether Android itself still holds the alarm this app thinks is next. + * + * The app's own database is not evidence that anything is scheduled. `AlarmManager` entries are + * dropped when an app is force-stopped — which is what OEM battery managers do to apps they decide + * are idle — and nothing tells the app it happened. The alarm list keeps showing the alarm as + * enabled while the OS has no record of it, and the first sign of trouble is not waking up. + * + * [AlarmManager.getNextAlarmClock] is the same source Android uses for the status-bar alarm icon, + * so it reports the next alarm clock across *all* apps. That makes it conclusive in only two + * directions, which is why [Inconclusive] exists rather than a reassuring guess. + */ +sealed interface ScheduleHealth { + /** Nothing is enabled, so there is nothing to verify. */ + data object NoAlarms : ScheduleHealth + + /** The OS reports an alarm clock at exactly the time we expect. */ + data object Registered : ScheduleHealth + + /** The OS reports no alarm clock at all, or one only *after* ours — either way ours is gone. */ + data class Missing(val expectedAtMillis: Long) : ScheduleHealth + + /** Some other app has a sooner alarm clock, which hides ours from this check. */ + data object Inconclusive : ScheduleHealth +} + +/** Tolerance for matching the OS's trigger time against ours; they are set from the same value, so + * this only absorbs rounding rather than any real drift. */ +private const val MATCH_TOLERANCE_MILLIS = 1_000L + +fun checkScheduleHealth(context: Context, alarms: List): ScheduleHealth { + val next = alarms + .filter { it.enabled && it.hasValidTime } + .minByOrNull { it.nextTriggerAtMillis() } + ?: return ScheduleHealth.NoAlarms + + val expected = next.nextTriggerAtMillis() + val system = context.getSystemService()?.nextAlarmClock + ?: return ScheduleHealth.Missing(expected) + + return when { + kotlin.math.abs(system.triggerTime - expected) <= MATCH_TOLERANCE_MILLIS -> ScheduleHealth.Registered + // Ours would have been reported if it were registered and sooner than what we got back. + system.triggerTime > expected -> ScheduleHealth.Missing(expected) + else -> ScheduleHealth.Inconclusive + } +} diff --git a/app/src/main/java/com/targetcrafter/haalarmclock/ui/settings/SettingsScreen.kt b/app/src/main/java/com/targetcrafter/haalarmclock/ui/settings/SettingsScreen.kt index 0166f24..0c22d8a 100644 --- a/app/src/main/java/com/targetcrafter/haalarmclock/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/targetcrafter/haalarmclock/ui/settings/SettingsScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -71,7 +72,9 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.targetcrafter.haalarmclock.HaAlarmClockApp import com.targetcrafter.haalarmclock.alarm.AlarmSoundTestResult +import com.targetcrafter.haalarmclock.alarm.ScheduleHealth import com.targetcrafter.haalarmclock.alarm.alarmVolume +import com.targetcrafter.haalarmclock.alarm.checkScheduleHealth import com.targetcrafter.haalarmclock.alarm.startAlarmSoundTest import com.targetcrafter.haalarmclock.data.AppDefaults import com.targetcrafter.haalarmclock.data.ClockStyle @@ -82,6 +85,7 @@ import com.targetcrafter.haalarmclock.ha.startHaSyncServiceIfConfigured import com.targetcrafter.haalarmclock.ui.appViewModelFactory import com.targetcrafter.haalarmclock.util.canUseFullScreenIntent import com.targetcrafter.haalarmclock.util.isIgnoringBatteryOptimizations +import kotlinx.coroutines.launch private val PRESET_COLORS = listOf( 0xFF1B1B1B.toInt(), // near-black (default widget background) @@ -382,9 +386,63 @@ private fun ReliabilitySection() { }, ) } + ScheduleHealthRow() AlarmSoundTestRow() } +/** + * Reports whether Android itself still holds the alarm the app thinks is next. The app's own list + * is not evidence: a force-stop drops the OS's alarm entries silently, so an alarm can read as + * enabled here while nothing is actually scheduled — which is invisible until the morning it + * doesn't ring. See [checkScheduleHealth]. + */ +@Composable +private fun ScheduleHealthRow() { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val app = HaAlarmClockApp.from(context) + val scope = rememberCoroutineScope() + val alarms by app.repository.alarms.collectAsState(initial = emptyList()) + + var health by remember { mutableStateOf(ScheduleHealth.NoAlarms) } + // Recheck whenever the alarms change and on every return to the screen — the OS can drop the + // entry at any time, without anything here changing. + LaunchedEffect(alarms) { health = checkScheduleHealth(context, alarms) } + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) health = checkScheduleHealth(context, alarms) + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + when (health) { + is ScheduleHealth.Missing -> ReliabilityWarningRow( + title = "Android isn't holding your next alarm", + description = "The alarm is switched on here, but the system has no record of it — " + + "which means it will not ring. This happens when the app gets force-stopped, " + + "usually by a battery manager. Re-arm it, then exempt the app from battery " + + "optimization above and from any \"sleeping apps\" list your phone has.", + buttonLabel = "Re-arm", + onClick = { + scope.launch { + app.repository.rescheduleAll() + health = checkScheduleHealth(context, alarms) + } + }, + ) + is ScheduleHealth.Registered -> ListItem( + headlineContent = { Text("Next alarm is registered with Android") }, + supportingContent = { Text("The system is holding it, so it will ring even if this app is closed.") }, + ) + is ScheduleHealth.Inconclusive -> ListItem( + headlineContent = { Text("Another app has a sooner alarm") }, + supportingContent = { Text("That hides this app's alarm from the check, so it can't be confirmed from here.") }, + ) + is ScheduleHealth.NoAlarms -> Unit + } +} + /** * Plays the alarm sound on demand, through the same ringtone candidates and audio attributes a * real alarm uses. An alarm that fails only at 07:00 is close to undebuggable — this turns