Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<phone>_next_alarm`) and whether the phone reported ringing
(`binary_sensor.<phone>_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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Alarm>): ScheduleHealth {
val next = alarms
.filter { it.enabled && it.hasValidTime }
.minByOrNull { it.nextTriggerAtMillis() }
?: return ScheduleHealth.NoAlarms

val expected = next.nextTriggerAtMillis()
val system = context.getSystemService<AlarmManager>()?.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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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>(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
Expand Down
Loading