feat(selfcheck): why might my alarm not ring?

A pure rule table (domain/selfcheck/SelfCheck.kt) over real device
state: exact-alarm and full-screen-intent permission, notification and
alarm-channel state, battery-optimisation exemption, background
restriction, DND (read-only — Clockula never requests
ACCESS_NOTIFICATION_POLICY or touches DND itself), alarm stream
volume, a known aggressive-OEM allowlist, and the system's own next
alarm compared against what Clockula itself computed. Each row maps to
a deep link into the exact settings page responsible.

FSI denial and a missing battery exemption warn rather than fail —
both degrade (to heads-up, and to a doze-exempt setAlarmClock
registration) rather than silence the alarm. The settings hub's
'Why might my alarm not ring?' row now shows the live problem count.
This commit is contained in:
2026-10-01 22:15:42 +02:00
parent 616bf7f67f
commit a11396b058
17 changed files with 879 additions and 10 deletions
@@ -16,4 +16,12 @@ interface AlarmScheduler {
/** `AlarmManager.getNextAlarmClock()`, as the *system* sees it. */
fun systemNextAlarm(): Instant?
/**
* The package that scheduled [systemNextAlarm], read from its show
* intent's creator — M10's self-check uses this to tell "nobody's alarm",
* "some other app's alarm" and "Clockula's own alarm" apart. Null when
* there is no next alarm, or the platform cannot say who registered it.
*/
fun systemNextAlarmOwnerPackage(): String?
}
@@ -0,0 +1,8 @@
package de.jeanlucmakiola.clockula.alarm
import de.jeanlucmakiola.clockula.domain.selfcheck.DeviceState
/** The self-check screen's one Android seam: a fresh read of everything `SelfCheck.evaluate` needs. */
interface DeviceStateReader {
suspend fun read(): DeviceState
}
@@ -67,6 +67,8 @@ class AndroidAlarmScheduler @Inject constructor(
override fun systemNextAlarm(): Instant? =
manager.nextAlarmClock?.let { Instant.fromEpochMilliseconds(it.triggerTime) }
override fun systemNextAlarmOwnerPackage(): String? = manager.nextAlarmClock?.showIntent?.creatorPackage
/**
* One request code per slot, so the cancel below finds the same PendingIntent
* the schedule above created — the extras are updated, never matched on.
@@ -0,0 +1,67 @@
package de.jeanlucmakiola.clockula.alarm.android
import android.app.ActivityManager
import android.app.NotificationManager
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.os.PowerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmScheduler
import de.jeanlucmakiola.clockula.alarm.DeviceStateReader
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.domain.selfcheck.DeviceState
import kotlinx.coroutines.flow.first
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads every fact `SelfCheck.evaluate` needs, fresh, from the live platform —
* all of it can change while the user is not looking, so nothing here is
* cached. [AlarmCapabilities]/[AlarmScheduler] are reused rather than
* re-reading the same permissions a second way.
*/
@Singleton
class AndroidDeviceStateReader @Inject constructor(
@param:ApplicationContext private val context: Context,
private val capabilities: AlarmCapabilities,
private val scheduler: AlarmScheduler,
private val alarmEngine: AlarmEngine,
) : DeviceStateReader {
override suspend fun read(): DeviceState {
val snapshot = capabilities.snapshot()
val notifications = context.getSystemService(NotificationManager::class.java)
val power = context.getSystemService(PowerManager::class.java)
val activity = context.getSystemService(ActivityManager::class.java)
val audio = context.getSystemService(AudioManager::class.java)
// The list is sorted ascending with nulls last (AlarmEngine.upcoming's
// own contract), so the first non-null entry — if any — is the
// earliest fire Clockula itself computed.
val expectedNextFire = alarmEngine.upcoming().first().firstOrNull()?.nextFire
return DeviceState(
sdkInt = Build.VERSION.SDK_INT,
canScheduleExactAlarms = snapshot.canScheduleExactAlarms,
notificationsEnabled = snapshot.notificationsEnabled,
alarmChannelBlocked = notifications
?.getNotificationChannel(RingNotifications.RING_CHANNEL_ID)
?.importance == NotificationManager.IMPORTANCE_NONE,
canUseFullScreenIntent = snapshot.canUseFullScreenIntent,
ignoringBatteryOptimizations = power?.isIgnoringBatteryOptimizations(context.packageName) ?: true,
backgroundRestricted = activity?.isBackgroundRestricted == true,
interruptionFilter = notifications?.currentInterruptionFilter
?: NotificationManager.INTERRUPTION_FILTER_UNKNOWN,
alarmStreamVolume = audio?.getStreamVolume(AudioManager.STREAM_ALARM) ?: 1,
alarmStreamMaxVolume = audio?.getStreamMaxVolume(AudioManager.STREAM_ALARM) ?: 1,
manufacturer = Build.MANUFACTURER ?: "",
ownPackageName = context.packageName,
systemNextAlarm = scheduler.systemNextAlarm(),
systemNextAlarmOwnerPackage = scheduler.systemNextAlarmOwnerPackage(),
expectedNextFire = expectedNextFire,
)
}
}
@@ -7,10 +7,12 @@ import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
import de.jeanlucmakiola.clockula.alarm.AlarmScheduler
import de.jeanlucmakiola.clockula.alarm.DeviceStateReader
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
import de.jeanlucmakiola.clockula.alarm.android.AndroidAlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.android.AndroidAlarmNotifier
import de.jeanlucmakiola.clockula.alarm.android.AndroidAlarmScheduler
import de.jeanlucmakiola.clockula.alarm.android.AndroidDeviceStateReader
import de.jeanlucmakiola.clockula.alarm.android.ServiceRingCoordinator
import javax.inject.Singleton
@@ -38,4 +40,8 @@ abstract class AlarmModule {
@Binds
@Singleton
abstract fun bindAlarmNotifier(impl: AndroidAlarmNotifier): AlarmNotifier
@Binds
@Singleton
abstract fun bindDeviceStateReader(impl: AndroidDeviceStateReader): DeviceStateReader
}
@@ -0,0 +1,211 @@
package de.jeanlucmakiola.clockula.domain.selfcheck
import kotlin.time.Instant
/**
* The device-observable facts the self-check evaluates — everything
* [SelfCheck.evaluate] needs and nothing it has to go fetch itself, so the
* rules below are pure and unit-testable without touching Android
* (`docs/PLAN.md` §4). [ownPackageName] is this app's own package, read once
* by the Android reader rather than hardcoded here, so [nextAlarm]'s
* ownership comparison stays a plain string match.
*/
data class DeviceState(
val sdkInt: Int,
val canScheduleExactAlarms: Boolean,
val notificationsEnabled: Boolean,
val alarmChannelBlocked: Boolean,
val canUseFullScreenIntent: Boolean,
val ignoringBatteryOptimizations: Boolean,
val backgroundRestricted: Boolean,
/** `NotificationManager.getCurrentInterruptionFilter()`, read as-is — see [SelfCheck.dnd]. */
val interruptionFilter: Int,
val alarmStreamVolume: Int,
val alarmStreamMaxVolume: Int,
val manufacturer: String,
val ownPackageName: String,
/** `AlarmManager.getNextAlarmClock()`, as the *system* sees it. */
val systemNextAlarm: Instant?,
/** The package that scheduled [systemNextAlarm], from its show intent's creator. Null if unknown. */
val systemNextAlarmOwnerPackage: String?,
/** What Clockula itself last computed as its own earliest fire. */
val expectedNextFire: Instant?,
)
enum class CheckStatus { OK, WARN, PROBLEM }
enum class SelfCheckId {
EXACT_ALARM,
NOTIFICATIONS,
ALARM_CHANNEL,
FULL_SCREEN_INTENT,
BATTERY_OPTIMIZATION,
BACKGROUND_RESTRICTED,
DND,
ALARM_VOLUME,
OEM_KILLER,
NEXT_ALARM,
}
/**
* A deep-link target for a check's "Fix" action — an identifier only; the
* actual `Intent` is built in `ui/`, which is the one layer allowed to know
* what an `Intent` is.
*/
sealed interface SelfCheckFix {
data object OpenExactAlarmSettings : SelfCheckFix
data object OpenAppNotificationSettings : SelfCheckFix
data object OpenAlarmChannelSettings : SelfCheckFix
data object OpenFullScreenIntentSettings : SelfCheckFix
data object OpenBatteryOptimizationSettings : SelfCheckFix
data object OpenAppDetailsSettings : SelfCheckFix
data object OpenZenSettings : SelfCheckFix
data object OpenSoundSettings : SelfCheckFix
data class OpenOemKillerPage(val manufacturerSlug: String) : SelfCheckFix
}
data class CheckResult(val id: SelfCheckId, val status: CheckStatus, val fix: SelfCheckFix?)
/**
* The "why might my alarm not ring?" rule table (`docs/PLAN.md` §4). Each row
* is independent, reads only [DeviceState] and writes nothing; a caller
* renders [evaluate]'s list in declaration order. A row that does not apply
* on the current [DeviceState.sdkInt] (or has nothing to report) is left out
* of the list entirely rather than shown as a false OK.
*/
object SelfCheck {
/**
* Manufacturers whose stock OEM app-killer is documented (dontkillmyapp.com)
* to aggressively kill background alarms. Informational only — Clockula
* cannot detect the killer itself, only name the device it ships on.
*/
private val AGGRESSIVE_OEMS: Set<String> = setOf(
"xiaomi", "huawei", "honor", "oneplus", "oppo", "vivo", "asus", "samsung",
)
private const val INTERRUPTION_FILTER_NONE = 3
fun evaluate(state: DeviceState): List<CheckResult> = listOfNotNull(
exactAlarm(state),
notifications(state),
alarmChannel(state),
fullScreenIntent(state),
batteryOptimization(state),
backgroundRestricted(state),
dnd(state),
alarmVolume(state),
oemKiller(state),
nextAlarm(state),
)
/** Below API 31 the permission does not exist (`AndroidAlarmCapabilities`), so there is nothing to check. */
private fun exactAlarm(state: DeviceState): CheckResult? {
if (state.sdkInt < 31) return null
return CheckResult(
SelfCheckId.EXACT_ALARM,
if (state.canScheduleExactAlarms) CheckStatus.OK else CheckStatus.PROBLEM,
if (state.canScheduleExactAlarms) null else SelfCheckFix.OpenExactAlarmSettings,
)
}
/**
* The ring notification's audio belongs to the foreground service, not the
* notification (`RingNotifications`), so a denial costs only the screen
* surface, never the alarm itself — a warning, not a failure.
*/
private fun notifications(state: DeviceState) = CheckResult(
SelfCheckId.NOTIFICATIONS,
if (state.notificationsEnabled) CheckStatus.OK else CheckStatus.WARN,
if (state.notificationsEnabled) null else SelfCheckFix.OpenAppNotificationSettings,
)
private fun alarmChannel(state: DeviceState) = CheckResult(
SelfCheckId.ALARM_CHANNEL,
if (state.alarmChannelBlocked) CheckStatus.WARN else CheckStatus.OK,
if (state.alarmChannelBlocked) SelfCheckFix.OpenAlarmChannelSettings else null,
)
/** Below API 34 there is no full-screen-intent permission to check. */
private fun fullScreenIntent(state: DeviceState): CheckResult? {
if (state.sdkInt < 34) return null
// Denied degrades to a heads-up notification that still sounds — a
// worse presentation, never silence (`docs/PLAN.md` §4) — so this
// warns rather than fails.
return CheckResult(
SelfCheckId.FULL_SCREEN_INTENT,
if (state.canUseFullScreenIntent) CheckStatus.OK else CheckStatus.WARN,
if (state.canUseFullScreenIntent) null else SelfCheckFix.OpenFullScreenIntentSettings,
)
}
/** `setAlarmClock` is already doze-exempt (`docs/PLAN.md` §4), so this can only help further — never a failure. */
private fun batteryOptimization(state: DeviceState) = CheckResult(
SelfCheckId.BATTERY_OPTIMIZATION,
if (state.ignoringBatteryOptimizations) CheckStatus.OK else CheckStatus.WARN,
if (state.ignoringBatteryOptimizations) null else SelfCheckFix.OpenBatteryOptimizationSettings,
)
private fun backgroundRestricted(state: DeviceState) = CheckResult(
SelfCheckId.BACKGROUND_RESTRICTED,
if (state.backgroundRestricted) CheckStatus.PROBLEM else CheckStatus.OK,
if (state.backgroundRestricted) SelfCheckFix.OpenAppDetailsSettings else null,
)
/**
* Only total silence (`INTERRUPTION_FILTER_NONE`) is treated as blocking
* alarms: Priority and Alarms-only modes leave the system's own alarm
* exemption in effect. Clockula never requests `ACCESS_NOTIFICATION_POLICY`
* and never changes DND itself — it only reports what the user already has
* (`docs/PLAN.md` §4).
*/
private fun dnd(state: DeviceState): CheckResult {
val blocked = state.interruptionFilter == INTERRUPTION_FILTER_NONE
return CheckResult(
SelfCheckId.DND,
if (blocked) CheckStatus.PROBLEM else CheckStatus.OK,
if (blocked) SelfCheckFix.OpenZenSettings else null,
)
}
private fun alarmVolume(state: DeviceState): CheckResult {
val silent = state.alarmStreamVolume <= 0
return CheckResult(
SelfCheckId.ALARM_VOLUME,
if (silent) CheckStatus.PROBLEM else CheckStatus.OK,
if (silent) SelfCheckFix.OpenSoundSettings else null,
)
}
/** Informational only: Clockula cannot detect an OEM killer, only name the device it runs on. */
private fun oemKiller(state: DeviceState): CheckResult? {
val slug = state.manufacturer.trim().lowercase()
if (slug !in AGGRESSIVE_OEMS) return null
return CheckResult(SelfCheckId.OEM_KILLER, CheckStatus.WARN, SelfCheckFix.OpenOemKillerPage(slug))
}
/**
* Compares Clockula's own computed next fire against what the system
* actually has registered. No alarm scheduled is not a problem; an
* unknown owner is given the benefit of the doubt (the platform does not
* always expose it), but a *confirmed* foreign owner, a missing system
* registration, or a mismatched time all mean the registration this app
* made is not the one the system will honour.
*/
private fun nextAlarm(state: DeviceState): CheckResult {
val expected = state.expectedNextFire
val owner = state.systemNextAlarmOwnerPackage
val ok = when {
expected == null -> true
state.systemNextAlarm == null -> false
owner != null && owner != state.ownPackageName -> false
state.systemNextAlarm != expected -> false
else -> true
}
return CheckResult(
SelfCheckId.NEXT_ALARM,
if (ok) CheckStatus.OK else CheckStatus.PROBLEM,
if (ok) null else SelfCheckFix.OpenAppDetailsSettings,
)
}
}
@@ -1,24 +1,163 @@
package de.jeanlucmakiola.clockula.ui.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
import de.jeanlucmakiola.clockula.domain.selfcheck.CheckResult
import de.jeanlucmakiola.clockula.domain.selfcheck.CheckStatus
import de.jeanlucmakiola.clockula.domain.selfcheck.SelfCheckFix
import de.jeanlucmakiola.clockula.domain.selfcheck.SelfCheckId
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/**
* Placeholder for the "why might my alarm not ring?" self-check — the real
* screen lands with M10's self-check phase. Same route, same call shape, so
* swapping it in is a one-file change.
* The "why might my alarm not ring?" screen (`docs/PLAN.md` §4): one row per
* [CheckResult], re-read on every `ON_RESUME` — the facts it reports
* (permissions, DND, volume) can all change while this screen is in the
* background, most commonly because the user just came back from fixing one.
*/
@Composable
fun SelfCheckScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
fun SelfCheckScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SelfCheckViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
CollapsingScaffold(
title = stringResource(R.string.settings_self_check_title),
onBack = onBack,
modifier = modifier,
) {
Text(stringResource(R.string.settings_coming_soon))
val results = (state as? SelfCheckUiState.Loaded)?.results.orEmpty()
results.forEachIndexed { index, result ->
GroupedRow(
title = selfCheckTitle(result.id),
summary = selfCheckReason(result.id, result.status),
position = positionOf(index, results.size),
leading = { StatusIcon(result.status) },
trailing = result.fix?.let { fix ->
{
TextButton(onClick = { launchFix(context, fix) }) {
Text(stringResource(R.string.selfcheck_fix))
}
}
},
)
}
}
}
@Composable
private fun StatusIcon(status: CheckStatus) {
val (icon, tint) = when (status) {
CheckStatus.OK -> Icons.Rounded.CheckCircle to MaterialTheme.colorScheme.primary
CheckStatus.WARN -> Icons.Rounded.Warning to MaterialTheme.colorScheme.tertiary
CheckStatus.PROBLEM -> Icons.Rounded.Error to MaterialTheme.colorScheme.error
}
Icon(icon, contentDescription = null, tint = tint)
}
@Composable
private fun selfCheckTitle(id: SelfCheckId): String = stringResource(
when (id) {
SelfCheckId.EXACT_ALARM -> R.string.selfcheck_exact_alarm_title
SelfCheckId.NOTIFICATIONS -> R.string.selfcheck_notifications_title
SelfCheckId.ALARM_CHANNEL -> R.string.selfcheck_alarm_channel_title
SelfCheckId.FULL_SCREEN_INTENT -> R.string.selfcheck_fsi_title
SelfCheckId.BATTERY_OPTIMIZATION -> R.string.selfcheck_battery_title
SelfCheckId.BACKGROUND_RESTRICTED -> R.string.selfcheck_background_restricted_title
SelfCheckId.DND -> R.string.selfcheck_dnd_title
SelfCheckId.ALARM_VOLUME -> R.string.selfcheck_alarm_volume_title
SelfCheckId.OEM_KILLER -> R.string.selfcheck_oem_title
SelfCheckId.NEXT_ALARM -> R.string.selfcheck_next_alarm_title
},
)
@Composable
private fun selfCheckReason(id: SelfCheckId, status: CheckStatus): String {
val ok = status == CheckStatus.OK
return stringResource(
when (id) {
SelfCheckId.EXACT_ALARM -> if (ok) R.string.selfcheck_exact_alarm_ok else R.string.selfcheck_exact_alarm_problem
SelfCheckId.NOTIFICATIONS -> if (ok) R.string.selfcheck_notifications_ok else R.string.selfcheck_notifications_warn
SelfCheckId.ALARM_CHANNEL -> if (ok) R.string.selfcheck_alarm_channel_ok else R.string.selfcheck_alarm_channel_warn
SelfCheckId.FULL_SCREEN_INTENT -> if (ok) R.string.selfcheck_fsi_ok else R.string.selfcheck_fsi_warn
SelfCheckId.BATTERY_OPTIMIZATION -> if (ok) R.string.selfcheck_battery_ok else R.string.selfcheck_battery_warn
SelfCheckId.BACKGROUND_RESTRICTED ->
if (ok) R.string.selfcheck_background_restricted_ok else R.string.selfcheck_background_restricted_problem
SelfCheckId.DND -> if (ok) R.string.selfcheck_dnd_ok else R.string.selfcheck_dnd_problem
SelfCheckId.ALARM_VOLUME -> if (ok) R.string.selfcheck_alarm_volume_ok else R.string.selfcheck_alarm_volume_problem
SelfCheckId.OEM_KILLER -> R.string.selfcheck_oem_warn
SelfCheckId.NEXT_ALARM -> if (ok) R.string.selfcheck_next_alarm_ok else R.string.selfcheck_next_alarm_problem
},
)
}
/**
* [SelfCheckFix] names a destination; building the actual [Intent] — the one
* thing `domain/selfcheck` is not allowed to know exists — happens only here.
* Every launch is guarded: a device that advertises a settings action but
* cannot resolve it must not crash the row that offered it.
*/
private fun launchFix(context: Context, fix: SelfCheckFix) {
val intent = when (fix) {
SelfCheckFix.OpenExactAlarmSettings ->
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Uri.parse("package:${context.packageName}"))
SelfCheckFix.OpenAppNotificationSettings ->
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
SelfCheckFix.OpenAlarmChannelSettings ->
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, RingNotifications.RING_CHANNEL_ID)
SelfCheckFix.OpenFullScreenIntentSettings ->
Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, Uri.parse("package:${context.packageName}"))
SelfCheckFix.OpenBatteryOptimizationSettings ->
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
SelfCheckFix.OpenAppDetailsSettings ->
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
// Settings.ACTION_ZEN_MODE_SETTINGS is @hide — not in the public SDK —
// but the action string itself is stable AOSP-wide and safely guarded
// by the startActivity below like every other fix here.
SelfCheckFix.OpenZenSettings -> Intent("android.settings.ZEN_MODE_SETTINGS")
SelfCheckFix.OpenSoundSettings -> Intent(Settings.ACTION_SOUND_SETTINGS)
is SelfCheckFix.OpenOemKillerPage ->
Intent(Intent.ACTION_VIEW, Uri.parse("https://dontkillmyapp.com/${fix.manufacturerSlug}"))
}
runCatching { context.startActivity(intent) }
}
@@ -0,0 +1,12 @@
package de.jeanlucmakiola.clockula.ui.settings
import de.jeanlucmakiola.clockula.domain.selfcheck.CheckResult
import de.jeanlucmakiola.clockula.domain.selfcheck.CheckStatus
/** The self-check screen's state: the rule table's results, freshly read. */
internal sealed interface SelfCheckUiState {
data object Loading : SelfCheckUiState
data class Loaded(val results: List<CheckResult>) : SelfCheckUiState {
val problemCount: Int = results.count { it.status == CheckStatus.PROBLEM }
}
}
@@ -0,0 +1,38 @@
package de.jeanlucmakiola.clockula.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.alarm.DeviceStateReader
import de.jeanlucmakiola.clockula.domain.selfcheck.SelfCheck
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The "why might my alarm not ring?" screen's state: a fresh [SelfCheck.evaluate]
* read, repeated on [refresh] — called once on first composition and again on
* every `ON_RESUME`, since the facts it reports (permissions, DND, volume) can
* all change while the screen is in the background.
*/
@HiltViewModel
class SelfCheckViewModel @Inject internal constructor(
private val deviceState: DeviceStateReader,
) : ViewModel() {
private val _state = MutableStateFlow<SelfCheckUiState>(SelfCheckUiState.Loading)
internal val state: StateFlow<SelfCheckUiState> = _state.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val results = SelfCheck.evaluate(deviceState.read())
_state.value = SelfCheckUiState.Loaded(results)
}
}
}
@@ -209,7 +209,7 @@ fun SettingsScreen(
GroupedSectionHeader(stringResource(R.string.settings_section_reliability))
GroupedRow(
title = stringResource(R.string.settings_self_check),
summary = stringResource(R.string.settings_self_check_summary),
summary = selfCheckSummary(loaded?.selfCheckProblemCount),
position = Position.Alone,
leading = { Icon(Icons.Rounded.AccessTime, contentDescription = null) },
onClick = onOpenSelfCheck,
@@ -363,6 +363,14 @@ private fun themeModeLabel(mode: ThemeMode): String = stringResource(
},
)
/** Null while the one-off read is in flight; otherwise the plain-language count. */
@Composable
private fun selfCheckSummary(problemCount: Int?): String = when {
problemCount == null -> stringResource(R.string.settings_self_check_summary)
problemCount == 0 -> stringResource(R.string.settings_self_check_all_clear)
else -> pluralStringResource(R.plurals.settings_self_check_problems, problemCount, problemCount)
}
@Composable
private fun ringtoneSummary(uri: String?): String = when {
uri == null -> stringResource(R.string.ringtone_device_default)
@@ -3,10 +3,18 @@ package de.jeanlucmakiola.clockula.ui.settings
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.floret.prefs.Appearance
/** The settings hub's state: the appearance choices and the clock defaults. */
/**
* The settings hub's state: the appearance choices, the clock defaults, and
* the self-check's problem count — null until that one-off read completes, so
* the row shows nothing rather than a wrong zero while it's in flight.
*/
internal sealed interface SettingsUiState {
data object Loading : SettingsUiState
data class Loaded(val appearance: Appearance, val defaults: ClockDefaults) : SettingsUiState
data class Loaded(
val appearance: Appearance,
val defaults: ClockDefaults,
val selfCheckProblemCount: Int? = null,
) : SettingsUiState
}
/** Which of the two default ringtones the picker is editing. */
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.alarm.DeviceStateReader
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
@@ -10,6 +11,8 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.selfcheck.CheckStatus
import de.jeanlucmakiola.clockula.domain.selfcheck.SelfCheck
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockSource
@@ -44,15 +47,31 @@ class SettingsViewModel @Inject internal constructor(
private val ringtones: RingtoneCatalog,
private val preview: RingtonePreviewer,
private val worldClocks: WorldClockSource,
private val deviceState: DeviceStateReader,
) : ViewModel() {
/**
* Null until the one-off self-check read completes. Not live/ticking —
* the row only needs to be right when the hub is opened; the self-check
* screen itself re-reads on every visit.
*/
private val selfCheckProblemCount = MutableStateFlow<Int?>(null)
internal val state: StateFlow<SettingsUiState> = combine(
settings.appearance,
settings.defaults,
) { appearance, defaults ->
SettingsUiState.Loaded(appearance, defaults)
selfCheckProblemCount,
) { appearance, defaults, problemCount ->
SettingsUiState.Loaded(appearance, defaults, problemCount)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
init {
viewModelScope.launch {
val results = SelfCheck.evaluate(deviceState.read())
selfCheckProblemCount.value = results.count { it.status == CheckStatus.PROBLEM }
}
}
private val _ringtonePicker = MutableStateFlow<Pair<DefaultRingtoneKind, RingtonePickerUiState>?>(null)
/** Null while closed; the kind being edited and its picker state while open. */
+47
View File
@@ -270,6 +270,11 @@
<string name="settings_self_check">Why might my alarm not ring?</string>
<string name="settings_self_check_title">Why might my alarm not ring?</string>
<string name="settings_self_check_summary">Check exact-alarm, notification and battery settings</string>
<plurals name="settings_self_check_problems">
<item quantity="one">%1$d problem found</item>
<item quantity="other">%1$d problems found</item>
</plurals>
<string name="settings_self_check_all_clear">Everything looks fine</string>
<string name="settings_section_data">Data</string>
<string name="settings_backup">Back up &amp; restore</string>
<string name="settings_backup_title">Back up &amp; restore</string>
@@ -294,4 +299,46 @@
<string name="settings_backup_import_confirm_title">Replace everything?</string>
<string name="settings_backup_import_confirm_message">This replaces all your current alarms, timers, world clocks and settings with what\'s in the backup file. This can\'t be undone.</string>
<string name="settings_backup_import_confirm_action">Replace</string>
<!-- M10: self-check -->
<string name="selfcheck_fix">Fix</string>
<string name="selfcheck_exact_alarm_title">Exact alarms</string>
<string name="selfcheck_exact_alarm_ok">Allowed — your alarm will fire on time</string>
<string name="selfcheck_exact_alarm_problem">Not allowed — alarms may fire late</string>
<string name="selfcheck_notifications_title">Notifications</string>
<string name="selfcheck_notifications_ok">Allowed — the ring screen can show over other apps</string>
<string name="selfcheck_notifications_warn">Off — your alarm will still sound, but no screen or heads-up banner will show</string>
<string name="selfcheck_alarm_channel_title">Alarm channel</string>
<string name="selfcheck_alarm_channel_ok">Not blocked</string>
<string name="selfcheck_alarm_channel_warn">Blocked — your alarm will still sound, but no screen or heads-up banner will show</string>
<string name="selfcheck_fsi_title">Full-screen alarm</string>
<string name="selfcheck_fsi_ok">Allowed — the ring screen can show over the lock screen</string>
<string name="selfcheck_fsi_warn">Not allowed — your alarm will still sound, as a heads-up notification instead</string>
<string name="selfcheck_battery_title">Battery optimisation</string>
<string name="selfcheck_battery_ok">Exempt</string>
<string name="selfcheck_battery_warn">Not exempt — exempting Clockula can make alarms more reliable</string>
<string name="selfcheck_background_restricted_title">Background activity</string>
<string name="selfcheck_background_restricted_ok">Not restricted</string>
<string name="selfcheck_background_restricted_problem">Restricted — alarms may not fire reliably</string>
<string name="selfcheck_dnd_title">Do Not Disturb</string>
<string name="selfcheck_dnd_ok">Not blocking alarms</string>
<string name="selfcheck_dnd_problem">Total silence is on — it blocks alarms too</string>
<string name="selfcheck_alarm_volume_title">Alarm volume</string>
<string name="selfcheck_alarm_volume_ok">On</string>
<string name="selfcheck_alarm_volume_problem">Muted — your alarm will fire silently</string>
<string name="selfcheck_oem_title">Device manufacturer</string>
<string name="selfcheck_oem_warn">Some manufacturers aggressively close background apps — see how to keep Clockula exempt</string>
<string name="selfcheck_next_alarm_title">Next alarm, as the system sees it</string>
<string name="selfcheck_next_alarm_ok">Matches what Clockula scheduled</string>
<string name="selfcheck_next_alarm_problem">Doesn\'t match what Clockula scheduled — open the app to re-check</string>
</resources>
@@ -0,0 +1,208 @@
package de.jeanlucmakiola.clockula.domain.selfcheck
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/** `docs/PLAN.md` §4's rule table, each rule exercised in isolation. */
class SelfCheckTest {
private val allClear = DeviceState(
sdkInt = 34,
canScheduleExactAlarms = true,
notificationsEnabled = true,
alarmChannelBlocked = false,
canUseFullScreenIntent = true,
ignoringBatteryOptimizations = true,
backgroundRestricted = false,
interruptionFilter = INTERRUPTION_FILTER_ALL,
alarmStreamVolume = 5,
alarmStreamMaxVolume = 7,
manufacturer = "Google",
ownPackageName = OWN_PACKAGE,
systemNextAlarm = null,
systemNextAlarmOwnerPackage = null,
expectedNextFire = null,
)
private fun results(state: DeviceState) = SelfCheck.evaluate(state)
private fun resultFor(state: DeviceState, id: SelfCheckId) =
results(state).first { it.id == id }
@Test
fun `every row is OK when nothing is wrong`() {
assertThat(results(allClear).map { it.status }).containsNoneIn(listOf(CheckStatus.WARN, CheckStatus.PROBLEM))
}
@Test
fun `exact alarm denied is a problem with a fix on API 31+`() {
val state = allClear.copy(sdkInt = 33, canScheduleExactAlarms = false)
val result = resultFor(state, SelfCheckId.EXACT_ALARM)
assertThat(result.status).isEqualTo(CheckStatus.PROBLEM)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenExactAlarmSettings)
}
@Test
fun `exact alarm has nothing to check below API 31`() {
val state = allClear.copy(sdkInt = 30, canScheduleExactAlarms = false)
assertThat(results(state).map { it.id }).doesNotContain(SelfCheckId.EXACT_ALARM)
}
@Test
fun `notifications off is a warning, not a problem`() {
val result = resultFor(allClear.copy(notificationsEnabled = false), SelfCheckId.NOTIFICATIONS)
assertThat(result.status).isEqualTo(CheckStatus.WARN)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenAppNotificationSettings)
}
@Test
fun `a blocked alarm channel is a warning`() {
val result = resultFor(allClear.copy(alarmChannelBlocked = true), SelfCheckId.ALARM_CHANNEL)
assertThat(result.status).isEqualTo(CheckStatus.WARN)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenAlarmChannelSettings)
}
@Test
fun `full-screen intent denied degrades to a warning, never a failure`() {
val state = allClear.copy(sdkInt = 34, canUseFullScreenIntent = false)
val result = resultFor(state, SelfCheckId.FULL_SCREEN_INTENT)
assertThat(result.status).isEqualTo(CheckStatus.WARN)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenFullScreenIntentSettings)
}
@Test
fun `full-screen intent has nothing to check below API 34`() {
val state = allClear.copy(sdkInt = 33, canUseFullScreenIntent = false)
assertThat(results(state).map { it.id }).doesNotContain(SelfCheckId.FULL_SCREEN_INTENT)
}
@Test
fun `battery optimisation not exempt is a warning — setAlarmClock is already doze-exempt`() {
val result = resultFor(allClear.copy(ignoringBatteryOptimizations = false), SelfCheckId.BATTERY_OPTIMIZATION)
assertThat(result.status).isEqualTo(CheckStatus.WARN)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenBatteryOptimizationSettings)
}
@Test
fun `background restriction is a problem`() {
val result = resultFor(allClear.copy(backgroundRestricted = true), SelfCheckId.BACKGROUND_RESTRICTED)
assertThat(result.status).isEqualTo(CheckStatus.PROBLEM)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenAppDetailsSettings)
}
@Test
fun `total silence DND is a problem`() {
val result = resultFor(allClear.copy(interruptionFilter = INTERRUPTION_FILTER_NONE), SelfCheckId.DND)
assertThat(result.status).isEqualTo(CheckStatus.PROBLEM)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenZenSettings)
}
@Test
fun `priority and alarms-only DND modes do not block alarms`() {
val priority = resultFor(allClear.copy(interruptionFilter = 2), SelfCheckId.DND)
val alarmsOnly = resultFor(allClear.copy(interruptionFilter = 4), SelfCheckId.DND)
assertThat(priority.status).isEqualTo(CheckStatus.OK)
assertThat(alarmsOnly.status).isEqualTo(CheckStatus.OK)
}
@Test
fun `zero alarm volume is a problem`() {
val result = resultFor(allClear.copy(alarmStreamVolume = 0), SelfCheckId.ALARM_VOLUME)
assertThat(result.status).isEqualTo(CheckStatus.PROBLEM)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenSoundSettings)
}
@Test
fun `a known aggressive OEM is an informational warning`() {
val result = resultFor(allClear.copy(manufacturer = "Xiaomi"), SelfCheckId.OEM_KILLER)
assertThat(result.status).isEqualTo(CheckStatus.WARN)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenOemKillerPage("xiaomi"))
}
@Test
fun `an unlisted manufacturer has no OEM row at all`() {
assertThat(results(allClear.copy(manufacturer = "Google")).map { it.id })
.doesNotContain(SelfCheckId.OEM_KILLER)
}
@Test
fun `no alarm scheduled anywhere is fine`() {
val result = resultFor(allClear, SelfCheckId.NEXT_ALARM)
assertThat(result.status).isEqualTo(CheckStatus.OK)
}
@Test
fun `the system's next alarm matching what Clockula expected is fine`() {
val at = Instant.fromEpochMilliseconds(1_000_000)
val state = allClear.copy(
expectedNextFire = at,
systemNextAlarm = at,
systemNextAlarmOwnerPackage = OWN_PACKAGE,
)
assertThat(resultFor(state, SelfCheckId.NEXT_ALARM).status).isEqualTo(CheckStatus.OK)
}
@Test
fun `an unknown owner is given the benefit of the doubt when the time matches`() {
val at = Instant.fromEpochMilliseconds(1_000_000)
val state = allClear.copy(expectedNextFire = at, systemNextAlarm = at, systemNextAlarmOwnerPackage = null)
assertThat(resultFor(state, SelfCheckId.NEXT_ALARM).status).isEqualTo(CheckStatus.OK)
}
@Test
fun `nothing registered with the system when Clockula expected a fire is a problem`() {
val state = allClear.copy(
expectedNextFire = Instant.fromEpochMilliseconds(1_000_000),
systemNextAlarm = null,
)
val result = resultFor(state, SelfCheckId.NEXT_ALARM)
assertThat(result.status).isEqualTo(CheckStatus.PROBLEM)
assertThat(result.fix).isEqualTo(SelfCheckFix.OpenAppDetailsSettings)
}
@Test
fun `a mismatched system fire time is a problem`() {
val state = allClear.copy(
expectedNextFire = Instant.fromEpochMilliseconds(1_000_000),
systemNextAlarm = Instant.fromEpochMilliseconds(2_000_000),
systemNextAlarmOwnerPackage = OWN_PACKAGE,
)
assertThat(resultFor(state, SelfCheckId.NEXT_ALARM).status).isEqualTo(CheckStatus.PROBLEM)
}
@Test
fun `the system's registration belonging to a different app is a problem`() {
val at = Instant.fromEpochMilliseconds(1_000_000)
val state = allClear.copy(
expectedNextFire = at,
systemNextAlarm = at,
systemNextAlarmOwnerPackage = "com.other.clock",
)
assertThat(resultFor(state, SelfCheckId.NEXT_ALARM).status).isEqualTo(CheckStatus.PROBLEM)
}
private companion object {
const val OWN_PACKAGE = "de.jeanlucmakiola.clockula"
const val INTERRUPTION_FILTER_ALL = 1
const val INTERRUPTION_FILTER_NONE = 3
}
}
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.emptyPreferences
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
import de.jeanlucmakiola.clockula.alarm.AlarmScheduler
import de.jeanlucmakiola.clockula.alarm.DeviceStateReader
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
import de.jeanlucmakiola.clockula.data.prefs.BootStateStore
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
@@ -17,6 +18,7 @@ import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.alarm.CapabilitySnapshot
import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
import de.jeanlucmakiola.clockula.domain.selfcheck.DeviceState
import de.jeanlucmakiola.clockula.domain.time.BootId
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
@@ -79,6 +81,10 @@ class FakeAlarmScheduler(
}
override fun systemNextAlarm(): Instant? = next?.fireAt
var nextOwnerPackage: String? = null
override fun systemNextAlarmOwnerPackage(): String? = nextOwnerPackage
}
/** What the ring coordinator was told to do, in order — the order is the contract (D7). */
@@ -137,6 +143,35 @@ class FakeAlarmCapabilities(
override fun snapshot(): CapabilitySnapshot = snapshot
}
/** A canned self-check read, swappable per test; defaults to an all-clear [DeviceState]. */
class FakeDeviceStateReader(
var state: DeviceState = DeviceState(
sdkInt = 34,
canScheduleExactAlarms = true,
notificationsEnabled = true,
alarmChannelBlocked = false,
canUseFullScreenIntent = true,
ignoringBatteryOptimizations = true,
backgroundRestricted = false,
interruptionFilter = 1, // NotificationManager.INTERRUPTION_FILTER_ALL
alarmStreamVolume = 5,
alarmStreamMaxVolume = 7,
manufacturer = "Google",
ownPackageName = "de.jeanlucmakiola.clockula",
systemNextAlarm = null,
systemNextAlarmOwnerPackage = null,
expectedNextFire = null,
),
) : DeviceStateReader {
var reads: Int = 0
private set
override suspend fun read(): DeviceState {
reads++
return state
}
}
/** The device's zone, which a test can change under a scheduled alarm. */
class FakeZoneProvider(
var zone: ZoneId = BERLIN,
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.clockula.ui.settings
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.selfcheck.SelfCheckId
import de.jeanlucmakiola.clockula.testing.FakeDeviceStateReader
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
class SelfCheckViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
@Test
fun `loads results from the device-state reader on construction`() = runTest(main.dispatcher) {
val reader = FakeDeviceStateReader()
val viewModel = SelfCheckViewModel(reader)
backgroundScope.launch { viewModel.state.collect {} }
val loaded = viewModel.state.value as SelfCheckUiState.Loaded
assertThat(loaded.results).isNotEmpty()
assertThat(reader.reads).isEqualTo(1)
}
@Test
fun `refresh re-reads device state`() = runTest(main.dispatcher) {
val reader = FakeDeviceStateReader()
val viewModel = SelfCheckViewModel(reader)
backgroundScope.launch { viewModel.state.collect {} }
viewModel.refresh()
assertThat(reader.reads).isEqualTo(2)
}
@Test
fun `a device with something wrong is reflected in the loaded results`() = runTest(main.dispatcher) {
val reader = FakeDeviceStateReader(
state = FakeDeviceStateReader().state.copy(notificationsEnabled = false),
)
val viewModel = SelfCheckViewModel(reader)
backgroundScope.launch { viewModel.state.collect {} }
val loaded = viewModel.state.value as SelfCheckUiState.Loaded
assertThat(loaded.results.any { it.id == SelfCheckId.NOTIFICATIONS }).isTrue()
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.settings
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.testing.FakeDeviceStateReader
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
@@ -40,6 +41,7 @@ class SettingsViewModelTest {
ringtones = FakeRingtoneCatalog(),
preview = FakeRingtonePreviewer(),
worldClocks = harness.source,
deviceState = FakeDeviceStateReader(),
)
}