From 2672dbdcf6b942613d11b9cf0481268a00090ee7 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn Date: Fri, 17 Jul 2026 17:24:41 +0200 Subject: [PATCH] feat(monitor): Detect OS kills of the monitor and show a recovery hint --- app/src/main/AndroidManifest.xml | 5 + .../darken/capod/main/core/GeneralSettings.kt | 20 +++ .../capod/main/ui/overview/OverviewScreen.kt | 19 +++ .../main/ui/overview/OverviewViewModel.kt | 41 ++++- .../overview/cards/MonitorKilledHintCard.kt | 97 +++++++++++ .../capod/monitor/core/MonitorKillDetector.kt | 150 ++++++++++++++++++ .../capod/monitor/core/MonitorKillGuidance.kt | 80 ++++++++++ .../monitor/core/worker/MonitorService.kt | 16 ++ app/src/main/res/values/strings.xml | 5 + .../main/ui/overview/OverviewViewModelTest.kt | 36 +++++ .../monitor/core/MonitorKillDetectorTest.kt | 114 +++++++++++++ 11 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitorKilledHintCard.kt create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/MonitorKillDetector.kt create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/MonitorKillGuidance.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/MonitorKillDetectorTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 385a54d7..01d328b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,11 @@ android:name="android.hardware.bluetooth_le" android:required="true" /> + + + + + ( + "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( diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 0ad67d7b..dd2a265d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -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") { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 47bcc0e2..eaae2e74 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -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() @@ -89,9 +91,21 @@ class OverviewViewModel @Inject constructor( val reactionsHintDismissed: Boolean, val hideUnmatchedDevices: Boolean, val showTroubleshootSuggestion: Boolean, + val showOsKillHint: Boolean, val batteryEstimates: Map, ) + /** + * 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 = 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) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitorKilledHintCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitorKilledHintCard.kt new file mode 100644 index 00000000..a1b8a46a --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MonitorKilledHintCard.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/MonitorKillDetector.kt b/app/src/main/java/eu/darken/capod/monitor/core/MonitorKillDetector.kt new file mode 100644 index 00000000..e92ff6cf --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/MonitorKillDetector.kt @@ -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, + 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") + } +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/MonitorKillGuidance.kt b/app/src/main/java/eu/darken/capod/monitor/core/MonitorKillGuidance.kt new file mode 100644 index 00000000..7637f8e1 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/MonitorKillGuidance.kt @@ -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` `` 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") + } +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 13707672..ea2a0c54 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c872e5f..8d8a9bee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -235,6 +235,11 @@ Bluetooth is disabled, enable it ;) Monitoring for devices Make sure your device is nearby and active. + Your phone stopped CAPod + 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. + Show instructions + Autostart settings + Dismiss Connected, but no data 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. Run troubleshooter diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index e85bf8c1..73f8b192 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -71,6 +71,8 @@ class OverviewViewModelTest : BaseTest() { private lateinit var effectiveModeFlow: MutableStateFlow private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue + private lateinit var fakeLastOsKillAt: FakeDataStoreValue + private lateinit var fakeOsKillHintDismissedAt: FakeDataStoreValue @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().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().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() diff --git a/app/src/test/java/eu/darken/capod/monitor/core/MonitorKillDetectorTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/MonitorKillDetectorTest.kt new file mode 100644 index 00000000..2aa5e9d4 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/MonitorKillDetectorTest.kt @@ -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 + } +}