diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt index dc3c81a..e18f065 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmScheduler.kt @@ -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? } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/DeviceStateReader.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/DeviceStateReader.kt new file mode 100644 index 0000000..fec3285 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/DeviceStateReader.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt index bbd8669..98ace1e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidAlarmScheduler.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidDeviceStateReader.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidDeviceStateReader.kt new file mode 100644 index 0000000..072195c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/android/AndroidDeviceStateReader.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/di/AlarmModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/di/AlarmModule.kt index 130a5e4..1bc8a3e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/di/AlarmModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/di/AlarmModule.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt new file mode 100644 index 0000000..c6a208d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheck.kt @@ -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 = setOf( + "xiaomi", "huawei", "honor", "oneplus", "oppo", "vivo", "asus", "samsung", + ) + + private const val INTERRUPTION_FILTER_NONE = 3 + + fun evaluate(state: DeviceState): List = 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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckScreen.kt index 9c93c4a..22a8d34 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckScreen.kt @@ -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) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckUiState.kt new file mode 100644 index 0000000..56d1e08 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckUiState.kt @@ -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) : SelfCheckUiState { + val problemCount: Int = results.count { it.status == CheckStatus.PROBLEM } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckViewModel.kt new file mode 100644 index 0000000..fe408e5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckViewModel.kt @@ -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.Loading) + internal val state: StateFlow = _state.asStateFlow() + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + val results = SelfCheck.evaluate(deviceState.read()) + _state.value = SelfCheckUiState.Loaded(results) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt index 7af1425..c1e235c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt index 191937d..2b59f87 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt @@ -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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index 8c47e46..3bb8a60 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -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(null) + internal val state: StateFlow = 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?>(null) /** Null while closed; the kind being edited and its picker state while open. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1638d35..1468b8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,6 +270,11 @@ Why might my alarm not ring? Why might my alarm not ring? Check exact-alarm, notification and battery settings + + %1$d problem found + %1$d problems found + + Everything looks fine Data Back up & restore Back up & restore @@ -294,4 +299,46 @@ Replace everything? This replaces all your current alarms, timers, world clocks and settings with what\'s in the backup file. This can\'t be undone. Replace + + + Fix + + Exact alarms + Allowed — your alarm will fire on time + Not allowed — alarms may fire late + + Notifications + Allowed — the ring screen can show over other apps + Off — your alarm will still sound, but no screen or heads-up banner will show + + Alarm channel + Not blocked + Blocked — your alarm will still sound, but no screen or heads-up banner will show + + Full-screen alarm + Allowed — the ring screen can show over the lock screen + Not allowed — your alarm will still sound, as a heads-up notification instead + + Battery optimisation + Exempt + Not exempt — exempting Clockula can make alarms more reliable + + Background activity + Not restricted + Restricted — alarms may not fire reliably + + Do Not Disturb + Not blocking alarms + Total silence is on — it blocks alarms too + + Alarm volume + On + Muted — your alarm will fire silently + + Device manufacturer + Some manufacturers aggressively close background apps — see how to keep Clockula exempt + + Next alarm, as the system sees it + Matches what Clockula scheduled + Doesn\'t match what Clockula scheduled — open the app to re-check diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheckTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheckTest.kt new file mode 100644 index 0000000..81b0739 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/selfcheck/SelfCheckTest.kt @@ -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 + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt index 76708b2..591ec13 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt @@ -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, diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckViewModelTest.kt new file mode 100644 index 0000000..1c5e830 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckViewModelTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt index 20cdbb4..43b3bc9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt @@ -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(), ) }