feat(monitor): Detect OS kills of the monitor and show a recovery hint

This commit is contained in:
Matthias Urhahn
2026-07-17 17:24:41 +02:00
parent 3ddd5f3cfd
commit 2672dbdcf6
11 changed files with 582 additions and 1 deletions
+5
View File
@@ -43,6 +43,11 @@
android:name="android.hardware.bluetooth_le"
android:required="true" />
<!-- Package visibility (API 30+): resolving the MIUI/HyperOS autostart settings screen. -->
<queries>
<package android:name="com.miui.securitycenter" />
</queries>
<application
android:name=".App"
android:allowBackup="true"
@@ -16,6 +16,7 @@ import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.monitor.core.MonitorSessionMark
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
@@ -81,8 +82,27 @@ class GeneralSettings @Inject constructor(
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
/**
* Identity of the currently running monitor session, null while it isn't running.
* Set atomically on monitor start, cleared on clean [android.app.Service.onDestroy] — an OS
* force-stop skips onDestroy, so a non-null value at the next monitor start is evidence of an
* unclean death (see MonitorKillDetector).
*/
val monitorSessionMark = dataStore.createValue<MonitorSessionMark?>(
"core.monitor.health.session", null, json, onErrorFallbackToDefault = true,
)
/** Timestamp of the newest [android.app.ApplicationExitInfo] record already processed. */
val exitInfoWatermark = dataStore.createValue("core.monitor.health.exitinfo.watermark", 0L)
/** Timestamp of the most recent detected OS kill of the monitor (0 = none). */
val lastOsKillAt = dataStore.createValue("core.monitor.health.oskill.last", 0L)
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
/** When the "your phone stopped CAPod" hint was dismissed; re-shown only for newer kills. */
val osKillHintDismissedAt = dataStore.createValue("ui.hint.oskill.dismissed", 0L)
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val themeMode = dataStore.createValue(
@@ -56,6 +56,7 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
import eu.darken.capod.main.ui.overview.cards.MonitorKilledHintCard
import eu.darken.capod.main.ui.overview.cards.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard
@@ -175,6 +176,9 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() },
onOsKillShowInstructions = { vm.onOsKillShowInstructions() },
onOsKillAutostartSettings = { vm.onOsKillAutostartSettings() },
onOsKillDismiss = { vm.dismissOsKillHint() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
@@ -198,6 +202,9 @@ fun OverviewScreen(
onManageDevices: () -> Unit,
onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {},
onOsKillShowInstructions: () -> Unit = {},
onOsKillAutostartSettings: () -> Unit = {},
onOsKillDismiss: () -> Unit = {},
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
@@ -294,6 +301,18 @@ fun OverviewScreen(
)
}
// 1b. OS killed the monitor — historical fact, shown regardless of current BT/scan state
if (state.showOsKillHint) {
item(key = "os_kill_hint") {
MonitorKilledHintCard(
showAutostartAction = state.showOsKillAutostartAction,
onShowInstructions = onOsKillShowInstructions,
onAutostartSettings = onOsKillAutostartSettings,
onDismiss = onOsKillDismiss,
)
}
}
// 2. Bluetooth disabled card
if (!state.isBluetoothEnabled && !state.isScanBlocked) {
item(key = "bluetooth_disabled") {
@@ -21,6 +21,7 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorKillGuidance
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate
@@ -64,6 +65,7 @@ class OverviewViewModel @Inject constructor(
private val monitorModeResolver: MonitorModeResolver,
private val batteryEstimator: BatteryEstimator,
private val timeSource: TimeSource,
private val killGuidance: MonitorKillGuidance,
) : ViewModel4(dispatcherProvider) {
val requestPermissionEvent = SingleEventFlow<Permission>()
@@ -89,9 +91,21 @@ class OverviewViewModel @Inject constructor(
val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean,
val showOsKillHint: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>,
)
/**
* True when the OS killed the monitor (see MonitorKillDetector) and the user hasn't dismissed
* the hint since — dismissal only hides kills up to that point, a newer kill re-shows it.
*/
private val osKillHint = combineFlows(
generalSettings.lastOsKillAt.flow,
generalSettings.osKillHintDismissedAt.flow,
) { lastKillAt, dismissedAt ->
lastKillAt > 0 && lastKillAt > dismissedAt
}.distinctUntilChanged()
/**
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
@@ -125,12 +139,14 @@ class OverviewViewModel @Inject constructor(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion,
osKillHint,
batteryEstimator.estimates,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates ->
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, showOsKillHint, batteryEstimates ->
OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion,
showOsKillHint = showOsKillHint,
batteryEstimates = batteryEstimates,
)
}
@@ -221,6 +237,8 @@ class OverviewViewModel @Inject constructor(
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
showOsKillHint = uiSettings.showOsKillHint,
showOsKillAutostartAction = killGuidance.hasAutostartSettings,
batteryEstimates = uiSettings.batteryEstimates,
)
}.asLiveState()
@@ -240,6 +258,8 @@ class OverviewViewModel @Inject constructor(
val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
val showOsKillHint: Boolean = false,
val showOsKillAutostartAction: Boolean = false,
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
@@ -356,6 +376,25 @@ class OverviewViewModel @Inject constructor(
}
}
fun onOsKillShowInstructions() {
log(TAG, INFO) { "onOsKillShowInstructions()" }
killGuidance.openKillInstructions()
}
fun onOsKillAutostartSettings() {
log(TAG, INFO) { "onOsKillAutostartSettings()" }
killGuidance.openAutostartSettings()
}
fun dismissOsKillHint() {
log(TAG, INFO) { "dismissOsKillHint()" }
launch {
// Anchor the dismissal to the kill being dismissed, not wall-clock "now" — clock
// jumps must neither keep the card visible nor suppress future kills.
generalSettings.osKillHintDismissedAt.value(generalSettings.lastOsKillAt.value())
}
}
fun requestPermission(permission: Permission) {
log(TAG, INFO) { "requestPermission($permission)" }
requestPermissionEvent.tryEmit(permission)
@@ -0,0 +1,97 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatterySaver
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun MonitorKilledHintCard(
showAutostartAction: Boolean,
onShowInstructions: () -> Unit,
onAutostartSettings: () -> Unit,
onDismiss: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.BatterySaver,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = stringResource(R.string.overview_os_kill_hint_label),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_os_kill_hint_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
// Stacked instead of a single Row — up to three actions don't fit on narrow screens,
// especially localized or at increased font scale.
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.End,
) {
Button(onClick = onShowInstructions) {
Text(text = stringResource(R.string.overview_os_kill_hint_instructions_action))
}
if (showAutostartAction) {
OutlinedButton(onClick = onAutostartSettings) {
Text(text = stringResource(R.string.overview_os_kill_hint_autostart_action))
}
}
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.overview_os_kill_hint_dismiss_action))
}
}
}
}
}
@Preview2
@Composable
private fun MonitorKilledHintCardPreview() = PreviewWrapper {
MonitorKilledHintCard(
showAutostartAction = true,
onShowInstructions = {},
onAutostartSettings = {},
onDismiss = {},
)
}
@@ -0,0 +1,150 @@
package eu.darken.capod.monitor.core
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Process
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.main.core.GeneralSettings
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
import javax.inject.Inject
import javax.inject.Singleton
/**
* Identity of a monitoring session, persisted while the monitor runs (see
* [GeneralSettings.monitorSessionMark]). [pid] ties exit records to the exact process that was
* monitoring, [versionCode] filters out app-update kills (which report REASON_USER_REQUESTED
* before API 34).
*/
@Serializable
data class MonitorSessionMark(
val startedAt: Long,
val pid: Int,
val versionCode: Long,
)
/**
* Detects that the OS force-stopped us while the monitor was running (e.g. MIUI/HyperOS killing
* the app when the user clears recents), so the UI can point the user at vendor settings
* (autostart, battery restrictions, lock-in-recents).
*
* A force-stop can't be observed from inside the dying process: `onDestroy()`/`onTaskRemoved()`
* are skipped and `START_STICKY` restarts are suppressed for force-stopped apps. Instead,
* [GeneralSettings.monitorSessionMark] acts as a breadcrumb — set when monitoring starts, cleared
* on clean service destroy — and on the next monitor start the previous mark is checked against
* the system's [ApplicationExitInfo] records.
*/
@Singleton
class MonitorKillDetector @Inject constructor(
@ApplicationContext private val context: Context,
private val generalSettings: GeneralSettings,
private val timeSource: TimeSource,
) {
/**
* Marks the monitor as running and evaluates whether the previous monitoring session was
* killed by the OS.
*
* The atomic swap makes this race-free against concurrent starts: a session that already
* restarted leaves a mark *newer* than any exit record, which never qualifies — a kill the
* system recovered from by itself needs no user-facing hint.
*/
suspend fun onMonitorStart() {
val current = MonitorSessionMark(
startedAt = timeSource.currentTimeMillis(),
pid = Process.myPid(),
versionCode = BuildConfigWrap.VERSION_CODE,
)
val previous = generalSettings.monitorSessionMark.update { current }.old
try {
evaluate(previous = previous, current = current)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Exit record evaluation failed: ${e.asLog()}" }
}
}
/** Clears the breadcrumb — call on clean service destroy. Skipped by OS force-stops. */
fun onMonitorCleanStopBlocking() {
generalSettings.monitorSessionMark.valueBlocking = null
}
// NewApi: lint can't follow the hasApiLevel(30) guard below.
@SuppressLint("NewApi")
private suspend fun evaluate(previous: MonitorSessionMark?, current: MonitorSessionMark) {
if (!hasApiLevel(30)) return
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val records = activityManager.getHistoricalProcessExitReasons(null, 0, MAX_RECORDS)
if (records.isEmpty()) return
val watermark = generalSettings.exitInfoWatermark.value()
val newRecords = records.filter { it.timestamp > watermark }
// Full record dumps end up in user debug logs — field data to verify/extend the
// vendor-specific reason codes we match on.
newRecords.forEach { log(TAG, INFO) { "New exit record: $it" } }
val killedAt = findOsKill(
records = newRecords.map { ExitRecord(reason = it.reason, timestamp = it.timestamp, pid = it.pid) },
previous = previous,
currentVersionCode = current.versionCode,
)
if (killedAt != null) {
log(TAG, WARN) { "OS killed the monitor at $killedAt (session was $previous)" }
generalSettings.lastOsKillAt.value(killedAt)
}
val newest = records.maxOf { it.timestamp }
if (newest > watermark) generalSettings.exitInfoWatermark.value(newest)
}
internal data class ExitRecord(val reason: Int, val timestamp: Long, val pid: Int)
companion object {
private const val MAX_RECORDS = 10
/**
* Newest exit record showing the OS killed us while the monitor was running, or null.
*
* Only [ApplicationExitInfo.REASON_USER_REQUESTED] counts: MIUI-style force-stops
* (recents-clear, security-app cleaners, settings force-stop) report it, while crashes,
* low-memory kills and self-stops must not trigger the hint. REASON_SIGNALED is deliberately
* excluded — LMKD SIGKILLs on ordinary low-memory devices would be false positives.
*
* A record must match the previous session's [MonitorSessionMark.pid] (so a swipe-kill of a
* later UI-only process isn't blamed on an old monitoring session) and postdate its start
* (which also dedups: once a new session's mark is written, older records can never match
* again). Records are ignored entirely when the app version changed, because before API 34
* package updates also killed the old process with REASON_USER_REQUESTED.
*/
internal fun findOsKill(
records: List<ExitRecord>,
previous: MonitorSessionMark?,
currentVersionCode: Long,
): Long? {
if (previous == null) return null
if (previous.versionCode != currentVersionCode) return null
return records
.filter { it.reason == ApplicationExitInfo.REASON_USER_REQUESTED }
.filter { it.pid == previous.pid }
.filter { it.timestamp > previous.startedAt }
.maxOfOrNull { it.timestamp }
}
private val TAG = logTag("Monitor", "KillDetector")
}
}
@@ -0,0 +1,80 @@
package eu.darken.capod.monitor.core
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import javax.inject.Inject
/**
* Remedies for OS kills detected by [MonitorKillDetector]: vendor-specific "don't kill my app"
* instructions and, where the device has one (MIUI/HyperOS), the autostart permission screen.
*/
@Reusable
class MonitorKillGuidance @Inject constructor(
@ApplicationContext private val context: Context,
private val webpageTool: WebpageTool,
) {
/**
* Whether the MIUI/HyperOS autostart settings screen exists on this device.
* Requires the `com.miui.securitycenter` `<queries>` entry in the manifest — package
* visibility filtering makes this resolve to null otherwise on API 30+.
*/
val hasAutostartSettings: Boolean by lazy {
autostartIntent.resolveActivity(context.packageManager) != null
}
fun openKillInstructions() {
webpageTool.open(dontKillMyAppUrl(Build.MANUFACTURER))
}
fun openAutostartSettings() {
try {
context.startActivity(autostartIntent)
} catch (e: Exception) {
// ActivityNotFoundException on most builds, SecurityException on some MIUI versions.
log(TAG, WARN) { "Failed to open autostart settings: ${e.asLog()}" }
openKillInstructions()
}
}
private val autostartIntent: Intent
get() = Intent().apply {
component = ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity",
)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
companion object {
internal fun dontKillMyAppUrl(manufacturer: String): String {
val slug = when (manufacturer.lowercase()) {
"xiaomi", "redmi", "poco" -> "xiaomi"
"huawei", "honor" -> "huawei"
"samsung" -> "samsung"
"oneplus" -> "oneplus"
"oppo" -> "oppo"
"realme" -> "realme"
"vivo", "iqoo" -> "vivo"
"meizu" -> "meizu"
"asus" -> "asus"
"sony" -> "sony"
"lenovo" -> "lenovo"
"motorola" -> "motorola"
else -> null
}
return if (slug != null) "https://dontkillmyapp.com/$slug" else "https://dontkillmyapp.com/"
}
private val TAG = logTag("Monitor", "KillGuidance")
}
}
@@ -29,6 +29,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.MonitorKillDetector
import eu.darken.capod.monitor.core.battery.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey
@@ -92,12 +93,14 @@ class MonitorService : Service() {
@Inject lateinit var aapConnectionManager: AapConnectionManager
@Inject lateinit var monitorModeResolver: MonitorModeResolver
@Inject lateinit var batteryEstimator: BatteryEstimator
@Inject lateinit var killDetector: MonitorKillDetector
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@Volatile private var monitorGeneration = 0
private var foregroundStartFailed = false
private var injectionComplete = false
@Volatile private var monitorMarkedActive = false
@Volatile
private var latestNotificationSettings: NotificationSettings =
@@ -215,6 +218,9 @@ class MonitorService : Service() {
return
}
monitorMarkedActive = true
killDetector.onMonitorStart()
val deviceFlow = deviceMonitor.primaryDeviceByTier
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChangedBy { it?.toNotificationKey() }
@@ -396,6 +402,16 @@ class MonitorService : Service() {
monitorScope.cancel("Service destroyed")
if (injectionComplete) {
// Clean shutdown — a force-stop skips onDestroy(), leaving the breadcrumb set. Only
// clear it if this instance claimed it: an aborted start (missing permissions) must
// not erase evidence left behind by an earlier killed session.
if (monitorMarkedActive) {
try {
killDetector.onMonitorCleanStopBlocking()
} catch (e: Exception) {
log(TAG, WARN) { "Failed to clear monitor breadcrumb: ${e.message}" }
}
}
try {
chargedReactionNotifications.cancelAll()
} catch (e: Exception) {
+5
View File
@@ -235,6 +235,11 @@
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string>
<string name="overview_monitoring_active_label">Monitoring for devices</string>
<string name="overview_monitoring_active_description">Make sure your device is nearby and active.</string>
<string name="overview_os_kill_hint_label">Your phone stopped CAPod</string>
<string name="overview_os_kill_hint_description">This device\'s battery management force-closed CAPod, removing the battery notification. To keep monitoring alive, allow CAPod to start automatically, set its battery usage to unrestricted, and lock CAPod in the recent apps screen.</string>
<string name="overview_os_kill_hint_instructions_action">Show instructions</string>
<string name="overview_os_kill_hint_autostart_action">Autostart settings</string>
<string name="overview_os_kill_hint_dismiss_action">Dismiss</string>
<string name="overview_troubleshoot_suggestion_label">Connected, but no data</string>
<string name="overview_troubleshoot_suggestion_description">Your device is connected, but CAPod isn\'t receiving any live data from it. Your phone may need a compatibility option — the troubleshooter can try to find one automatically.</string>
<string name="overview_troubleshoot_suggestion_action">Run troubleshooter</string>
@@ -71,6 +71,8 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
private lateinit var fakeLastOsKillAt: FakeDataStoreValue<Long>
private lateinit var fakeOsKillHintDismissedAt: FakeDataStoreValue<Long>
@BeforeEach
fun setup() {
@@ -86,6 +88,8 @@ class OverviewViewModelTest : BaseTest() {
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
fakeLastOsKillAt = FakeDataStoreValue(0L)
fakeOsKillHintDismissedAt = FakeDataStoreValue(0L)
Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true)
@@ -104,6 +108,8 @@ class OverviewViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also {
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
every { it.lastOsKillAt } returns fakeLastOsKillAt.mock
every { it.osKillHintDismissedAt } returns fakeOsKillHintDismissedAt.mock
}
batteryEstimator = mockk<BatteryEstimator>().also {
@@ -148,6 +154,7 @@ class OverviewViewModelTest : BaseTest() {
monitorModeResolver = monitorModeResolver,
batteryEstimator = batteryEstimator,
timeSource = timeSource,
killGuidance = mockk(relaxed = true),
)
@Nested
@@ -165,6 +172,23 @@ class OverviewViewModelTest : BaseTest() {
state.showUnmatchedDevices shouldBe false
}
@Test
fun `os kill hint shows for undismissed kills and re-shows only for newer kills`() = runTest(testDispatcher) {
val vm = createViewModel()
vm.state.first().showOsKillHint shouldBe false
fakeLastOsKillAt.value = 5000L
vm.state.first().showOsKillHint shouldBe true
// Dismissal (dismissedAt = now) hides the kill that was just seen ...
fakeOsKillHintDismissedAt.value = 6000L
vm.state.first().showOsKillHint shouldBe false
// ... but a newer kill re-shows the hint.
fakeLastOsKillAt.value = 7000L
vm.state.first().showOsKillHint shouldBe true
}
@Test
fun `devices empty when permissions missing`() = runTest(testDispatcher) {
missingPermissionsFlow.value = setOf(Permission.BLUETOOTH)
@@ -537,6 +561,18 @@ class OverviewViewModelTest : BaseTest() {
updated.showUnmatchedDevices shouldBe true
}
@Test
fun `dismissOsKillHint anchors dismissal to the observed kill, not the clock`() = runTest(testDispatcher) {
fakeLastOsKillAt.value = 5000L
val vm = createViewModel()
vm.dismissOsKillHint()
advanceUntilIdle()
fakeOsKillHintDismissedAt.value shouldBe 5000L
vm.state.first().showOsKillHint shouldBe false
}
@Test
fun `onPermissionResult calls permissionTool recheck`() = runTest(testDispatcher) {
val vm = createViewModel()
@@ -0,0 +1,114 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.monitor.core.MonitorKillDetector.ExitRecord
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class MonitorKillDetectorTest : BaseTest() {
// ApplicationExitInfo constants — inlined here to avoid android.jar in unit tests.
private val userRequested = 10 // ApplicationExitInfo.REASON_USER_REQUESTED
private val crash = 4 // ApplicationExitInfo.REASON_CRASH
private val lowMemory = 3 // ApplicationExitInfo.REASON_LOW_MEMORY
private val exitSelf = 1 // ApplicationExitInfo.REASON_EXIT_SELF
private val signaled = 2 // ApplicationExitInfo.REASON_SIGNALED
private val session = MonitorSessionMark(startedAt = 1000L, pid = 42, versionCode = 7L)
@Test
fun `no records - no kill`() {
MonitorKillDetector.findOsKill(
records = emptyList(),
previous = session,
currentVersionCode = 7L,
) shouldBe null
}
@Test
fun `user requested kill of the monitoring process is detected`() {
MonitorKillDetector.findOsKill(
records = listOf(ExitRecord(reason = userRequested, timestamp = 2000L, pid = 42)),
previous = session,
currentVersionCode = 7L,
) shouldBe 2000L
}
@Test
fun `no previous session - no kill regardless of records`() {
MonitorKillDetector.findOsKill(
records = listOf(ExitRecord(reason = userRequested, timestamp = 2000L, pid = 42)),
previous = null,
currentVersionCode = 7L,
) shouldBe null
}
@Test
fun `session newer than record - no kill`() {
// A restarted session's mark postdates the exit record: the kill was self-recovered.
MonitorKillDetector.findOsKill(
records = listOf(ExitRecord(reason = userRequested, timestamp = 2000L, pid = 42)),
previous = session.copy(startedAt = 3000L),
currentVersionCode = 7L,
) shouldBe null
}
@Test
fun `pid mismatch - no kill`() {
// A swipe-kill of a later UI-only process must not be blamed on the old monitor session.
MonitorKillDetector.findOsKill(
records = listOf(ExitRecord(reason = userRequested, timestamp = 2000L, pid = 99)),
previous = session,
currentVersionCode = 7L,
) shouldBe null
}
@Test
fun `app update - no kill`() {
// Before API 34, package updates kill the old process with REASON_USER_REQUESTED too.
MonitorKillDetector.findOsKill(
records = listOf(ExitRecord(reason = userRequested, timestamp = 2000L, pid = 42)),
previous = session,
currentVersionCode = 8L,
) shouldBe null
}
@Test
fun `non user-requested reasons never trigger`() {
listOf(crash, lowMemory, exitSelf, signaled).forEach { reason ->
MonitorKillDetector.findOsKill(
records = listOf(ExitRecord(reason = reason, timestamp = 2000L, pid = 42)),
previous = session,
currentVersionCode = 7L,
) shouldBe null
}
}
@Test
fun `multiple qualifying records - newest wins`() {
MonitorKillDetector.findOsKill(
records = listOf(
ExitRecord(reason = userRequested, timestamp = 2000L, pid = 42),
ExitRecord(reason = userRequested, timestamp = 4000L, pid = 42),
ExitRecord(reason = crash, timestamp = 5000L, pid = 42),
ExitRecord(reason = userRequested, timestamp = 3000L, pid = 42),
),
previous = session,
currentVersionCode = 7L,
) shouldBe 4000L
}
@Test
fun `mixed records - only qualifying user-requested ones count`() {
MonitorKillDetector.findOsKill(
records = listOf(
ExitRecord(reason = userRequested, timestamp = 500L, pid = 42), // predates session
ExitRecord(reason = crash, timestamp = 2500L, pid = 42),
ExitRecord(reason = userRequested, timestamp = 2200L, pid = 99), // different process
ExitRecord(reason = userRequested, timestamp = 2000L, pid = 42),
),
previous = session,
currentVersionCode = 7L,
) shouldBe 2000L
}
}