mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911a09c743 | ||
|
|
2672dbdcf6 | ||
|
|
3ddd5f3cfd | ||
|
|
31f4d4aafa | ||
|
|
e06dc933b8 | ||
|
|
1418be999d |
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
#############################
|
#############################
|
||||||
|
|||||||
Reference in New Issue
Block a user