From 82bce9bf8d8cca71b38510b842e457ccfb8b3486 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 08:54:42 +0000 Subject: [PATCH 1/2] Record when Android drops the alarm schedule, before repairing it Re-arming on app start fixes a dropped schedule, but on its own it also destroys the evidence that anything went wrong: by the time anyone opens the app to look, it has been repaired and everything reads healthy. That is exactly the case where knowing whether it happened at all IS the diagnosis - a dropped schedule means the OS killed the app, while an alarm that stayed registered and still didn't ring is a bug in here, and the two need completely different fixes. The startup path now runs the health check first, records the finding, and only then re-arms. Settings shows the count and the most recent occurrence when there is one, so each failure leaves a trail instead of costing a night of observation to catch in the act. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7x8v5c5Lu29PBWy1LdfCe --- .../haalarmclock/HaAlarmClockApp.kt | 20 +++++- .../haalarmclock/data/AlarmScheduleAudit.kt | 67 +++++++++++++++++++ .../ui/settings/SettingsScreen.kt | 32 +++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/targetcrafter/haalarmclock/data/AlarmScheduleAudit.kt diff --git a/app/src/main/java/com/targetcrafter/haalarmclock/HaAlarmClockApp.kt b/app/src/main/java/com/targetcrafter/haalarmclock/HaAlarmClockApp.kt index 1ee8ca8..9f75c74 100644 --- a/app/src/main/java/com/targetcrafter/haalarmclock/HaAlarmClockApp.kt +++ b/app/src/main/java/com/targetcrafter/haalarmclock/HaAlarmClockApp.kt @@ -7,8 +7,12 @@ import android.app.NotificationManager import android.content.Context import android.os.Build import android.provider.Settings as AndroidSettings +import android.util.Log import com.targetcrafter.haalarmclock.alarm.AlarmScheduler +import com.targetcrafter.haalarmclock.alarm.ScheduleHealth +import com.targetcrafter.haalarmclock.alarm.checkScheduleHealth import com.targetcrafter.haalarmclock.data.AlarmRepository +import com.targetcrafter.haalarmclock.data.AlarmScheduleAudit import com.targetcrafter.haalarmclock.data.AppDatabase import com.targetcrafter.haalarmclock.data.AppDefaultsStore import com.targetcrafter.haalarmclock.data.ClockPreferencesStore @@ -26,10 +30,13 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit +private const val TAG = "HaAlarmClockApp" + const val NOTIFICATION_CHANNEL_ALARM = "alarm_ringing" const val NOTIFICATION_CHANNEL_SYNC = "ha_sync" const val NOTIFICATION_CHANNEL_UPCOMING = "upcoming_alarm" @@ -45,6 +52,7 @@ class HaAlarmClockApp : Application() { val widgetAppearanceStore: WidgetAppearanceStore by lazy { WidgetAppearanceStore(this) } val worldClockStore: WorldClockStore by lazy { WorldClockStore(this) } val tabPreferencesStore: TabPreferencesStore by lazy { TabPreferencesStore(this) } + val alarmScheduleAudit: AlarmScheduleAudit by lazy { AlarmScheduleAudit(this) } val timerScheduler: TimerScheduler by lazy { TimerScheduler(this) } private val timerNotifications: TimerNotifications by lazy { TimerNotifications(this) } @@ -74,7 +82,7 @@ class HaAlarmClockApp : Application() { // (widget/HA sync upkeep) failing shouldn't be able to take the whole app down with it. private val applicationScope = CoroutineScope( SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, throwable -> - android.util.Log.e("HaAlarmClockApp", "Unhandled exception in application-scope coroutine", throwable) + Log.e(TAG, "Unhandled exception in application-scope coroutine", throwable) }, ) @@ -87,7 +95,17 @@ class HaAlarmClockApp : Application() { // 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. + // + // Check *before* repairing. Re-arming fixes the schedule but also erases the evidence that + // it was ever dropped, and that evidence is the diagnosis: a dropped schedule means the OS + // killed the app, while an alarm that stayed registered and still didn't ring is a bug in + // here. Repairing first would make those two look identical by morning. applicationScope.launch { + val health = checkScheduleHealth(this@HaAlarmClockApp, repository.alarms.first()) + if (health is ScheduleHealth.Missing) { + Log.e(TAG, "Android had dropped the alarm schedule (expected ${health.expectedAtMillis}); re-arming") + } + alarmScheduleAudit.recordStartupCheck(scheduleWasDropped = health is ScheduleHealth.Missing) repository.rescheduleAll() timerRepository.rescheduleAll() } diff --git a/app/src/main/java/com/targetcrafter/haalarmclock/data/AlarmScheduleAudit.kt b/app/src/main/java/com/targetcrafter/haalarmclock/data/AlarmScheduleAudit.kt new file mode 100644 index 0000000..9e94439 --- /dev/null +++ b/app/src/main/java/com/targetcrafter/haalarmclock/data/AlarmScheduleAudit.kt @@ -0,0 +1,67 @@ +package com.targetcrafter.haalarmclock.data + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** What the startup schedule check has found over time. [lastHealthyAtMillis] and + * [lastDropAtMillis] are epoch millis, 0 when it hasn't happened yet. */ +data class ScheduleAuditState( + val dropCount: Int = 0, + val lastDropAtMillis: Long = 0L, + val lastHealthyAtMillis: Long = 0L, +) { + val hasDrops: Boolean get() = dropCount > 0 +} + +/** + * Records each time Android was found to have dropped this app's alarm schedule. + * + * Re-arming on app start repairs a dropped schedule — but on its own it also destroys the evidence + * that anything went wrong: by the time anyone opens the app to look, it has already been fixed and + * everything reads healthy. That is precisely the case where knowing *whether it happened at all* + * is the whole diagnosis, because a dropped schedule means the OS killed the app, while an alarm + * that was registered and still didn't ring means a bug in this app. Writing the finding down + * before the repair is what makes those two distinguishable after the fact. + */ +class AlarmScheduleAudit(context: Context) { + + private val prefs = context.getSharedPreferences("alarm_schedule_audit", Context.MODE_PRIVATE) + + private val _state = MutableStateFlow(read()) + val state: StateFlow = _state.asStateFlow() + + /** Records what the startup check found. Call this *before* re-arming, not after. */ + fun recordStartupCheck(scheduleWasDropped: Boolean, atMillis: Long = System.currentTimeMillis()) { + val current = _state.value + val next = if (scheduleWasDropped) { + current.copy(dropCount = current.dropCount + 1, lastDropAtMillis = atMillis) + } else { + current.copy(lastHealthyAtMillis = atMillis) + } + prefs.edit() + .putInt(KEY_DROP_COUNT, next.dropCount) + .putLong(KEY_LAST_DROP, next.lastDropAtMillis) + .putLong(KEY_LAST_HEALTHY, next.lastHealthyAtMillis) + .apply() + _state.value = next + } + + fun clear() { + prefs.edit().clear().apply() + _state.value = ScheduleAuditState() + } + + private fun read() = ScheduleAuditState( + dropCount = prefs.getInt(KEY_DROP_COUNT, 0), + lastDropAtMillis = prefs.getLong(KEY_LAST_DROP, 0L), + lastHealthyAtMillis = prefs.getLong(KEY_LAST_HEALTHY, 0L), + ) + + companion object { + private const val KEY_DROP_COUNT = "drop_count" + private const val KEY_LAST_DROP = "last_drop_at" + private const val KEY_LAST_HEALTHY = "last_healthy_at" + } +} 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 0c22d8a..73fb71c 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 @@ -86,6 +86,9 @@ import com.targetcrafter.haalarmclock.ui.appViewModelFactory import com.targetcrafter.haalarmclock.util.canUseFullScreenIntent import com.targetcrafter.haalarmclock.util.isIgnoringBatteryOptimizations import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter private val PRESET_COLORS = listOf( 0xFF1B1B1B.toInt(), // near-black (default widget background) @@ -387,9 +390,38 @@ private fun ReliabilitySection() { ) } ScheduleHealthRow() + ScheduleDropHistoryRow() AlarmSoundTestRow() } +/** + * Shows how often Android has been caught having dropped the alarm schedule. Without this the + * self-repair on app start would hide its own reason for existing: the schedule is put back before + * anyone can see it was gone, so every morning-after inspection looks healthy no matter what + * happened overnight. + */ +@Composable +private fun ScheduleDropHistoryRow() { + val context = LocalContext.current + val app = HaAlarmClockApp.from(context) + val audit by app.alarmScheduleAudit.state.collectAsState() + + if (!audit.hasDrops) return + + val formatter = remember { DateTimeFormatter.ofPattern("d MMM, HH:mm") } + val lastDrop = remember(audit.lastDropAtMillis) { + Instant.ofEpochMilli(audit.lastDropAtMillis).atZone(ZoneId.systemDefault()).format(formatter) + } + ReliabilityWarningRow( + title = "Android has dropped your alarms ${audit.dropCount}×", + description = "Most recently on $lastDrop, found and re-armed when the app next started. " + + "This is the system force-stopping the app, not the alarm failing to ring — no code " + + "in here can prevent it, so it has to be fixed in the phone's battery settings.", + buttonLabel = "Reset", + onClick = { app.alarmScheduleAudit.clear() }, + ) +} + /** * 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 From 98cbff8e63b9105d431ff61a4f647b3d199442a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:55:55 +0000 Subject: [PATCH 2/2] Ask Android directly whether it is restricting this app Confirmed today that the ringing sensor never changed state, so the ring service never started; combined with the alarm still showing as enabled in the app's own widget yesterday (which means the receiver never ran and never consumed it), AlarmManager did not deliver the alarm at all. The entry was gone before its trigger time. Force-stopping an app is what drops its alarm entries, and Android will say whether it has this app in that position rather than leaving it to be inferred from a missed alarm. ActivityManager.isBackgroundRestricted() reports Restricted background mode, and UsageStatsManager .getAppStandbyBucket() reports the standby bucket - unprivileged for the calling app, so neither needs a permission. Settings now warns when either says alarms cannot be relied on, and opens the app's settings page, since no intent toggles this directly. This is diagnosis rather than a fix: nothing here can stop a force-stop. But it turns "the alarm didn't ring and we don't know why" into the OS stating its own position, which is what has been missing for five failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7x8v5c5Lu29PBWy1LdfCe --- .../ui/settings/SettingsScreen.kt | 31 +++++++++++ .../haalarmclock/util/BackgroundPolicy.kt | 55 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 app/src/main/java/com/targetcrafter/haalarmclock/util/BackgroundPolicy.kt 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 73fb71c..1ed7119 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 @@ -83,6 +83,7 @@ import com.targetcrafter.haalarmclock.ha.HaConnectionState import com.targetcrafter.haalarmclock.ha.HaSettings import com.targetcrafter.haalarmclock.ha.startHaSyncServiceIfConfigured import com.targetcrafter.haalarmclock.ui.appViewModelFactory +import com.targetcrafter.haalarmclock.util.backgroundPolicy import com.targetcrafter.haalarmclock.util.canUseFullScreenIntent import com.targetcrafter.haalarmclock.util.isIgnoringBatteryOptimizations import kotlinx.coroutines.launch @@ -331,6 +332,7 @@ private fun ReliabilitySection() { var batteryUnrestricted by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) } var fullScreenIntentAllowed by remember { mutableStateOf(canUseFullScreenIntent(context)) } var volume by remember { mutableStateOf(alarmVolume(context)) } + var policy by remember { mutableStateOf(backgroundPolicy(context)) } // None of these are changeable from in here — the first two need a system settings screen, and // the volume is changed with the hardware keys — so re-check on every return to this screen @@ -341,6 +343,7 @@ private fun ReliabilitySection() { batteryUnrestricted = isIgnoringBatteryOptimizations(context) fullScreenIntentAllowed = canUseFullScreenIntent(context) volume = alarmVolume(context) + policy = backgroundPolicy(context) } } lifecycleOwner.lifecycle.addObserver(observer) @@ -363,6 +366,34 @@ private fun ReliabilitySection() { "with the volume keys while the test below is playing.", ) } + if (policy.threatensAlarms) { + // Asked of Android directly rather than inferred from a missed alarm: in this state the + // system force-stops the app when it decides it's idle, which silently drops every alarm + // it had scheduled. There's no intent that toggles this, so this opens the app's own + // settings page, where "Battery" / "Background restriction" lives. + ReliabilityWarningRow( + title = "Android is restricting this app in the background", + description = buildString { + if (policy.isRestricted) append("This app is in Restricted background mode. ") + policy.standbyBucket?.let { append("Android has it in the \"${it.label}\" standby bucket. ") } + append( + "In this state the system force-stops the app when it thinks it's idle, and " + + "force-stopping cancels every alarm it had scheduled — the alarm then " + + "never rings, because it no longer exists. Open the app's settings and " + + "allow unrestricted background use and battery usage.", + ) + }, + buttonLabel = "Open", + onClick = { + context.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${context.packageName}"), + ), + ) + }, + ) + } if (!batteryUnrestricted) { ReliabilityWarningRow( title = "Battery optimization is restricting this app", diff --git a/app/src/main/java/com/targetcrafter/haalarmclock/util/BackgroundPolicy.kt b/app/src/main/java/com/targetcrafter/haalarmclock/util/BackgroundPolicy.kt new file mode 100644 index 0000000..1c20850 --- /dev/null +++ b/app/src/main/java/com/targetcrafter/haalarmclock/util/BackgroundPolicy.kt @@ -0,0 +1,55 @@ +package com.targetcrafter.haalarmclock.util + +import android.app.ActivityManager +import android.app.usage.UsageStatsManager +import android.content.Context +import android.os.Build +import androidx.core.content.getSystemService + +/** + * What Android's own background policy currently says about this app. + * + * Worth asking directly rather than inferring: when a scheduled alarm silently stops existing, the + * usual cause is the system having decided the app is idle and force-stopping it, which drops every + * AlarmManager entry it owned. Both of these are readable without any special permission — the + * standby bucket query is unprivileged for the *calling* app — so the app can simply report what + * the OS thinks of it instead of leaving the user to guess from a missed alarm. + */ +data class BackgroundPolicy( + /** The user or system put this app in "Restricted" background mode — it will be killed + * aggressively and its alarms will not survive. */ + val isRestricted: Boolean, + /** Android's App Standby bucket, or null below API 28 where the concept doesn't exist. */ + val standbyBucket: StandbyBucket?, +) { + /** True when the OS has this app somewhere its alarms can't be relied on. */ + val threatensAlarms: Boolean + get() = isRestricted || standbyBucket == StandbyBucket.RARE || standbyBucket == StandbyBucket.RESTRICTED +} + +enum class StandbyBucket(val label: String) { + ACTIVE("Active"), + WORKING_SET("Working set"), + FREQUENT("Frequent"), + RARE("Rare"), + RESTRICTED("Restricted"), + UNKNOWN("Unknown"), +} + +fun backgroundPolicy(context: Context): BackgroundPolicy { + // Both APIs arrived in Android 9; below that there is nothing to report. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return BackgroundPolicy(false, null) + + val restricted = context.getSystemService()?.isBackgroundRestricted() ?: false + val bucket = when (context.getSystemService()?.getAppStandbyBucket()) { + null -> null + UsageStatsManager.STANDBY_BUCKET_ACTIVE -> StandbyBucket.ACTIVE + UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> StandbyBucket.WORKING_SET + UsageStatsManager.STANDBY_BUCKET_FREQUENT -> StandbyBucket.FREQUENT + UsageStatsManager.STANDBY_BUCKET_RARE -> StandbyBucket.RARE + // Only exists from Android 11; harmless to match on older releases, it simply never occurs. + UsageStatsManager.STANDBY_BUCKET_RESTRICTED -> StandbyBucket.RESTRICTED + else -> StandbyBucket.UNKNOWN + } + return BackgroundPolicy(isRestricted = restricted, standbyBucket = bucket) +}