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..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,9 +83,13 @@ 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 +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) @@ -328,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 @@ -338,6 +343,7 @@ private fun ReliabilitySection() { batteryUnrestricted = isIgnoringBatteryOptimizations(context) fullScreenIntentAllowed = canUseFullScreenIntent(context) volume = alarmVolume(context) + policy = backgroundPolicy(context) } } lifecycleOwner.lifecycle.addObserver(observer) @@ -360,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", @@ -387,9 +421,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 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) +}