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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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) }
Expand Down Expand Up @@ -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)
},
)

Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ScheduleAuditState> = _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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -338,6 +343,7 @@ private fun ReliabilitySection() {
batteryUnrestricted = isIgnoringBatteryOptimizations(context)
fullScreenIntentAllowed = canUseFullScreenIntent(context)
volume = alarmVolume(context)
policy = backgroundPolicy(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ActivityManager>()?.isBackgroundRestricted() ?: false
val bucket = when (context.getSystemService<UsageStatsManager>()?.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)
}
Loading