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