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:
@@ -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.
|
||||
|
||||
+67
@@ -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. */
|
||||
|
||||
@@ -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 & restore</string>
|
||||
<string name="settings_backup_title">Back up & 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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user