mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(monitor): Detect OS kills of the monitor and show a recovery hint
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user