Compare commits

..
Author SHA1 Message Date
Matthias Urhahn 911a09c743 fix(permissions): Offer battery optimization card in AUTOMATIC mode 2026-07-17 17:24:48 +02:00
Matthias Urhahn 2672dbdcf6 feat(monitor): Detect OS kills of the monitor and show a recovery hint 2026-07-17 17:24:41 +02:00
d4rken-org-releaser[bot] 3ddd5f3cfd Release: 5.2.1-rc0 2026-07-08 11:21:18 +00:00
Matthias Urhahn 31f4d4aafa fix(battery): Stop remaining-time jumping up when toggling ANC
A mode's own drain-rate bucket starts empty until it accumulates history,
so toggling ANC into an unlearned mode fell straight through to Apple's
optimistic spec rating while the mode just left showed its worse measured
rate. Result: enabling ANC could make the displayed time jump up ~1h.

Fill an empty ANC bucket at display time with the less-optimistic of the
mode-agnostic UNKNOWN reading and a sibling mode's learned rate, scaled by
the ratio of the two modes' rated drain. Scoped to spec'd models and
device-supported modes; picks the best-evidenced sibling, tie-broken by
closest rated drain then recency. No persistence or UI change.

The existing spec ceiling and display clamp still backstop the borrowed rate.
2026-07-07 22:23:01 +02:00
Matthias Urhahn e06dc933b8 chore(logging): Log BLE scan reception nanos in scan summaries 2026-07-06 12:42:10 +02:00
Matthias Urhahn 1418be999d feat(reaction): Time-cap auto-pause debounce on slow BLE scanners 2026-07-06 12:42:10 +02:00
20 changed files with 1292 additions and 32 deletions
+1 -1
View File
@@ -1 +1 @@
5.2.0-rc0 50200000 5.2.1-rc0 50201000
+5
View File
@@ -43,6 +43,11 @@
android:name="android.hardware.bluetooth_le" android:name="android.hardware.bluetooth_le"
android:required="true" /> android:required="true" />
<!-- Package visibility (API 30+): resolving the MIUI/HyperOS autostart settings screen. -->
<queries>
<package android:name="com.miui.securitycenter" />
</queries>
<application <application
android:name=".App" android:name=".App"
android:allowBackup="true" android:allowBackup="true"
@@ -14,7 +14,7 @@ fun BleScanResult.logSummary(): String {
.sortedBy { it.key } .sortedBy { it.key }
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" } .joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
.ifEmpty { "-" } .ifEmpty { "-" }
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]" return "addr=${address.redactedForLogs()}, rssi=$rssi, gen=$generatedAtNanos, payloads=[$payloadSummary]"
} }
@JvmName("logBleScanResultCollectionSummary") @JvmName("logBleScanResultCollectionSummary")
@@ -34,7 +34,7 @@ fun ScanResult.logSummary(): String {
.sorted() .sorted()
.joinToString(separator = ",") .joinToString(separator = ",")
.ifEmpty { "-" } .ifEmpty { "-" }
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]" return "addr=${device.address.redactedForLogs()}, rssi=$rssi, gen=$timestampNanos, payloads=[$payloadSummary]"
} }
@JvmName("logFrameworkScanResultCollectionSummary") @JvmName("logFrameworkScanResultCollectionSummary")
@@ -16,6 +16,7 @@ import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.common.theming.ThemeColor import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.common.theming.ThemeStyle 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.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey 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) 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) 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 hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val themeMode = dataStore.createValue( val themeMode = dataStore.createValue(
@@ -64,8 +64,10 @@ class PermissionTool @Inject constructor(
/** /**
* Whether [permission] is applicable for the user's current configuration. * Whether [permission] is applicable for the user's current configuration.
* Three permissions are conditional on monitor mode or popup usage: * Three permissions are conditional on monitor mode or popup usage:
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed * - [Permission.IGNORE_BATTERY_OPTIMIZATION] whenever the monitor service is expected to
* - [Permission.ACCESS_BACKGROUND_LOCATION] same * run unattended (AUTOMATIC and ALWAYS) — those users depend on a long-lived foreground
* service that aggressive vendor battery management may otherwise kill
* - [Permission.ACCESS_BACKGROUND_LOCATION] only when always-on scanning is needed
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled * - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
* Everything else is unconditionally applicable. * Everything else is unconditionally applicable.
*/ */
@@ -74,7 +76,7 @@ class PermissionTool @Inject constructor(
monitorMode: MonitorMode, monitorMode: MonitorMode,
anyPopupEnabled: Boolean, anyPopupEnabled: Boolean,
): Boolean = when (permission) { ): Boolean = when (permission) {
Permission.IGNORE_BATTERY_OPTIMIZATION, Permission.IGNORE_BATTERY_OPTIMIZATION -> monitorMode != MonitorMode.MANUAL
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
else -> true else -> true
@@ -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.BluetoothDisabledCard
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard 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.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.MonitoringActiveCard
import eu.darken.capod.main.ui.overview.cards.NoProfilesCard import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard import eu.darken.capod.main.ui.overview.cards.PermissionCard
@@ -175,6 +176,9 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onManageDevices = { vm.goToDeviceManager() }, onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() }, onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() }, onTroubleShooter = { vm.goToTroubleShooter() },
onOsKillShowInstructions = { vm.onOsKillShowInstructions() },
onOsKillAutostartSettings = { vm.onOsKillAutostartSettings() },
onOsKillDismiss = { vm.dismissOsKillHint() },
onUpgrade = { vm.onUpgrade() }, onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() }, onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) }, onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
@@ -198,6 +202,9 @@ fun OverviewScreen(
onManageDevices: () -> Unit, onManageDevices: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {}, onTroubleShooter: () -> Unit = {},
onOsKillShowInstructions: () -> Unit = {},
onOsKillAutostartSettings: () -> Unit = {},
onOsKillDismiss: () -> Unit = {},
onUpgrade: () -> Unit, onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit, onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> 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 // 2. Bluetooth disabled card
if (!state.isBluetoothEnabled && !state.isScanBlocked) { if (!state.isBluetoothEnabled && !state.isScanBlocked) {
item(key = "bluetooth_disabled") { 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.MonitorMode
import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor 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.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.battery.BatteryEstimate import eu.darken.capod.monitor.core.battery.BatteryEstimate
@@ -64,6 +65,7 @@ class OverviewViewModel @Inject constructor(
private val monitorModeResolver: MonitorModeResolver, private val monitorModeResolver: MonitorModeResolver,
private val batteryEstimator: BatteryEstimator, private val batteryEstimator: BatteryEstimator,
private val timeSource: TimeSource, private val timeSource: TimeSource,
private val killGuidance: MonitorKillGuidance,
) : ViewModel4(dispatcherProvider) { ) : ViewModel4(dispatcherProvider) {
val requestPermissionEvent = SingleEventFlow<Permission>() val requestPermissionEvent = SingleEventFlow<Permission>()
@@ -89,9 +91,21 @@ class OverviewViewModel @Inject constructor(
val reactionsHintDismissed: Boolean, val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean, val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean, val showTroubleshootSuggestion: Boolean,
val showOsKillHint: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>, 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 * 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 * 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.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow, generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion, troubleshootSuggestion,
osKillHint,
batteryEstimator.estimates, batteryEstimator.estimates,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates -> ) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, showOsKillHint, batteryEstimates ->
OverviewUiSettings( OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed, reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched, hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion, showTroubleshootSuggestion = showTroubleshootSuggestion,
showOsKillHint = showOsKillHint,
batteryEstimates = batteryEstimates, batteryEstimates = batteryEstimates,
) )
} }
@@ -221,6 +237,8 @@ class OverviewViewModel @Inject constructor(
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed, showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices, hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion, showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
showOsKillHint = uiSettings.showOsKillHint,
showOsKillAutostartAction = killGuidance.hasAutostartSettings,
batteryEstimates = uiSettings.batteryEstimates, batteryEstimates = uiSettings.batteryEstimates,
) )
}.asLiveState() }.asLiveState()
@@ -240,6 +258,8 @@ class OverviewViewModel @Inject constructor(
val showReactionsHint: Boolean = false, val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false, val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false, val showTroubleshootSuggestion: Boolean = false,
val showOsKillHint: Boolean = false,
val showOsKillAutostartAction: Boolean = false,
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(), val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
) { ) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } 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) { fun requestPermission(permission: Permission) {
log(TAG, INFO) { "requestPermission($permission)" } log(TAG, INFO) { "requestPermission($permission)" }
requestPermissionEvent.tryEmit(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")
}
}
@@ -28,6 +28,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.math.abs
/** /**
* Learns each device's battery drain rate from observed levels over time and turns it into a * Learns each device's battery drain rate from observed levels over time and turns it into a
@@ -569,9 +570,58 @@ class BatteryEstimator @Inject constructor(
private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? { private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? {
val profile = storedProfileFor(profileId, device) ?: return null val profile = storedProfileFor(profileId, device) ?: return null
return (profile.rates[rateKey(bucket, slot)] ?: profile.rates[rateKey(MODE_UNKNOWN, slot)])?.fractionPerHour // Exact per-mode learning always wins — a real measurement for THIS mode.
profile.rates[rateKey(bucket, slot)]?.validFractionPerHour()?.let { return it }
// Empty bucket: fill it conservatively with the less-optimistic (faster-draining) of the
// mode-agnostic UNKNOWN reading and the spec-scaled sibling reading, so toggling ANC into an
// unlearned mode never inflates the estimate past a real sibling measurement.
val unknown = profile.rates[rateKey(MODE_UNKNOWN, slot)]?.validFractionPerHour()
val sibling = siblingScaledRate(profile, device, bucket, slot)
return listOfNotNull(unknown, sibling).maxOrNull()
} }
/**
* Fills an empty ANC bucket by borrowing another mode's learned rate, scaled to this mode by the
* ratio of the two modes' rated drain (`predicted = sibling × specRate(current)/specRate(sibling)`).
* Keeps the estimate continuous across an ANC toggle instead of jumping to the optimistic spec.
*
* Only fires when:
* - the current bucket is a real ANC mode (an UNKNOWN / mode-not-known reading keeps its
* conservative spec-min behaviour), and
* - the model publishes ratings for both modes (without a rating there's no [effectiveRate] ceiling
* or display clamp, so a borrowed rate could over-promise unbounded), and
* - the sibling mode is one the device reports as supported (ignore stale keys for modes this
* hardware can't use), falling back to all modes only when the supported list is unavailable.
*
* Among the candidates the best-evidenced one wins, tie-broken by closest rated drain (best
* physical predictor) then recency. Returns null when nothing trustworthy is available; the caller
* merges the result conservatively with the UNKNOWN reading.
*/
private fun siblingScaledRate(profile: DrainProfile, device: PodDevice, bucket: String, slot: Slot): Float? {
if (bucket == MODE_UNKNOWN) return null
val targetSpec = device.specRate(bucket) ?: return null
val supported = device.ancMode?.supported?.map { it.name }?.takeIf { it.isNotEmpty() }
val best = AapSetting.AncMode.Value.entries
.map { it.name }
.filter { it != bucket && (supported == null || it in supported) }
.mapNotNull { sib ->
val siblingSpec = device.specRate(sib) ?: return@mapNotNull null
val learned = profile.rates[rateKey(sib, slot)]
?.takeIf { it.fractionPerHour.isFinite() && it.fractionPerHour > 0f }
?: return@mapNotNull null
learned to siblingSpec
}
.maxWithOrNull(
compareBy<Pair<DrainProfile.LearnedRate, Float>> { it.first.updateCount }
.thenBy { -abs(targetSpec - it.second) }
.thenBy { it.first.updatedAt },
) ?: return null
return (best.first.fractionPerHour * (targetSpec / best.second)).takeIf { it.isFinite() && it > 0f }
}
private fun DrainProfile.LearnedRate.validFractionPerHour(): Float? =
fractionPerHour.takeIf { it.isFinite() && it > 0f }
private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? = private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? =
storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour
@@ -29,6 +29,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorCoroutineScope 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.BatteryEstimate
import eu.darken.capod.monitor.core.battery.BatteryEstimator import eu.darken.capod.monitor.core.battery.BatteryEstimator
import eu.darken.capod.monitor.core.battery.displayKey import eu.darken.capod.monitor.core.battery.displayKey
@@ -92,12 +93,14 @@ class MonitorService : Service() {
@Inject lateinit var aapConnectionManager: AapConnectionManager @Inject lateinit var aapConnectionManager: AapConnectionManager
@Inject lateinit var monitorModeResolver: MonitorModeResolver @Inject lateinit var monitorModeResolver: MonitorModeResolver
@Inject lateinit var batteryEstimator: BatteryEstimator @Inject lateinit var batteryEstimator: BatteryEstimator
@Inject lateinit var killDetector: MonitorKillDetector
private val monitorScope = MonitorCoroutineScope() private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null private var monitoringJob: Job? = null
@Volatile private var monitorGeneration = 0 @Volatile private var monitorGeneration = 0
private var foregroundStartFailed = false private var foregroundStartFailed = false
private var injectionComplete = false private var injectionComplete = false
@Volatile private var monitorMarkedActive = false
@Volatile @Volatile
private var latestNotificationSettings: NotificationSettings = private var latestNotificationSettings: NotificationSettings =
@@ -215,6 +218,9 @@ class MonitorService : Service() {
return return
} }
monitorMarkedActive = true
killDetector.onMonitorStart()
val deviceFlow = deviceMonitor.primaryDeviceByTier val deviceFlow = deviceMonitor.primaryDeviceByTier
.setupCommonEventHandlers(TAG) { "BlePodMonitor" } .setupCommonEventHandlers(TAG) { "BlePodMonitor" }
.distinctUntilChangedBy { it?.toNotificationKey() } .distinctUntilChangedBy { it?.toNotificationKey() }
@@ -396,6 +402,16 @@ class MonitorService : Service() {
monitorScope.cancel("Service destroyed") monitorScope.cancel("Service destroyed")
if (injectionComplete) { 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 { try {
chargedReactionNotifications.cancelAll() chargedReactionNotifications.cancelAll()
} catch (e: Exception) { } catch (e: Exception) {
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import java.time.Duration
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -223,6 +224,8 @@ class PlayPause @Inject constructor(
rawDecision = confirmation.decision, rawDecision = confirmation.decision,
currentState = currState, currentState = currState,
autoPauseEnabled = reactions.autoPause, autoPauseEnabled = reactions.autoPause,
now = current.ble?.seenLastAt,
generatedAtNanos = current.ble?.scanResult?.generatedAtNanos,
) )
pendingPauseDebounce = debounceResult.pending pendingPauseDebounce = debounceResult.pending
@@ -240,7 +243,7 @@ class PlayPause @Inject constructor(
"rawShouldPlay=${confirmation.decision.shouldPlay}" "rawShouldPlay=${confirmation.decision.shouldPlay}"
} }
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) { PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
"Pause debounce committed: source=$source confirmed pause" "Pause debounce committed: source=$source, ${debounceResult.decision.reason}"
} }
PauseDebounceEvent.NONE -> {} PauseDebounceEvent.NONE -> {}
} }
@@ -438,12 +441,21 @@ class PlayPause @Inject constructor(
} }
/** /**
* Sample-count debounce for pause decisions when the ear-detection source is an * Hybrid sample-count + time-cap debounce for pause decisions when the ear-detection source
* unauthenticated BLE advertisement. * is an unauthenticated BLE advertisement.
* *
* RF interference can produce a single corrupt advert that decodes as not-worn, * RF interference can produce a single corrupt advert that decodes as not-worn, triggering a
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires * false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires 3 consecutive not-worn
* 3 consecutive not-worn samples before firing. * samples before firing on the *count* path.
*
* On OEM stacks that deliver scan results in slow (~1.5-2s) batches, waiting for the full
* sample count would take ~4s. To cap that, the pause also commits early once the not-worn
* condition has persisted at least [PAUSE_DEBOUNCE_TIME_CAP] — but only when the samples are
* still strictly consecutive (no tolerated rebound) and the confirming sample is a *distinct*
* radio reception ([BleScanResult.generatedAtNanos] differs from the first). The time path
* therefore never commits on fewer than 2 consecutive, distinct not-worn receptions. Elapsed
* uses [now] (the sample's `ble.seenLastAt`, a callback-receive wall-clock) clamped to ≥ 0.
* When [now]/[generatedAtNanos] are null (no live BLE sample) the time path is inactive.
* *
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause] * The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
* — subsequent samples after the initial detection are not-worn → not-worn, and * — subsequent samples after the initial detection are not-worn → not-worn, and
@@ -462,6 +474,8 @@ class PlayPause @Inject constructor(
rawDecision: PlayPauseDecision, rawDecision: PlayPauseDecision,
currentState: EarDetectionState, currentState: EarDetectionState,
autoPauseEnabled: Boolean, autoPauseEnabled: Boolean,
now: Instant? = null,
generatedAtNanos: Long? = null,
): PauseDebounceResult { ): PauseDebounceResult {
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK || val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
source == EarDetectionSource.BLE_ANONYMOUS source == EarDetectionSource.BLE_ANONYMOUS
@@ -525,6 +539,8 @@ class PlayPause @Inject constructor(
profileId = profileId, profileId = profileId,
initialPodCount = currentState.podCount, initialPodCount = currentState.podCount,
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES, confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
startedAt = now,
startedGeneratedAtNanos = generatedAtNanos,
), ),
event = PauseDebounceEvent.STARTED, event = PauseDebounceEvent.STARTED,
) )
@@ -545,7 +561,10 @@ class PlayPause @Inject constructor(
shouldPause = false, shouldPause = false,
reason = "Debouncing pause (rebound tolerated)", reason = "Debouncing pause (rebound tolerated)",
), ),
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1), pending = activePending.copy(
resetTolerance = activePending.resetTolerance - 1,
reboundTolerated = true,
),
event = PauseDebounceEvent.ADVANCED, event = PauseDebounceEvent.ADVANCED,
) )
} }
@@ -554,12 +573,41 @@ class PlayPause @Inject constructor(
// Confirmation: count <= initialPodCount, decrement remaining. // Confirmation: count <= initialPodCount, decrement remaining.
val remaining = activePending.confirmationsRemaining - 1 val remaining = activePending.confirmationsRemaining - 1
if (remaining <= 0) { val commitOnCount = remaining <= 0
// Time-cap early commit: once the not-worn condition has persisted at least
// PAUSE_DEBOUNCE_TIME_CAP, commit before the full sample count is reached — this caps the
// pause latency on OEM stacks that deliver scan results in slow batches. Three guards keep
// the "≥2 consecutive, distinct not-worn receptions" invariant:
// - reboundTolerated: a count-up rebound broke the consecutive not-worn run → the time
// path is disabled and we fall back to pure sample-count.
// - distinct generatedAtNanos: the confirming sample must be a different radio reception
// than the first, so a stack re-delivering one cached advert in a later batch (fresh
// seenLastAt, same reception) cannot early-commit on a single physical advert. A
// broken/constant OEM timebase just disables the time path (fail-safe to count).
// - elapsed clamped to >= 0: a backward seenLastAt jump (wall-clock step, or the backing
// snapshot switching to a different physical device under BLE_PROFILE_FALLBACK) can
// only delay, never prematurely fire.
val startedAt = activePending.startedAt
val startedNanos = activePending.startedGeneratedAtNanos
val elapsed = if (startedAt != null && now != null) {
Duration.between(startedAt, now).coerceAtLeast(Duration.ZERO)
} else {
null
}
val commitOnTime = !activePending.reboundTolerated &&
elapsed != null && elapsed >= PAUSE_DEBOUNCE_TIME_CAP &&
startedNanos != null && generatedAtNanos != null &&
generatedAtNanos != startedNanos
if (commitOnCount || commitOnTime) {
val mode = if (commitOnCount) "count" else "time(${elapsed?.toMillis()}ms)"
return PauseDebounceResult( return PauseDebounceResult(
decision = PlayPauseDecision( decision = PlayPauseDecision(
shouldPlay = false, shouldPlay = false,
shouldPause = true, shouldPause = true,
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})", reason = "Debounced pause confirmed via $mode " +
"(initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
), ),
pending = null, pending = null,
event = PauseDebounceEvent.COMMITTED, event = PauseDebounceEvent.COMMITTED,
@@ -674,6 +722,18 @@ class PlayPause @Inject constructor(
// shows a pod returning shouldn't kill the pending, since the next sample may // shows a pod returning shouldn't kill the pending, since the next sample may
// confirm the pods are still out. // confirm the pods are still out.
val resetTolerance: Int = 1, val resetTolerance: Int = 1,
// Observation time (ble.seenLastAt, a callback-receive wall-clock) of the first
// not-worn sample. null → the time-cap early commit is inactive for this pending.
val startedAt: Instant? = null,
// Hardware radio-reception timestamp (ScanResult.timestampNanos) of the first
// not-worn sample. The time-cap commit requires the confirming sample to be a
// DISTINCT reception (different generatedAtNanos), so a janky OEM re-delivering the
// same cached advert in a later batch cannot early-commit on one physical advert.
val startedGeneratedAtNanos: Long? = null,
// Set once a count-up rebound has been tolerated. Disables the time-cap early commit
// (the not-worn samples are no longer strictly consecutive), falling back to pure
// sample-count confirmation.
val reboundTolerated: Boolean = false,
) )
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */ /** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
@@ -801,5 +861,19 @@ class PlayPause @Inject constructor(
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total. * decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
*/ */
internal const val PAUSE_DEBOUNCE_SAMPLES = 2 internal const val PAUSE_DEBOUNCE_SAMPLES = 2
/**
* Upper bound on how long the sample-count debounce is allowed to stretch. Once the
* not-worn condition has persisted this long (measured from the first not-worn sample's
* observation time) with at least one further *distinct* not-worn reception and no
* tolerated rebound, the pause commits early — even if [PAUSE_DEBOUNCE_SAMPLES] hasn't
* been reached. This caps the delay on OEM BLE stacks (e.g. Samsung/OneUI) that deliver
* scan results in slow ~1.5-2s batches, where a pure sample count would take ~4s.
*
* Does not weaken corruption protection: an early commit still requires ≥2 consecutive,
* distinct not-worn receptions (see [applyPauseDebounce]). Cadences below ~2× this value
* see no change (count commits first). Tunable.
*/
internal val PAUSE_DEBOUNCE_TIME_CAP: Duration = Duration.ofMillis(1500)
} }
} }
+5
View File
@@ -235,6 +235,11 @@
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string> <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_label">Monitoring for devices</string>
<string name="overview_monitoring_active_description">Make sure your device is nearby and active.</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_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_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> <string name="overview_troubleshoot_suggestion_action">Run troubleshooter</string>
@@ -8,7 +8,7 @@ import testhelpers.BaseTest
class PermissionToolTest : BaseTest() { class PermissionToolTest : BaseTest() {
@Test @Test
fun `IGNORE_BATTERY_OPTIMIZATION is applicable only in ALWAYS mode`() { fun `IGNORE_BATTERY_OPTIMIZATION is applicable in ALWAYS and AUTOMATIC modes`() {
PermissionTool.isApplicable( PermissionTool.isApplicable(
Permission.IGNORE_BATTERY_OPTIMIZATION, Permission.IGNORE_BATTERY_OPTIMIZATION,
MonitorMode.ALWAYS, MonitorMode.ALWAYS,
@@ -18,7 +18,7 @@ class PermissionToolTest : BaseTest() {
Permission.IGNORE_BATTERY_OPTIMIZATION, Permission.IGNORE_BATTERY_OPTIMIZATION,
MonitorMode.AUTOMATIC, MonitorMode.AUTOMATIC,
anyPopupEnabled = false, anyPopupEnabled = false,
) shouldBe false ) shouldBe true
PermissionTool.isApplicable( PermissionTool.isApplicable(
Permission.IGNORE_BATTERY_OPTIMIZATION, Permission.IGNORE_BATTERY_OPTIMIZATION,
MonitorMode.MANUAL, MonitorMode.MANUAL,
@@ -73,13 +73,27 @@ class PermissionToolTest : BaseTest() {
@Test @Test
fun `mode-gated permissions don't depend on popup state`() { fun `mode-gated permissions don't depend on popup state`() {
// IGNORE_BATTERY_OPTIMIZATION and ACCESS_BACKGROUND_LOCATION gate on mode only. // IGNORE_BATTERY_OPTIMIZATION and ACCESS_BACKGROUND_LOCATION gate on mode only
// but on different modes: battery optimization applies to all unattended modes,
// background location only to ALWAYS.
listOf(Permission.IGNORE_BATTERY_OPTIMIZATION, Permission.ACCESS_BACKGROUND_LOCATION).forEach { perm -> listOf(Permission.IGNORE_BATTERY_OPTIMIZATION, Permission.ACCESS_BACKGROUND_LOCATION).forEach { perm ->
PermissionTool.isApplicable(perm, MonitorMode.ALWAYS, anyPopupEnabled = true) shouldBe true PermissionTool.isApplicable(perm, MonitorMode.ALWAYS, anyPopupEnabled = true) shouldBe true
PermissionTool.isApplicable(perm, MonitorMode.ALWAYS, anyPopupEnabled = false) shouldBe true PermissionTool.isApplicable(perm, MonitorMode.ALWAYS, anyPopupEnabled = false) shouldBe true
PermissionTool.isApplicable(perm, MonitorMode.AUTOMATIC, anyPopupEnabled = true) shouldBe false PermissionTool.isApplicable(perm, MonitorMode.MANUAL, anyPopupEnabled = true) shouldBe false
PermissionTool.isApplicable(perm, MonitorMode.AUTOMATIC, anyPopupEnabled = false) shouldBe false PermissionTool.isApplicable(perm, MonitorMode.MANUAL, anyPopupEnabled = false) shouldBe false
} }
PermissionTool.isApplicable(
Permission.IGNORE_BATTERY_OPTIMIZATION, MonitorMode.AUTOMATIC, anyPopupEnabled = true,
) shouldBe true
PermissionTool.isApplicable(
Permission.IGNORE_BATTERY_OPTIMIZATION, MonitorMode.AUTOMATIC, anyPopupEnabled = false,
) shouldBe true
PermissionTool.isApplicable(
Permission.ACCESS_BACKGROUND_LOCATION, MonitorMode.AUTOMATIC, anyPopupEnabled = true,
) shouldBe false
PermissionTool.isApplicable(
Permission.ACCESS_BACKGROUND_LOCATION, MonitorMode.AUTOMATIC, anyPopupEnabled = false,
) shouldBe false
} }
@Test @Test
@@ -71,6 +71,8 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode> private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean> private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean> private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
private lateinit var fakeLastOsKillAt: FakeDataStoreValue<Long>
private lateinit var fakeOsKillHintDismissedAt: FakeDataStoreValue<Long>
@BeforeEach @BeforeEach
fun setup() { fun setup() {
@@ -86,6 +88,8 @@ class OverviewViewModelTest : BaseTest() {
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC) effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false) fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false) fakeHideUnmatchedDevices = FakeDataStoreValue(false)
fakeLastOsKillAt = FakeDataStoreValue(0L)
fakeOsKillHintDismissedAt = FakeDataStoreValue(0L)
Bugs.isDebug.value = false Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true) monitorControl = mockk(relaxed = true)
@@ -104,6 +108,8 @@ class OverviewViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also { generalSettings = mockk<GeneralSettings>().also {
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
every { it.lastOsKillAt } returns fakeLastOsKillAt.mock
every { it.osKillHintDismissedAt } returns fakeOsKillHintDismissedAt.mock
} }
batteryEstimator = mockk<BatteryEstimator>().also { batteryEstimator = mockk<BatteryEstimator>().also {
@@ -148,6 +154,7 @@ class OverviewViewModelTest : BaseTest() {
monitorModeResolver = monitorModeResolver, monitorModeResolver = monitorModeResolver,
batteryEstimator = batteryEstimator, batteryEstimator = batteryEstimator,
timeSource = timeSource, timeSource = timeSource,
killGuidance = mockk(relaxed = true),
) )
@Nested @Nested
@@ -165,6 +172,23 @@ class OverviewViewModelTest : BaseTest() {
state.showUnmatchedDevices shouldBe false 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 @Test
fun `devices empty when permissions missing`() = runTest(testDispatcher) { fun `devices empty when permissions missing`() = runTest(testDispatcher) {
missingPermissionsFlow.value = setOf(Permission.BLUETOOTH) missingPermissionsFlow.value = setOf(Permission.BLUETOOTH)
@@ -537,6 +561,18 @@ class OverviewViewModelTest : BaseTest() {
updated.showUnmatchedDevices shouldBe true 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 @Test
fun `onPermissionResult calls permissionTool recheck`() = runTest(testDispatcher) { fun `onPermissionResult calls permissionTool recheck`() = runTest(testDispatcher) {
val vm = createViewModel() 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
}
}
@@ -39,6 +39,8 @@ class BatteryEstimatorTest : BaseTest() {
estimateEnabled: Boolean = true, estimateEnabled: Boolean = true,
worn: Boolean = false, worn: Boolean = false,
systemConnected: Boolean = false, systemConnected: Boolean = false,
ancMode: AapSetting.AncMode.Value? = null,
ancSupported: List<AapSetting.AncMode.Value> = AapSetting.AncMode.Value.entries,
): PodDevice { ): PodDevice {
val state = when { val state = when {
optimized -> ChargingState.CHARGING_OPTIMIZED optimized -> ChargingState.CHARGING_OPTIMIZED
@@ -49,14 +51,19 @@ class BatteryEstimatorTest : BaseTest() {
if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state)) if (left != null) put(BatteryType.LEFT, Battery(BatteryType.LEFT, left, state))
if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state)) if (right != null) put(BatteryType.RIGHT, Battery(BatteryType.RIGHT, right, state))
} }
val settings = if (worn) { val settings = buildMap<kotlin.reflect.KClass<out AapSetting>, AapSetting> {
mapOf<kotlin.reflect.KClass<out AapSetting>, AapSetting>( if (worn) put(
AapSetting.EarDetection::class to AapSetting.EarDetection( AapSetting.EarDetection::class,
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
) ),
) )
} else emptyMap() if (ancMode != null) put(
AapSetting.AncMode::class,
AapSetting.AncMode(current = ancMode, supported = ancSupported),
)
}
return PodDevice( return PodDevice(
profileId = profileId, profileId = profileId,
ble = null, ble = null,
@@ -554,6 +561,251 @@ class BatteryEstimatorTest : BaseTest() {
result["p1"].shouldNotBeNull().left.shouldNotBeNull().source shouldBe BatteryEstimate.Source.SPEC result["p1"].shouldNotBeNull().left.shouldNotBeNull().source shouldBe BatteryEstimate.Source.SPEC
} }
@Test
fun `an empty ANC bucket borrows the sibling rate instead of jumping to spec`() = runTest(UnconfinedTestDispatcher()) {
// Pro 2: OFF learned (5h-equivalent), user toggles to ON whose bucket is empty. Both modes
// rate at 6h so the scale is 1 — ON reuses OFF's measured 0.20/hr (300 min) rather than the
// optimistic 6h spec (360). This is the +1h "ANC increases battery" paradox, removed.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 300
}
@Test
fun `an empty bucket prefers the more conservative of UNKNOWN and the sibling`() = runTest(UnconfinedTestDispatcher()) {
// A mode-agnostic UNKNOWN reading (0.12/hr, optimistic) AND a real OFF sibling (0.20/hr) both
// exist while ON is empty. The estimate takes the less-optimistic of the two so a toggle can't
// inflate past the sibling: 0.20/hr -> 300, not the 360 the optimistic UNKNOWN would clamp to.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"UNKNOWN/LEFT" to learned(0.12f), "UNKNOWN/RIGHT" to learned(0.12f),
"OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 300
}
@Test
fun `a borrowed sibling rate is scaled by the modes' rated drain`() = runTest(UnconfinedTestDispatcher()) {
// AirPods Pro (gen1) rates ANC on at 4.5h, off at 5h. An empty ON bucket borrows the OFF
// learned 0.20/hr and scales it by (1/4.5)/(1/5) == 1.111 -> 0.222/hr -> 270 min (4.5h). ANC
// on shows LESS than OFF's 300 min, the physically correct direction.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 270
}
@Test
fun `sibling scaling works in the inverse direction too`() = runTest(UnconfinedTestDispatcher()) {
// gen1 Pro: only ON learned (0.30/hr). An empty OFF bucket borrows it scaled by
// (1/5)/(1/4.5) == 0.9 -> 0.27/hr -> 222 min. OFF drains slower than the measured ON, correct.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("ON/LEFT" to learned(0.30f), "ON/RIGHT" to learned(0.30f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.OFF))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 222
}
@Test
fun `an empty bucket with no sibling still falls back to spec`() = runTest(UnconfinedTestDispatcher()) {
// Nothing learned in any mode -> the fallback can't fire, the model rating seeds as before.
val result = collectEstimate(
estimator(listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))))
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `a populated current bucket is never overridden by a sibling`() = runTest(UnconfinedTestDispatcher()) {
// Real ON data (0.30/hr) exists alongside OFF (0.20/hr). The current mode's own measurement
// wins outright -> 200 min; a genuine per-mode difference is preserved, not flattened.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"ON/LEFT" to learned(0.30f), "ON/RIGHT" to learned(0.30f),
"OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 200
}
@Test
fun `the best-evidenced sibling is chosen`() = runTest(UnconfinedTestDispatcher()) {
// ON empty; OFF (0.20/hr, 1 update) and TRANSPARENCY (0.40/hr, 5 updates) both available and
// same-rated (all non-off modes rate 6h on a Pro 2, so scale 1). The higher-evidence
// TRANSPARENCY rate wins -> 150 min, not the 300 the thinner OFF rate would give.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"OFF/LEFT" to learned(0.20f, updateCount = 1), "OFF/RIGHT" to learned(0.20f, updateCount = 1),
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 5), "TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 5),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 150
}
@Test
fun `equal-evidence siblings tie-break on closest rated drain`() = runTest(UnconfinedTestDispatcher()) {
// gen1 Pro rates ON and TRANSPARENCY at 4.5h but OFF at 5h. With equal evidence, the sibling
// whose rating is closest to ON (TRANSPARENCY, identical rating) is the better predictor and
// wins over OFF: 0.40/hr -> 150. Had OFF (0.20/hr) won, scaling would give 0.222/hr -> 270.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"OFF/LEFT" to learned(0.20f, updateCount = 3), "OFF/RIGHT" to learned(0.20f, updateCount = 3),
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 3), "TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 3),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 150
}
@Test
fun `equal-evidence equal-rated siblings tie-break on recency`() = runTest(UnconfinedTestDispatcher()) {
// TRANSPARENCY and ADAPTIVE both rate identically to ON (4.5h) with equal evidence — only
// recency separates them. The newer ADAPTIVE (0.50/hr) wins over the older TRANSPARENCY
// (0.40/hr): 0.50/hr -> 120, not 150.
val stored = mapOf(
"p1" to DrainProfile(
rates = mapOf(
"TRANSPARENCY/LEFT" to learned(0.40f, updateCount = 2, updatedAt = now.minusSeconds(3600)),
"TRANSPARENCY/RIGHT" to learned(0.40f, updateCount = 2, updatedAt = now.minusSeconds(3600)),
"ADAPTIVE/LEFT" to learned(0.50f, updateCount = 2, updatedAt = now),
"ADAPTIVE/RIGHT" to learned(0.50f, updateCount = 2, updatedAt = now),
)
)
)
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
result["p1"].shouldNotBeNull().left.shouldNotBeNull().minutesRemaining shouldBe 120
}
@Test
fun `the UNKNOWN bucket does not borrow sibling rates`() = runTest(UnconfinedTestDispatcher()) {
// BLE-only (mode not known) keeps its conservative spec-min behaviour: a real OFF sibling is
// NOT borrowed, the estimate stays on the 6h rating (360), not OFF's 300.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `a model without ratings does not borrow a sibling rate`() = runTest(UnconfinedTestDispatcher()) {
// Beats Fit Pro has ANC but no published battery rating -> no spec ceiling to clamp a borrowed
// rate, so the fallback is skipped entirely and nothing over-promises (no estimate at all).
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 1.0f, right = 1.0f, model = PodModel.BEATS_FIT_PRO, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
) shouldBe emptyMap()
}
@Test
fun `an unsupported sibling mode is not borrowed`() = runTest(UnconfinedTestDispatcher()) {
// A stale ADAPTIVE key exists, but the device only reports OFF/ON as supported -> the stale
// key is ignored, no other sibling has data, so the estimate stays on spec (360).
val stored = mapOf("p1" to DrainProfile(rates = mapOf("ADAPTIVE/LEFT" to learned(0.20f), "ADAPTIVE/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(
listOf(
device(
"p1", left = 1.0f, right = 1.0f, model = PodModel.AIRPODS_PRO2,
ancMode = AapSetting.AncMode.Value.ON,
ancSupported = listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON),
)
)
),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.SPEC
left.minutesRemaining shouldBe 360
}
@Test
fun `the in-case runtime projection also borrows a sibling rate`() = runTest(UnconfinedTestDispatcher()) {
// Charging (no live drain) in an empty ON bucket: the "if used now" projection borrows the OFF
// sibling (0.20/hr) instead of spec. At 50% that's 0.50 / 0.20 * 60 == 150.
val stored = mapOf("p1" to DrainProfile(rates = mapOf("OFF/LEFT" to learned(0.20f), "OFF/RIGHT" to learned(0.20f))))
val result = collectEstimate(
estimator(
emissions = listOf(listOf(device("p1", left = 0.50f, right = 0.50f, charging = true, model = PodModel.AIRPODS_PRO2, ancMode = AapSetting.AncMode.Value.ON))),
stored = stored,
)
)
val left = result["p1"].shouldNotBeNull().left.shouldNotBeNull()
left.source shouldBe BatteryEstimate.Source.LEARNED
left.minutesRemaining shouldBe 150
}
@Test @Test
fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) { fun `reset deletes persisted data and drops the estimate`() = runTest(UnconfinedTestDispatcher()) {
val drainStore = mockk<BatteryDrainStore> { val drainStore = mockk<BatteryDrainStore> {
@@ -577,9 +829,10 @@ class BatteryEstimatorTest : BaseTest() {
estimator.estimates.value.containsKey("p1") shouldBe false estimator.estimates.value.containsKey("p1") shouldBe false
} }
private fun learned(rate: Float) = DrainProfile.LearnedRate( private fun learned(rate: Float, updateCount: Int = 1, updatedAt: Instant = now) = DrainProfile.LearnedRate(
fractionPerHour = rate, fractionPerHour = rate,
sampleCount = 5, sampleCount = 5,
updatedAt = now, updateCount = updateCount,
updatedAt = updatedAt,
) )
} }
@@ -1,6 +1,7 @@
package eu.darken.capod.reaction.core.playpause package eu.darken.capod.reaction.core.playpause
import eu.darken.capod.common.MediaControl import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
@@ -1507,6 +1508,203 @@ class PlayPauseLogicTest : BaseTest() {
result.decision shouldBe noopDecision result.decision shouldBe noopDecision
result.pending shouldBe null result.pending shouldBe null
} }
// --- Time-cap early commit (slow-scanner mitigation) ---
private val t0 = Instant.parse("2026-01-01T00:00:00Z")
@Test
fun `time-cap - slow cadence commits early on the first distinct confirmation past the cap`() {
// STARTED at t0 (reception nanos=100). The first confirmation arrives 2000ms later
// (> 1500ms cap) as a DISTINCT reception (nanos=200) → commit via time-cap on the
// first confirmation, before PAUSE_DEBOUNCE_SAMPLES would have fired.
val started = playPause.applyPauseDebounce(
pending = null,
profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true,
now = t0,
generatedAtNanos = 100L,
)
started.event shouldBe PlayPause.PauseDebounceEvent.STARTED
started.pending!!.startedAt shouldBe t0
started.pending!!.startedGeneratedAtNanos shouldBe 100L
val result = playPause.applyPauseDebounce(
pending = started.pending,
profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true,
now = t0.plusMillis(2000),
generatedAtNanos = 200L,
)
result.decision.shouldPause shouldBe true
result.pending shouldBe null
result.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED
result.decision.reason.contains("via time") shouldBe true
}
@Test
fun `time-cap - fast cadence still commits on sample count, not time`() {
// Per-advert cadence (batching disabled): confirmations at +150ms and +300ms, both
// under the cap → the pause commits on the 2nd confirmation via count, exactly as
// without the time-cap. Guards the recommended "Disable hardware batching" fast path.
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 1L,
)
val confirm1 = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(150), generatedAtNanos = 2L,
)
confirm1.decision.shouldPause shouldBe false
confirm1.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
// startedAt / startedGeneratedAtNanos are preserved across an ADVANCED.
confirm1.pending!!.startedAt shouldBe t0
confirm1.pending!!.startedGeneratedAtNanos shouldBe 1L
val confirm2 = playPause.applyPauseDebounce(
pending = confirm1.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(300), generatedAtNanos = 3L,
)
confirm2.decision.shouldPause shouldBe true
confirm2.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED
confirm2.decision.reason.contains("via count") shouldBe true
}
@Test
fun `time-cap - identical reception (same generatedAtNanos) does not time-commit`() {
// A janky OEM re-delivers the SAME cached advert in a later batch callback: fresh
// seenLastAt but identical generatedAtNanos. The distinct-reception guard must block
// the time path — one physical advert cannot early-commit a pause.
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 100L,
)
val result = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(2000), generatedAtNanos = 100L,
)
result.decision.shouldPause shouldBe false
result.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
}
@Test
fun `time-cap - boundary elapsed exactly equal to the cap commits`() {
// Pins the comparison to >= (not >).
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 1L,
)
val result = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true,
now = t0.plus(PlayPause.PAUSE_DEBOUNCE_TIME_CAP),
generatedAtNanos = 2L,
)
result.decision.shouldPause shouldBe true
result.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED
}
@Test
fun `time-cap - backward wall-clock does not time-commit (elapsed clamped to zero)`() {
// seenLastAt regresses (wall-clock jump, or the backing snapshot switching to a
// different physical device under BLE_PROFILE_FALLBACK). Clamp to >=0 → elapsed 0 →
// no time commit; must ADVANCE and wait for the count.
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(5000), generatedAtNanos = 1L,
)
val result = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 2L,
)
result.decision.shouldPause shouldBe false
result.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
}
@Test
fun `time-cap - a tolerated rebound disables the time path but count still fires`() {
// STARTED t0; a count-up rebound at +1000 is tolerated (reboundTolerated=true). A
// not-worn at +2500 is past the cap but must NOT time-commit — the not-worn samples
// are no longer strictly consecutive. It ADVANCES; a further not-worn commits on count.
val started = playPause.applyPauseDebounce(
pending = null, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = pauseDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0, generatedAtNanos = 1L,
)
val rebound = playPause.applyPauseDebounce(
pending = started.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(true, false), // pod returned (count up)
autoPauseEnabled = true, now = t0.plusMillis(1000), generatedAtNanos = 2L,
)
rebound.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
rebound.pending!!.reboundTolerated shouldBe true
val pastCap = playPause.applyPauseDebounce(
pending = rebound.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(2500), generatedAtNanos = 3L,
)
pastCap.decision.shouldPause shouldBe false
pastCap.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED
val committed = playPause.applyPauseDebounce(
pending = pastCap.pending, profileId = "profile",
source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK,
rawDecision = noopDecision,
currentState = EarDetectionState.fromDualPod(false, false),
autoPauseEnabled = true, now = t0.plusMillis(2700), generatedAtNanos = 4L,
)
committed.decision.shouldPause shouldBe true
committed.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED
}
} }
@Nested @Nested
@@ -1708,13 +1906,21 @@ class PlayPauseLogicTest : BaseTest() {
@Nested @Nested
inner class MonitorFlowTests { inner class MonitorFlowTests {
private fun buildBle(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) = private fun buildBle(
seenAt: Instant,
leftWorn: Boolean,
rightWorn: Boolean,
genNanos: Long = 0L,
) =
mockk<DualApplePods>(relaxed = true) { mockk<DualApplePods>(relaxed = true) {
every { meta } returns ApplePods.AppleMeta( every { meta } returns ApplePods.AppleMeta(
isIRKMatch = false, isIRKMatch = false,
profile = mockk(relaxed = true), profile = mockk(relaxed = true),
) )
every { seenLastAt } returns seenAt every { seenLastAt } returns seenAt
every { scanResult } returns mockk<BleScanResult>(relaxed = true) {
every { generatedAtNanos } returns genNanos
}
every { isLeftPodInEar } returns leftWorn every { isLeftPodInEar } returns leftWorn
every { isRightPodInEar } returns rightWorn every { isRightPodInEar } returns rightWorn
every { isBeingWorn } returns (leftWorn && rightWorn) every { isBeingWorn } returns (leftWorn && rightWorn)
@@ -1731,9 +1937,10 @@ class PlayPauseLogicTest : BaseTest() {
leftWorn: Boolean, leftWorn: Boolean,
rightWorn: Boolean, rightWorn: Boolean,
startMusicOnWear: Boolean = true, startMusicOnWear: Boolean = true,
genNanos: Long = 0L,
) = PodDevice( ) = PodDevice(
profileId = "test-profile", profileId = "test-profile",
ble = buildBle(seenAt, leftWorn, rightWorn), ble = buildBle(seenAt, leftWorn, rightWorn, genNanos),
aap = null, aap = null,
profileModel = PodModel.AIRPODS_PRO3, profileModel = PodModel.AIRPODS_PRO3,
reactions = ReactionConfig( reactions = ReactionConfig(
@@ -1784,6 +1991,9 @@ class PlayPauseLogicTest : BaseTest() {
@Test @Test
fun `flow - stable worn rebound resets stale pause debounce before a new removal sequence`() = runTest { fun `flow - stable worn rebound resets stale pause debounce before a new removal sequence`() = runTest {
// NOTE: buildDevice defaults genNanos = 0L, so every sample here shares one
// generatedAtNanos. The time-cap's distinct-reception guard is therefore inert and
// this test exercises the pure sample-count path — independent of PAUSE_DEBOUNCE_TIME_CAP.
val deviceFlow = MutableStateFlow<List<PodDevice>>(emptyList()) val deviceFlow = MutableStateFlow<List<PodDevice>>(emptyList())
val deviceMonitor: DeviceMonitor = mockk(relaxed = true) { val deviceMonitor: DeviceMonitor = mockk(relaxed = true) {
every { devices } returns deviceFlow every { devices } returns deviceFlow
@@ -1834,6 +2044,82 @@ class PlayPauseLogicTest : BaseTest() {
job.cancel() job.cancel()
} }
@Test
fun `flow - slow cadence commits pause via time-cap on the second distinct not-worn sample`() = runTest {
val deviceFlow = MutableStateFlow<List<PodDevice>>(emptyList())
val deviceMonitor: DeviceMonitor = mockk(relaxed = true) {
every { devices } returns deviceFlow
}
val bluetoothManager: BluetoothManager2 = mockk(relaxed = true) {
every { connectedDevices } returns flowOf(listOf(mockk(relaxed = true)))
}
val mediaControl: MediaControl = mockk(relaxed = true) {
every { isPlaying } returns true
every { wasRecentlyPausedByCap } returns false
coEvery { sendPause(rememberForResume = true) } returns true
}
val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl)
val now = Instant.parse("2026-01-01T00:00:00Z")
val job = launch { flowPlayPause.monitor().collect {} }
// T0: worn baseline.
deviceFlow.value = listOf(buildDevice(now, leftWorn = true, rightWorn = true, genNanos = 1L))
advanceUntilIdle()
// T1: first not-worn sample starts the debounce (seenLastAt = t0+1000).
deviceFlow.value = listOf(buildDevice(now.plusMillis(1000), leftWorn = false, rightWorn = false, genNanos = 2L))
advanceUntilIdle()
// T2: a second, DISTINCT not-worn reception 2000ms after the first (> 1500ms cap).
// Only two not-worn samples so far — the pure sample count would need a third — but
// on a slow (~2s) batch cadence the time-cap commits the pause here.
deviceFlow.value = listOf(buildDevice(now.plusMillis(3000), leftWorn = false, rightWorn = false, genNanos = 3L))
advanceUntilIdle()
coVerify(exactly = 1) { mediaControl.sendPause(rememberForResume = true) }
job.cancel()
}
@Test
fun `flow - two not-worn samples within the cap do not pause (elapsed anchored at first not-worn)`() = runTest {
val deviceFlow = MutableStateFlow<List<PodDevice>>(emptyList())
val deviceMonitor: DeviceMonitor = mockk(relaxed = true) {
every { devices } returns deviceFlow
}
val bluetoothManager: BluetoothManager2 = mockk(relaxed = true) {
every { connectedDevices } returns flowOf(listOf(mockk(relaxed = true)))
}
val mediaControl: MediaControl = mockk(relaxed = true) {
every { isPlaying } returns true
every { wasRecentlyPausedByCap } returns false
coEvery { sendPause(rememberForResume = true) } returns true
}
val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl)
val now = Instant.parse("2026-01-01T00:00:00Z")
val job = launch { flowPlayPause.monitor().collect {} }
// T0: worn baseline.
deviceFlow.value = listOf(buildDevice(now, leftWorn = true, rightWorn = true, genNanos = 1L))
advanceUntilIdle()
// T1: first not-worn at t0+1400 → debounce STARTED, elapsed anchored here.
deviceFlow.value = listOf(buildDevice(now.plusMillis(1400), leftWorn = false, rightWorn = false, genNanos = 2L))
advanceUntilIdle()
// T2: second not-worn at t0+2000. Elapsed from the FIRST not-worn is 600ms (< cap),
// and only two samples → no pause. A wrong anchor (baseline t0, or a device-level
// timestamp) would read 2000ms >= cap and wrongly pause here.
deviceFlow.value = listOf(buildDevice(now.plusMillis(2000), leftWorn = false, rightWorn = false, genNanos = 3L))
advanceUntilIdle()
coVerify(exactly = 0) { mediaControl.sendPause(rememberForResume = true) }
job.cancel()
}
private fun buildIrkMatchedBle(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) = private fun buildIrkMatchedBle(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) =
mockk<DualApplePods>(relaxed = true) { mockk<DualApplePods>(relaxed = true) {
every { meta } returns ApplePods.AppleMeta( every { meta } returns ApplePods.AppleMeta(
+1 -1
View File
@@ -1,7 +1,7 @@
### Updated by tools/release/bump.sh ### ### Updated by tools/release/bump.sh ###
project.versioning.major=5 project.versioning.major=5
project.versioning.minor=2 project.versioning.minor=2
project.versioning.patch=0 project.versioning.patch=1
project.versioning.build=0 project.versioning.build=0
project.versioning.type=rc project.versioning.type=rc
############################# #############################