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
+ }
+}