feat(reaction): Add charged notification

This commit is contained in:
darken
2026-06-10 18:13:51 +02:00
committed by Matthias Urhahn
parent 380397321c
commit 2804543b9d
17 changed files with 1707 additions and 0 deletions
@@ -64,6 +64,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import java.time.Duration
import java.time.Instant
@@ -175,6 +176,9 @@ fun DeviceSettingsScreenHost(
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
onNotifyWhenChargedChange = { vm.setNotifyWhenCharged(it) },
onChargedThresholdChange = { vm.setChargedThreshold(it) },
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
onOpenIssueTracker = { vm.openIssueTracker() },
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
)
@@ -215,6 +219,9 @@ fun DeviceSettingsScreen(
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
onOpenAapTracker: () -> Unit = {},
) {
@@ -356,6 +363,9 @@ fun DeviceSettingsScreen(
onAutoConnectConditionChange = onAutoConnectConditionChange,
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
onNotifyWhenChargedChange = onNotifyWhenChargedChange,
onChargedThresholdChange = onChargedThresholdChange,
onChargedSlotScopeChange = onChargedSlotScopeChange,
onOpenIssueTracker = onOpenIssueTracker,
)
}
@@ -31,6 +31,7 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemAction
import kotlinx.coroutines.delay
@@ -438,6 +439,24 @@ class DeviceSettingsViewModel @Inject constructor(
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
}
fun setNotifyWhenCharged(enabled: Boolean) {
log(TAG, INFO) { "setNotifyWhenCharged($enabled)" }
proGatedReaction(enabled) { it.copy(notifyWhenCharged = enabled) }
}
fun setChargedSlotScope(scope: ChargedSlotScope) = launch {
log(TAG, INFO) { "setChargedSlotScope($scope)" }
updateProfileNow { it.copy(chargedSlotScope = scope) }
}
fun setChargedThreshold(percent: Int) = launch {
log(TAG, INFO) { "setChargedThreshold($percent)" }
val snapped = (percent.toFloat() / ReactionConfig.CHARGED_THRESHOLD_STEP)
.let { Math.round(it) * ReactionConfig.CHARGED_THRESHOLD_STEP }
.coerceIn(ReactionConfig.MIN_CHARGED_THRESHOLD, ReactionConfig.MAX_CHARGED_THRESHOLD)
updateProfileNow { it.copy(chargedThreshold = snapped) }
}
fun setConversationAction(action: ConversationAction) = launch {
log(TAG, INFO) { "setConversationAction($action)" }
// The action picker is only shown while Conversation Awareness is already enabled (the pod
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne
@@ -38,6 +39,7 @@ import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
@@ -45,6 +47,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
@Composable
@@ -64,6 +67,9 @@ internal fun ReactionsCard(
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
onChargedThresholdChange: (Int) -> Unit = {},
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val reactions = device.reactions
@@ -71,6 +77,7 @@ internal fun ReactionsCard(
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
var showConversationActionDialog by remember { mutableStateOf(false) }
var showChargedScopeDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
@@ -251,6 +258,40 @@ internal fun ReactionsCard(
text = stringResource(R.string.settings_popup_info_not_in_app),
)
}
ReactionsDivider()
SettingsSwitchItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.settings_charged_notification_label),
subtitle = stringResource(R.string.settings_charged_notification_description),
checked = reactions.notifyWhenCharged,
onCheckedChange = onNotifyWhenChargedChange,
requiresUpgrade = !isPro,
)
if (reactions.notifyWhenCharged) {
var thresholdValue by remember(reactions.chargedThreshold) {
mutableIntStateOf(reactions.chargedThreshold)
}
SettingsSliderItem(
icon = Icons.TwoTone.BatteryChargingFull,
title = stringResource(R.string.settings_charged_threshold_label),
value = thresholdValue.toFloat(),
onValueChange = { thresholdValue = it.toInt() },
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
valueLabel = { "${it.toInt()}%" },
)
if (features.hasCase) {
SettingsBaseItem(
icon = Icons.TwoTone.Workspaces,
title = stringResource(R.string.settings_charged_scope_label),
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
onClick = { showChargedScopeDialog = true },
)
}
}
}
if (showConversationActionDialog) {
@@ -264,6 +305,17 @@ internal fun ReactionsCard(
)
}
if (showChargedScopeDialog) {
ChargedSlotScopeDialog(
current = reactions.chargedSlotScope,
onSelect = {
onChargedSlotScopeChange(it)
showChargedScopeDialog = false
},
onDismiss = { showChargedScopeDialog = false },
)
}
if (showAutoConnectConditionDialog) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
@@ -0,0 +1,75 @@
package eu.darken.capod.main.ui.devicesettings.dialogs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
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.semantics.Role
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
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
@Composable
internal fun ChargedSlotScopeDialog(
current: ChargedSlotScope,
onSelect: (ChargedSlotScope) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.settings_charged_scope_label)) },
text = {
Column(Modifier.selectableGroup()) {
ChargedSlotScope.entries.forEach { scope ->
val isSelected = scope == current
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onSelect(scope) },
role = Role.RadioButton,
)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = null)
Text(
text = stringResource(scope.labelRes),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
@Preview2
@Composable
private fun ChargedSlotScopeDialogPreview() = PreviewWrapper {
ChargedSlotScopeDialog(
current = ChargedSlotScope.PODS_AND_CASE,
onSelect = {},
onDismiss = {},
)
}
@@ -267,6 +267,14 @@ data class PodDevice(
val isEitherPodInEar: Boolean?
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
/**
* True when ear detection comes from a live AAP reading. The BLE ear-detection bits are
* unreliable for pods resting in the case (they report phantom "in ear" and disagree
* between the two pods' advertisements), so consumers that must not act on a false "worn"
* gate on this.
*/
val hasAapEarDetection: Boolean get() = aap?.aapEarDetection != null
val caseLidState: DualApplePods.LidState?
get() = (ble as? DualApplePods)?.caseLidState
@@ -43,6 +43,8 @@ import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
import eu.darken.capod.reaction.core.conversation.ConversationReaction
import eu.darken.capod.reaction.core.charged.ChargedReaction
import eu.darken.capod.reaction.core.charged.ChargedReactionNotifications
import eu.darken.capod.reaction.core.sleep.SleepReaction
import eu.darken.capod.reaction.ui.popup.PopUpWindow
import kotlinx.coroutines.CancellationException
@@ -79,6 +81,8 @@ class MonitorService : Service() {
@Inject lateinit var autoConnect: AutoConnect
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var sleepReaction: SleepReaction
@Inject lateinit var chargedReaction: ChargedReaction
@Inject lateinit var chargedReactionNotifications: ChargedReactionNotifications
@Inject lateinit var conversationReaction: ConversationReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@@ -333,6 +337,23 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
chargedReaction.monitor()
.onEach { event ->
when (event) {
is ChargedReaction.Event.ShowNotification -> chargedReactionNotifications.show(
profileId = event.profileId,
deviceLabel = event.deviceLabel,
thresholdPercent = event.thresholdPercent,
)
is ChargedReaction.Event.CancelNotification ->
chargedReactionNotifications.cancel(event.profileId)
}
}
.setupCommonEventHandlers(TAG) { "chargedReaction" }
.catch { log(TAG, WARN) { "chargedReaction failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
conversationReaction.monitor()
.setupCommonEventHandlers(TAG) { "conversationReaction" }
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
@@ -348,6 +369,11 @@ class MonitorService : Service() {
monitorScope.cancel("Service destroyed")
if (injectionComplete) {
try {
chargedReactionNotifications.cancelAll()
} catch (e: Exception) {
log(TAG, WARN) { "Failed to cancel charged notifications: ${e.message}" }
}
val snapshot = latestNotificationSettings
if (snapshot.useExtraNotification && !snapshot.keepAfterDisconnect) {
try {
@@ -5,6 +5,7 @@ import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
import eu.darken.capod.reaction.core.stem.StemActionsConfig
import kotlinx.parcelize.Parcelize
@@ -34,6 +35,9 @@ data class AppleDeviceProfile(
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
@SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING,
@SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION,
@SerialName("reactionNotifyWhenCharged") val notifyWhenCharged: Boolean = false,
@SerialName("reactionChargedThreshold") val chargedThreshold: Int = ReactionConfig.DEFAULT_CHARGED_THRESHOLD,
@SerialName("reactionChargedSlotScope") val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
/**
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
@@ -61,6 +65,9 @@ data class AppleDeviceProfile(
showPopUpOnConnection = showPopUpOnConnection,
conversationAction = conversationAction,
conversationVolumeReduction = conversationVolumeReduction,
notifyWhenCharged = notifyWhenCharged,
chargedThreshold = chargedThreshold,
chargedSlotScope = chargedSlotScope,
)
override fun toString(): String = "AppleDeviceProfile(" +
@@ -76,6 +83,9 @@ data class AppleDeviceProfile(
"showPopUpOnConnection=$showPopUpOnConnection, " +
"conversationAction=$conversationAction, " +
"conversationVolumeReduction=$conversationVolumeReduction, " +
"notifyWhenCharged=$notifyWhenCharged, " +
"chargedThreshold=$chargedThreshold, " +
"chargedSlotScope=$chargedSlotScope, " +
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
"stemActions=$stemActions" +
@@ -1,6 +1,7 @@
package eu.darken.capod.profiles.core
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import eu.darken.capod.reaction.core.conversation.ConversationAction
data class ReactionConfig(
@@ -15,8 +16,17 @@ data class ReactionConfig(
val conversationAction: ConversationAction = ConversationAction.NOTHING,
/** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */
val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION,
val notifyWhenCharged: Boolean = false,
/** Battery percentage at which the charged notification fires (clamped 50..100 on use). */
val chargedThreshold: Int = DEFAULT_CHARGED_THRESHOLD,
val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
) {
companion object {
const val DEFAULT_CHARGED_THRESHOLD = 100
const val MIN_CHARGED_THRESHOLD = 50
const val MAX_CHARGED_THRESHOLD = 100
const val CHARGED_THRESHOLD_STEP = 10
const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50
const val MIN_CONVERSATION_VOLUME_REDUCTION = 10
@@ -0,0 +1,199 @@
package eu.darken.capod.reaction.core.charged
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.devicesWithProfiles
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Input
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Output
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Slot
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.SlotData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChargedReaction @Inject constructor(
private val deviceMonitor: DeviceMonitor,
private val profilesRepo: DeviceProfilesRepo,
) {
sealed class Event {
data class ShowNotification(
val profileId: String,
val deviceLabel: String,
val thresholdPercent: Int,
) : Event()
data class CancelNotification(val profileId: String) : Event()
}
private data class ChargedConfig(val scope: ChargedSlotScope, val thresholdPercent: Int)
fun monitor(): Flow<Event> = flow {
// Machines live in the collection scope: a monitor (service) restart starts fresh sessions.
val machines = mutableMapOf<String, ChargingSessionStateMachine>()
val configs = mutableMapOf<String, ChargedConfig>()
combine(
profilesRepo.profiles,
deviceMonitor.devicesWithProfiles(),
) { profiles, devices -> profiles.filterIsInstance<AppleDeviceProfile>() to devices }
.collect { (profiles, devices) ->
val enabled = profiles.filter { it.notifyWhenCharged }.associateBy { it.id }
// Profile deleted or toggle disabled → reset; a missing device snapshot is
// handled below as staleness, never as a reset.
machines.keys.filter { it !in enabled }.forEach { profileId ->
val output = machines.remove(profileId)!!.process(Input.Reset)
configs.remove(profileId)
if (output == Output.CANCEL) emit(Event.CancelNotification(profileId))
}
for (profile in enabled.values) {
val machine = machines.getOrPut(profile.id) { ChargingSessionStateMachine() }
val thresholdPercent = profile.chargedThreshold.coerceIn(
ReactionConfig.MIN_CHARGED_THRESHOLD,
ReactionConfig.MAX_CHARGED_THRESHOLD,
)
// A persisted CASE scope on a caseless model (restored backup, model change)
// would otherwise produce an eternally-empty slot set.
val scope = if (profile.model.features.hasCase) profile.chargedSlotScope else ChargedSlotScope.PODS
val config = ChargedConfig(scope, thresholdPercent)
val previousConfig = configs.put(profile.id, config)
if (previousConfig != null && previousConfig != config) {
// Scope/threshold changed: stale highwater slots and an already-shown
// notification don't fit the new settings — start evaluation over.
log(TAG, INFO) { "Settings changed for ${profile.id} ($previousConfig -> $config), resetting" }
if (machine.process(Input.Reset) == Output.CANCEL) {
emit(Event.CancelNotification(profile.id))
}
}
val device = devices.firstOrNull { it.profileId == profile.id }
val slots = device?.liveChargingSlots(scope).orEmpty()
// No live slot data (device gone, lid closed, or AAP hasn't pushed battery
// yet) is treated as staleness — the session suspends instead of resetting.
val input = if (device == null || slots.isEmpty()) {
Input.StaleUpdate
} else {
Input.LiveUpdate(
slots = slots,
threshold = thresholdPercent / 100f,
activity = device.chargingActivity(),
)
}
val phaseBefore = machine.phase
val output = machine.process(input)
if (machine.phase != phaseBefore) {
log(TAG, VERBOSE) {
"${profile.id}: $phaseBefore -> ${machine.phase} (output=$output, input=$input)"
}
}
when (output) {
Output.SHOW -> {
val label = device?.label ?: profile.label
log(TAG, INFO) { "Charge complete for ${profile.id} ($label) at $thresholdPercent%" }
emit(Event.ShowNotification(profile.id, label, thresholdPercent))
}
Output.CANCEL -> {
log(TAG, INFO) { "Charging session over for ${profile.id}, cancelling notification" }
emit(Event.CancelNotification(profile.id))
}
Output.NONE -> Unit
}
}
}
}
.setupCommonEventHandlers(TAG) { "chargedReaction" }
companion object {
private val TAG = logTag("Reaction", "Charged")
}
}
/**
* Per-slot live readings only — AAP first, then live BLE. The [PodDevice] facade getters are
* deliberately bypassed because they silently fall back to the disk cache; stale cached values
* must never start, complete, or end a charging session.
*/
/**
* Signals that the user is handling the device, used to dismiss an already-shown notification.
* All sources are live (ear detection: AAP/BLE; lid: BLE; DISCONNECTED slots: AAP) — the cache
* never feeds in here.
*/
internal fun PodDevice.chargingActivity(): ChargingSessionStateMachine.Activity =
ChargingSessionStateMachine.Activity(
// Only trust AAP ear detection — BLE ear bits are phantom for in-case pods (see
// PodDevice.hasAapEarDetection). Unknown when there's no AAP session, which is the
// normal state while charging in the case, so phantom "worn" never dismisses.
wornSlots = when {
!hasAapEarDetection -> null
hasDualPods -> buildSet {
if (isLeftInEar == true) add(Slot.LEFT)
if (isRightInEar == true) add(Slot.RIGHT)
}
else -> if (isBeingWorn == true) setOf(Slot.HEADSET) else emptySet()
},
lid = when (caseLidState) {
DualApplePods.LidState.OPEN -> ChargingSessionStateMachine.Lid.OPEN
DualApplePods.LidState.CLOSED -> ChargingSessionStateMachine.Lid.CLOSED
DualApplePods.LidState.NOT_IN_CASE -> ChargingSessionStateMachine.Lid.NOT_IN_CASE
else -> null
},
disconnectedSlots = buildSet {
if (leftPodChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.LEFT)
if (rightPodChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.RIGHT)
if (caseChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.CASE)
if (headsetChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.HEADSET)
},
)
internal fun PodDevice.liveChargingSlots(scope: ChargedSlotScope): Map<Slot, SlotData> {
val slots = mutableMapOf<Slot, SlotData>()
fun add(slot: Slot, battery: Float?, charging: Boolean?) {
if (battery == null || battery < 0f || charging == null) return
slots[slot] = SlotData(battery = battery, isCharging = charging)
}
if (scope != ChargedSlotScope.CASE) {
add(
Slot.LEFT,
aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent,
aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging,
)
add(
Slot.RIGHT,
aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent,
aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging,
)
add(
Slot.HEADSET,
aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent,
aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged,
)
}
if (scope != ChargedSlotScope.PODS) {
add(
Slot.CASE,
aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent,
aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging,
)
}
return slots
}
@@ -0,0 +1,87 @@
package eu.darken.capod.reaction.core.charged
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import eu.darken.capod.main.ui.MainActivity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChargedReactionNotifications @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManager,
) {
init {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reaction_charged_channel_label),
NotificationManager.IMPORTANCE_LOW,
)
)
}
fun show(profileId: String, deviceLabel: String, thresholdPercent: Int) {
if (!notificationManager.areNotificationsEnabled()) {
log(TAG, WARN) { "Notifications disabled — charged notification suppressed" }
return
}
val openPi = PendingIntent.getActivity(
context,
PENDING_INTENT_REQUEST_CODE,
Intent(context, MainActivity::class.java),
PendingIntentCompat.FLAG_IMMUTABLE,
)
val text = if (thresholdPercent >= 100) {
context.getString(R.string.reaction_charged_notification_text_full, deviceLabel)
} else {
context.getString(R.string.reaction_charged_notification_text_partial, deviceLabel, thresholdPercent)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.device_earbuds_generic_both)
.setContentTitle(context.getString(R.string.reaction_charged_notification_title))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(openPi)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
notificationManager.notify(profileId.toTag(), NOTIFICATION_ID, notification)
}
fun cancel(profileId: String) {
notificationManager.cancel(profileId.toTag(), NOTIFICATION_ID)
}
/**
* Drops every charged notification still on screen. Used when monitoring stops — without a
* running monitor the auto-dismiss-on-unplug promise can't be kept, so don't leave them up.
*/
fun cancelAll() {
notificationManager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.tag?.startsWith(TAG_PREFIX) == true }
.forEach { notificationManager.cancel(it.tag, it.id) }
}
private fun String.toTag() = "$TAG_PREFIX$this"
companion object {
private val TAG = logTag("Reaction", "Charged", "Notifications")
private val CHANNEL_ID = "${BuildConfigWrap.APPLICATION_ID}.notification.channel.reaction.charged"
private const val NOTIFICATION_ID = 4
private const val TAG_PREFIX = "charged:"
private const val PENDING_INTENT_REQUEST_CODE = 1
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.reaction.core.charged
import androidx.annotation.StringRes
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Which battery slots the charged notification watches. */
@Serializable
enum class ChargedSlotScope(
@StringRes val labelRes: Int,
) {
@SerialName("charged.scope.pods") PODS(R.string.settings_charged_scope_pods_label),
@SerialName("charged.scope.case") CASE(R.string.settings_charged_scope_case_label),
@SerialName("charged.scope.podsandcase") PODS_AND_CASE(R.string.settings_charged_scope_both_label),
}
@@ -0,0 +1,287 @@
package eu.darken.capod.reaction.core.charged
/**
* Tracks one charging session for one profile and decides when the "charged" notification
* should be shown or cancelled. Pure Kotlin, no Android/DI/Flow dependencies; instances are
* driven sequentially from a single coroutine (no internal locking).
*
* A session starts when any slot reports charging and collects every slot that charges while
* the session runs. Per-slot battery highwater marks decide completion: a slot that stops
* charging at/above the threshold finished naturally (firmware flips the charging flag off at
* 100%), while a slot that stops below the threshold was genuinely unplugged and resets the
* session. Stale data suspends the session instead of resetting it — brief BLE gaps while the
* device sits on the charger must not lose progress.
*
* After firing, the notification is taken down again when the user demonstrably reacted to it:
* battery discharging below the threshold, a new charging session starting, or any activity
* signal showing the pods are being handled (worn-pod set changed, pod removed from the case,
* case lid moved). Activity is judged against baselines captured at fire time so pre-existing
* state doesn't dismiss — only a change does:
* - Worn pods and DISCONNECTED slots baseline on the firing frame. They only apply to
* sessions containing pod slots — wearing pods says nothing about a case-only charge.
* - The lid signal baselines on the UNION of values seen before the fire (this session plus
* a few frames leading into it). With one pod worn and one charging, both pods broadcast
* and the monitor's dedup alternates between their frames (OPEN from the in-case pod,
* NOT_IN_CASE from the worn pod) — a single-frame baseline would read that steady flapping
* as activity. Lid movement is a handling signal for any session, including case-only.
*
* An activity dismissal latches into [Phase.DISMISSED] so a still-full, still-charging device
* can't immediately re-fire.
*/
class ChargingSessionStateMachine {
enum class Slot { LEFT, RIGHT, CASE, HEADSET }
/** [battery] is a fraction 0..1, matching the monitor layer's battery values. */
data class SlotData(val battery: Float, val isCharging: Boolean)
/**
* Case posture from the BLE lid byte. NOT_IN_CASE isn't a lid position — it means the
* broadcasting pod is physically outside the case, which is just as much a handling
* signal as a lid movement (it's the only BLE-side trace of "pod taken out").
*/
enum class Lid { OPEN, CLOSED, NOT_IN_CASE }
/** Signals that the user is physically handling the device. All optional/unknown-safe. */
data class Activity(
/** Pods currently worn (per-side); null when no ear data at all. */
val wornSlots: Set<Slot>? = null,
val lid: Lid? = null,
/** Slots physically absent from the case (AAP ChargingState.DISCONNECTED). */
val disconnectedSlots: Set<Slot> = emptySet(),
)
sealed interface Input {
/**
* Fresh data from a live source (BLE/AAP). [slots] only contains slots with live
* readings — cached values must never be fed in here. [threshold] is a fraction 0..1.
*/
data class LiveUpdate(
val slots: Map<Slot, SlotData>,
val threshold: Float,
val activity: Activity = Activity(),
) : Input
/** Device has no live data right now (out of range, lid closed, cache-only). */
data object StaleUpdate : Input
/** Toggle disabled, settings changed, or profile deleted. */
data object Reset : Input
}
enum class Output { NONE, SHOW, CANCEL }
enum class Phase { IDLE, CHARGING, FIRED, DISMISSED, SUSPENDED }
var phase: Phase = Phase.IDLE
private set
/** Phase to resume into when live data returns; only meaningful while SUSPENDED. */
private var suspendedFrom: Phase = Phase.IDLE
/** Battery highwater per slot that joined the session. */
private val highwater = mutableMapOf<Slot, Float>()
/**
* Rolling window of the most recent lid observations across all phases. Seeds the at-fire
* baseline so the OPEN/NOT_IN_CASE alternation (worn pod broadcasts NOT_IN_CASE while the
* in-case pod broadcasts OPEN; the monitor's dedup serves them in turn) is absorbed — even
* for an instant fire right after a settings-change reset, since the window is never cleared.
*/
private val recentLids = ArrayDeque<Lid>()
// Activity baselines frozen at fire; see class docs.
private val baselineLids = mutableSetOf<Lid>()
private var baselineWorn: Set<Slot>? = null
private var baselineDisconnected: Set<Slot> = emptySet()
fun process(input: Input): Output {
if (input is Input.LiveUpdate) input.activity.lid?.let(::recordRecentLid)
return when (phase) {
Phase.IDLE -> processIdle(input)
Phase.CHARGING -> processCharging(input)
Phase.FIRED -> processFired(input)
Phase.DISMISSED -> processDismissed(input)
Phase.SUSPENDED -> processSuspended(input)
}
}
private fun processIdle(input: Input): Output {
if (input !is Input.LiveUpdate) return Output.NONE
val charging = input.slots.filterValues { it.isCharging }
if (charging.isEmpty()) return Output.NONE
highwater.clear()
charging.forEach { (slot, data) -> highwater[slot] = data.battery }
phase = Phase.CHARGING
return fireIfComplete(input)
}
private fun processCharging(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = false)
is Input.StaleUpdate -> suspend(Phase.CHARGING)
is Input.LiveUpdate -> {
// Charging slots join the session (e.g. case plugged in later); slots already in
// it advance their mark; slots absent from this update stay frozen.
input.slots.forEach { (slot, data) ->
if (data.isCharging || slot in highwater) {
highwater[slot] = maxOf(highwater[slot] ?: 0f, data.battery)
}
}
val unplugged = highwater.any { (slot, mark) ->
input.slots[slot]?.isCharging == false && mark < input.threshold
}
if (unplugged) reset(cancel = false) else fireIfComplete(input)
}
}
private fun processFired(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = true)
is Input.StaleUpdate -> suspend(Phase.FIRED)
is Input.LiveUpdate -> when {
// "All slots stopped charging" alone is NOT an unplug signal — firmware flips the
// charging flag off at 100% while still on power. Slots that never charged this
// session (e.g. an idle half-empty case) carry no signal either. The session ends
// on: a new charge context, discharge below threshold, or user activity.
isNewChargeContext(input) -> {
// Only CANCEL is reported now; the next update finds IDLE and starts (and, if
// the data already satisfies the threshold, fires) the new session.
phase = Phase.IDLE
highwater.clear()
Output.CANCEL
}
isSessionDischarged(input) -> reset(cancel = true)
isActivityDetected(input.activity) -> {
phase = Phase.DISMISSED
Output.CANCEL
}
else -> Output.NONE
}
}
/** Like FIRED, but the notification is already gone — same exits, silent. */
private fun processDismissed(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = false)
is Input.StaleUpdate -> suspend(Phase.DISMISSED)
is Input.LiveUpdate -> when {
isNewChargeContext(input) -> {
// Nothing to cancel, so unlike FIRED the new session can start right away.
phase = Phase.IDLE
highwater.clear()
processIdle(input)
}
isSessionDischarged(input) -> reset(cancel = false)
else -> Output.NONE
}
}
private fun processSuspended(input: Input): Output = when (input) {
is Input.Reset -> reset(cancel = suspendedFrom == Phase.FIRED)
is Input.StaleUpdate -> Output.NONE
is Input.LiveUpdate -> {
phase = suspendedFrom
if (phase == Phase.CHARGING && hasRegressed(input)) {
// Battery dropped well below a session mark while we were blind — the device
// was unplugged and used in the meantime. Start over with the fresh data.
phase = Phase.IDLE
highwater.clear()
processIdle(input)
} else {
process(input)
}
}
}
/** Something is charging below the threshold — a fresh charging session is beginning. */
private fun isNewChargeContext(input: Input.LiveUpdate): Boolean =
input.slots.any { it.value.isCharging && it.value.battery < input.threshold }
/** A session slot is off power and drained below the threshold — device is in use. */
private fun isSessionDischarged(input: Input.LiveUpdate): Boolean = highwater.keys.any { slot ->
input.slots[slot]?.let { !it.isCharging && it.battery < input.threshold } == true
}
private fun isActivityDetected(activity: Activity): Boolean {
// Worn pods and pod removal only speak about pod slots; a case-only session ignores them.
if (highwater.keys.any { it != Slot.CASE }) {
activity.wornSlots?.let { worn ->
when (val baseline = baselineWorn) {
// No ear data at fire time: pods can't be worn while charging in the case,
// so any worn pod is activity — only "nothing worn" is adoptable as baseline.
null -> if (worn.isNotEmpty()) return true else baselineWorn = emptySet()
else -> if (worn != baseline) return true
}
}
val newlyDisconnected = activity.disconnectedSlots.any {
it != Slot.CASE && it in highwater && it !in baselineDisconnected
}
if (newlyDisconnected) return true
}
activity.lid?.let { lid ->
if (baselineLids.isEmpty()) {
// Lid never observed pre-fire: first sighting is the baseline — it was most
// likely in that posture all along (open is the normal charging posture).
baselineLids.add(lid)
} else if (lid !in baselineLids) {
return true
}
}
return false
}
private fun hasRegressed(input: Input.LiveUpdate): Boolean = highwater.any { (slot, mark) ->
input.slots[slot]?.let { it.battery < mark - REGRESSION_TOLERANCE } == true
}
private fun recordRecentLid(lid: Lid) {
recentLids.addLast(lid)
while (recentLids.size > RECENT_LID_FRAMES) recentLids.removeFirst()
}
private fun fireIfComplete(input: Input.LiveUpdate): Output {
if (highwater.isEmpty() || highwater.any { it.value < input.threshold }) return Output.NONE
phase = Phase.FIRED
baselineLids.clear()
baselineLids += recentLids
baselineWorn = input.activity.wornSlots
baselineDisconnected = input.activity.disconnectedSlots.intersect(highwater.keys)
return Output.SHOW
}
private fun reset(cancel: Boolean): Output {
phase = Phase.IDLE
highwater.clear()
// recentLids is an ambient rolling window — intentionally kept across resets so an
// instant re-fire after a settings change still has the recent lid alternation.
baselineLids.clear()
baselineWorn = null
baselineDisconnected = emptySet()
return if (cancel) Output.CANCEL else Output.NONE
}
private fun suspend(from: Phase): Output {
suspendedFrom = from
phase = Phase.SUSPENDED
return Output.NONE
}
companion object {
/**
* Battery drop (fraction) below a slot's highwater mark that counts as a real
* regression on resume. Public BLE reports in 10% steps, so a single-step flicker
* (exactly 0.1) must not reset the session.
*/
private const val REGRESSION_TOLERANCE = 0.1f
/**
* Pre-fire lid observations kept as baseline context. At the observed 0.5-1.5s
* emission cadence this is a few seconds — enough to capture dedup alternation
* leading into an instant fire without dragging in long-stale postures.
*/
private const val RECENT_LID_FRAMES = 8
}
}
+11
View File
@@ -82,6 +82,13 @@
<string name="settings_popup_connected_label">Show connection popup</string>
<string name="settings_popup_connected_description">Show a popup when the device connects for the first time.</string>
<string name="settings_popup_info_not_in_app">Popups are only shown when you are not in the app.</string>
<string name="settings_charged_notification_label">Charged notification</string>
<string name="settings_charged_notification_description">Show a notification once everything that is charging reaches the target charge level.</string>
<string name="settings_charged_threshold_label">Target charge level</string>
<string name="settings_charged_scope_label">Monitored batteries</string>
<string name="settings_charged_scope_pods_label">Pods only</string>
<string name="settings_charged_scope_case_label">Case only</string>
<string name="settings_charged_scope_both_label">Pods and case</string>
<string name="device_settings_experimental_title">Experimental feature</string>
<string name="device_settings_experimental_description">This feature hasn\'t been tested on all devices yet. If something isn\'t working correctly, please open an issue with a debug log.</string>
@@ -540,6 +547,10 @@
<string name="reaction_sleep_channel_label">Sleep Detection</string>
<string name="reaction_sleep_notification_title">Paused by Sleep Detection</string>
<string name="reaction_sleep_notification_text">%1$s reported you fell asleep, so your music was paused. You can disable this in the device settings.</string>
<string name="reaction_charged_channel_label">Charged Notification</string>
<string name="reaction_charged_notification_title">Charging complete</string>
<string name="reaction_charged_notification_text_full">%1$s is fully charged.</string>
<string name="reaction_charged_notification_text_partial">%1$s has been charged to %2$d%%.</string>
<string name="device_settings_rename_label">Rename</string>
<string name="device_settings_rename_hint">Device name</string>
<string name="device_settings_rename_confirm">Rename</string>
@@ -0,0 +1,46 @@
package eu.darken.capod.profiles.core
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AppleDeviceProfileSerializationTest : BaseTest() {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `profiles stored before the charged reaction decode with defaults`() {
val legacyJson = """
{
"id": "test-id",
"label": "My Pods"
}
""".trimIndent()
val profile = json.decodeFromString<AppleDeviceProfile>(legacyJson)
profile.notifyWhenCharged shouldBe false
profile.chargedThreshold shouldBe ReactionConfig.DEFAULT_CHARGED_THRESHOLD
profile.chargedSlotScope shouldBe ChargedSlotScope.PODS_AND_CASE
profile.reactionConfig.chargedSlotScope shouldBe ChargedSlotScope.PODS_AND_CASE
}
@Test
fun `charged reaction settings round-trip`() {
val profile = AppleDeviceProfile(
label = "My Pods",
notifyWhenCharged = true,
chargedThreshold = 80,
chargedSlotScope = ChargedSlotScope.PODS,
)
val decoded = json.decodeFromString<AppleDeviceProfile>(json.encodeToString(profile))
decoded.notifyWhenCharged shouldBe true
decoded.chargedThreshold shouldBe 80
decoded.chargedSlotScope shouldBe ChargedSlotScope.PODS
decoded.reactionConfig shouldBe profile.reactionConfig
}
}
@@ -0,0 +1,175 @@
package eu.darken.capod.reaction.core.charged
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class ChargedReactionTest : BaseTest() {
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var deviceMonitor: DeviceMonitor
private lateinit var profilesRepo: DeviceProfilesRepo
@BeforeEach
fun setup() {
profilesFlow = MutableStateFlow(emptyList())
devicesFlow = MutableStateFlow(emptyList())
deviceMonitor = mockk(relaxed = true) { every { devices } returns devicesFlow }
profilesRepo = mockk(relaxed = true) { every { profiles } returns profilesFlow }
}
private fun reaction() = ChargedReaction(deviceMonitor, profilesRepo)
private fun profile(
id: String = "p1",
model: PodModel = PodModel.AIRPODS_PRO2_USBC,
notify: Boolean = true,
threshold: Int = 80,
scope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
) = AppleDeviceProfile(
id = id,
label = "Test",
model = model,
notifyWhenCharged = notify,
chargedThreshold = threshold,
chargedSlotScope = scope,
)
private fun aap(vararg slots: Pair<AapPodState.BatteryType, Pair<Float, AapPodState.ChargingState>>) =
AapPodState(
connectionState = AapPodState.ConnectionState.READY,
batteries = slots.associate { (type, v) ->
type to AapPodState.Battery(type, v.first, v.second)
},
)
private fun device(
id: String = "p1",
model: PodModel = PodModel.AIRPODS_PRO2_USBC,
aap: AapPodState,
) = PodDevice(profileId = id, ble = null, aap = aap, profileModel = model)
private fun bothPodsCharging(percent: Float, charging: AapPodState.ChargingState = AapPodState.ChargingState.CHARGING) =
aap(
AapPodState.BatteryType.LEFT to (percent to charging),
AapPodState.BatteryType.RIGHT to (percent to charging),
)
@Test
fun `fires once when all session slots reach the threshold`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile())
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.7f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `disabling the toggle cancels the notification`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile())
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
profilesFlow.value = listOf(profile(notify = false))
runCurrent()
events.filterIsInstance<ChargedReaction.Event.CancelNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `scope change resets a fired session and cancels`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile(scope = ChargedSlotScope.PODS_AND_CASE))
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
// Switch to CASE scope: the device has no case slot, so after the reset nothing
// qualifies and the notification must come down (and not re-fire).
profilesFlow.value = listOf(profile(scope = ChargedSlotScope.CASE))
runCurrent()
events.filterIsInstance<ChargedReaction.Event.CancelNotification>().size shouldBe 1
// No re-fire under the new scope (device has no case slot).
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `deleted profile cancels its notification`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile())
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
profilesFlow.value = emptyList()
runCurrent()
events.filterIsInstance<ChargedReaction.Event.CancelNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `caseless model normalizes a stored CASE scope to pods and still fires`() =
runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
// AirPods Max has no case; a restored CASE scope must degrade to PODS or the
// headset slot would be filtered out and the session could never complete.
profilesFlow.value = listOf(
profile(model = PodModel.AIRPODS_MAX, scope = ChargedSlotScope.CASE),
)
devicesFlow.value = listOf(
device(
model = PodModel.AIRPODS_MAX,
aap = aap(AapPodState.BatteryType.SINGLE to (0.9f to AapPodState.ChargingState.CHARGING)),
),
)
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.filterIsInstance<ChargedReaction.Event.ShowNotification>().size shouldBe 1
job.cancel()
}
@Test
fun `non-enabled profiles never emit`() = runTest(UnconfinedTestDispatcher()) {
val events = mutableListOf<ChargedReaction.Event>()
profilesFlow.value = listOf(profile(notify = false))
devicesFlow.value = listOf(device(aap = bothPodsCharging(0.9f)))
val job = reaction().monitor().onEach { events.add(it) }.launchIn(this)
runCurrent()
events.size shouldBe 0
job.cancel()
}
}
@@ -0,0 +1,136 @@
package eu.darken.capod.reaction.core.charged
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Slot
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class ChargingActivityMapperTest : BaseTest() {
private fun aapWith(
primary: AapSetting.EarDetection.PodPlacement,
secondary: AapSetting.EarDetection.PodPlacement,
primaryPod: AapSetting.PrimaryPod.Pod = AapSetting.PrimaryPod.Pod.LEFT,
) = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = primary,
secondaryPod = secondary,
),
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(primaryPod),
),
)
@Test
fun `BLE-only in-ear is ignored as phantom for in-case pods`() {
// Pods report in-ear over BLE but there's no AAP session — the normal in-case charging
// state. Worn must be unknown so phantom/flapping BLE bits can't dismiss the notification.
val device = PodDevice(
profileId = "p",
ble = MockPodDataProvider.airPodsGen1Wearing(),
aap = null,
)
device.chargingActivity().wornSlots shouldBe null
}
@Test
fun `AAP ear detection is trusted`() {
val device = PodDevice(
profileId = "p",
ble = MockPodDataProvider.airPodsGen1Wearing(),
aap = aapWith(
primary = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondary = AapSetting.EarDetection.PodPlacement.IN_CASE,
),
)
// Both pods worn over BLE, but AAP says only the primary (LEFT) is in-ear — AAP wins.
device.chargingActivity().wornSlots shouldBe setOf(Slot.LEFT)
}
@Test
fun `AAP reporting nothing worn yields an empty set, not null`() {
val device = PodDevice(
profileId = "p",
ble = MockPodDataProvider.airPodsGen1Wearing(),
aap = aapWith(
primary = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondary = AapSetting.EarDetection.PodPlacement.IN_CASE,
),
)
device.chargingActivity().wornSlots shouldBe emptySet<Slot>()
}
private fun chargingAap(vararg slots: AapPodState.BatteryType) = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
batteries = slots.associateWith {
AapPodState.Battery(it, 0.9f, AapPodState.ChargingState.CHARGING)
},
)
@Test
fun `PODS scope excludes the case slot`() {
val device = PodDevice(
profileId = "p",
ble = null,
aap = chargingAap(
AapPodState.BatteryType.LEFT,
AapPodState.BatteryType.RIGHT,
AapPodState.BatteryType.CASE,
),
)
device.liveChargingSlots(ChargedSlotScope.PODS).keys shouldBe setOf(Slot.LEFT, Slot.RIGHT)
}
@Test
fun `CASE scope keeps only the case slot`() {
val device = PodDevice(
profileId = "p",
ble = null,
aap = chargingAap(
AapPodState.BatteryType.LEFT,
AapPodState.BatteryType.RIGHT,
AapPodState.BatteryType.CASE,
),
)
device.liveChargingSlots(ChargedSlotScope.CASE).keys shouldBe setOf(Slot.CASE)
}
@Test
fun `PODS_AND_CASE scope keeps every charging slot`() {
val device = PodDevice(
profileId = "p",
ble = null,
aap = chargingAap(
AapPodState.BatteryType.LEFT,
AapPodState.BatteryType.RIGHT,
AapPodState.BatteryType.CASE,
),
)
device.liveChargingSlots(ChargedSlotScope.PODS_AND_CASE).keys shouldBe
setOf(Slot.LEFT, Slot.RIGHT, Slot.CASE)
}
@Test
fun `headset counts as a pod slot under PODS scope`() {
val device = PodDevice(
profileId = "p",
ble = null,
aap = chargingAap(AapPodState.BatteryType.SINGLE),
)
device.liveChargingSlots(ChargedSlotScope.PODS).keys shouldBe setOf(Slot.HEADSET)
// ...and CASE scope yields nothing for a headset device.
device.liveChargingSlots(ChargedSlotScope.CASE).keys shouldBe emptySet<Slot>()
}
}
@@ -0,0 +1,540 @@
package eu.darken.capod.reaction.core.charged
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Activity
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Input
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Lid
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Output
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Phase
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Slot
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.SlotData
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class ChargingSessionStateMachineTest : BaseTest() {
private val machine = ChargingSessionStateMachine()
private fun live(threshold: Float = 1.0f, vararg slots: Pair<Slot, SlotData>) =
Input.LiveUpdate(slots = slots.toMap(), threshold = threshold)
private fun charging(battery: Float) = SlotData(battery = battery, isCharging = true)
private fun idle(battery: Float) = SlotData(battery = battery, isCharging = false)
@Test
fun `no charging slots keeps machine idle`() {
machine.process(live(1.0f, Slot.LEFT to idle(0.5f))) shouldBe Output.NONE
machine.phase shouldBe Phase.IDLE
}
@Test
fun `dual pods and case charge to full fires once`() {
machine.process(
live(1.0f, Slot.LEFT to charging(0.5f), Slot.RIGHT to charging(0.6f), Slot.CASE to charging(0.7f))
) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(
live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.NONE
machine.process(
live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.SHOW
machine.phase shouldBe Phase.FIRED
// Same data again — no duplicate fire.
machine.process(
live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.NONE
}
@Test
fun `slot stopping to charge at threshold counts as complete not unplug`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)))
// Firmware flips isCharging off once a pod hits 100%.
machine.process(live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to charging(0.9f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f))) shouldBe Output.SHOW
}
@Test
fun `slot stopping to charge below threshold resets the session`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.5f)))
machine.process(live(1.0f, Slot.LEFT to idle(0.6f))) shouldBe Output.NONE
machine.phase shouldBe Phase.IDLE
}
@Test
fun `notification cancels when battery discharges below threshold after firing`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.99f)))
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
// Charging flag dropping at full is NOT an unplug signal — notification stays.
machine.process(live(1.0f, Slot.HEADSET to idle(1.0f))) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
// Battery dropping below threshold proves the device is off power and in use.
machine.process(live(1.0f, Slot.HEADSET to idle(0.97f))) shouldBe Output.CANCEL
machine.phase shouldBe Phase.IDLE
}
@Test
fun `new charging session after firing cancels and re-arms`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(live(1.0f, Slot.HEADSET to charging(0.4f))) shouldBe Output.CANCEL
machine.phase shouldBe Phase.IDLE
machine.process(live(1.0f, Slot.HEADSET to charging(0.4f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `idle non-session slot below threshold does not cancel after firing`() {
// Regression: pods charging in an open case whose own battery sits below the threshold
// but isn't charging. The case carries no signal and must not flap SHOW/CANCEL.
machine.process(
live(0.6f, Slot.LEFT to charging(0.7f), Slot.RIGHT to charging(0.7f), Slot.CASE to idle(0.45f))
) shouldBe Output.SHOW
machine.process(
live(0.6f, Slot.LEFT to charging(0.7f), Slot.RIGHT to charging(0.7f), Slot.CASE to idle(0.45f))
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
}
@Test
fun `case starting to charge below threshold after firing starts a new session`() {
machine.process(live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f))) shouldBe Output.SHOW
machine.process(
live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f), Slot.CASE to charging(0.5f))
) shouldBe Output.CANCEL
machine.process(
live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.SHOW
}
@Test
fun `re-plug near full after firing cancels then fires on the next update`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
// Re-plugged while slightly drained: the old notification is cancelled first…
machine.process(live(1.0f, Slot.HEADSET to charging(0.95f))) shouldBe Output.CANCEL
// …and the new session starts (and can fire) on the following update.
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `custom threshold uses at-least semantics across coarse jumps`() {
// Public BLE reports in 10% steps; 0.7 → 0.9 may skip the 0.8 threshold entirely.
machine.process(live(0.8f, Slot.LEFT to charging(0.7f), Slot.RIGHT to charging(0.7f)))
machine.process(live(0.8f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f))) shouldBe Output.SHOW
}
@Test
fun `slot joining mid-session must also reach threshold`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)))
machine.process(
live(
1.0f,
Slot.LEFT to charging(1.0f),
Slot.RIGHT to charging(1.0f),
Slot.CASE to charging(0.5f),
)
) shouldBe Output.NONE
machine.process(
live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f), Slot.CASE to charging(1.0f))
) shouldBe Output.SHOW
}
@Test
fun `stale data suspends and resuming continues the session`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.8f)))
machine.process(Input.StaleUpdate) shouldBe Output.NONE
machine.phase shouldBe Phase.SUSPENDED
machine.process(Input.StaleUpdate) shouldBe Output.NONE
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `suspend after firing keeps notification and cancels on discharged resume`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(Input.StaleUpdate) shouldBe Output.NONE
machine.process(live(1.0f, Slot.HEADSET to idle(0.8f))) shouldBe Output.CANCEL
machine.phase shouldBe Phase.IDLE
}
@Test
fun `battery regression during suspension starts a fresh session`() {
machine.process(live(0.8f, Slot.HEADSET to charging(0.75f)))
machine.process(Input.StaleUpdate)
// Device was unplugged and used while we were blind: battery well below the highwater.
machine.process(live(0.8f, Slot.HEADSET to charging(0.4f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
// The old 0.75 highwater is gone — 0.4 must climb to 0.8 again before firing.
machine.process(live(0.8f, Slot.HEADSET to charging(0.7f))) shouldBe Output.NONE
machine.process(live(0.8f, Slot.HEADSET to charging(0.8f))) shouldBe Output.SHOW
}
@Test
fun `single step BLE flicker does not count as regression`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.8f)))
machine.process(Input.StaleUpdate)
// Exactly one public-BLE step (0.1) below the mark — tolerated, session continues.
machine.process(live(1.0f, Slot.HEADSET to charging(0.7f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `session slot missing from an update neither completes nor unplugs`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)))
// Right slot vanishes (e.g. AAP only pushed a partial battery update).
machine.process(live(1.0f, Slot.LEFT to charging(1.0f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.LEFT to idle(1.0f), Slot.RIGHT to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `reset before firing is silent`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.5f)))
machine.process(Input.Reset) shouldBe Output.NONE
machine.phase shouldBe Phase.IDLE
}
@Test
fun `reset after firing cancels the notification`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(Input.Reset) shouldBe Output.CANCEL
}
@Test
fun `reset while suspended from fired cancels the notification`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(Input.StaleUpdate)
machine.process(Input.Reset) shouldBe Output.CANCEL
}
@Test
fun `reset while suspended from charging is silent`() {
machine.process(live(1.0f, Slot.HEADSET to charging(0.5f)))
machine.process(Input.StaleUpdate)
machine.process(Input.Reset) shouldBe Output.NONE
}
@Test
fun `already full slot fires immediately when charging starts`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `wearing a pod after firing dismisses without re-firing`() {
machine.process(live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f))) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.LEFT)),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
// Still full, pod back in the charging case — latched, no second notification.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `lid movement after firing dismisses`() {
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.NONE
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.CLOSED),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `lid unknown at fire becomes baseline instead of dismissing`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
// First definite lid sighting after fire establishes the baseline silently…
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
// …and only a subsequent change dismisses.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.CLOSED),
)
) shouldBe Output.CANCEL
}
@Test
fun `pod taken out of case after firing dismisses`() {
// BLE-only path: removing a pod doesn't produce an in-ear or AAP DISCONNECTED signal,
// but the out-of-case pod broadcasts lid state NOT_IN_CASE — that change is activity.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.NOT_IN_CASE),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `session slot going disconnected after firing dismisses`() {
machine.process(live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f))) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(disconnectedSlots = setOf(Slot.LEFT)),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `worn while charging at fire time does not insta-dismiss`() {
// AirPods Max can be worn while cable-charging: worn when the notification fires and
// staying worn is not activity — taking them off is.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to charging(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.HEADSET)),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to charging(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.HEADSET)),
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = emptySet()),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `steady one-pod wear with lid flapping never dismisses but second pod does`() {
// The hardware scenario: LEFT worn, RIGHT charging in the open case. Both pods
// broadcast, dedup alternates their frames, so the lid flaps OPEN/NOT_IN_CASE and
// the worn set stays {LEFT}. None of that is new activity after the fire.
fun frame(lid: Lid, battery: Float) = Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to charging(battery)),
threshold = 0.8f,
activity = Activity(wornSlots = setOf(Slot.LEFT), lid = lid),
)
machine.process(frame(Lid.OPEN, 0.6f)) shouldBe Output.NONE
machine.process(frame(Lid.NOT_IN_CASE, 0.7f)) shouldBe Output.NONE
machine.process(frame(Lid.OPEN, 0.7f)) shouldBe Output.NONE
machine.process(frame(Lid.NOT_IN_CASE, 0.8f)) shouldBe Output.SHOW
// Post-fire flapping continues — both lid values are in the union baseline.
machine.process(frame(Lid.OPEN, 0.8f)) shouldBe Output.NONE
machine.process(frame(Lid.NOT_IN_CASE, 0.8f)) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
// Wearing the freshly charged second pod is new activity.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to idle(0.8f)),
threshold = 0.8f,
activity = Activity(wornSlots = setOf(Slot.LEFT, Slot.RIGHT), lid = Lid.NOT_IN_CASE),
)
) shouldBe Output.CANCEL
machine.phase shouldBe Phase.DISMISSED
}
@Test
fun `instant fire after a reset keeps the lid alternation in its baseline`() {
// Step 3 hardware path: left worn, right charging, user lowers the threshold to an
// already-reached value. The lid alternates OPEN/NOT_IN_CASE the whole time; the reset
// must not throw away that window or the next (instant) fire flaps itself away.
fun frame(lid: Lid, charging: Boolean) = Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to SlotData(0.9f, charging)),
threshold = 0.8f,
activity = Activity(wornSlots = setOf(Slot.LEFT), lid = lid),
)
// Ambient alternation observed before the settings change.
machine.process(frame(Lid.OPEN, true))
machine.process(frame(Lid.NOT_IN_CASE, true))
machine.process(frame(Lid.OPEN, true))
machine.process(Input.Reset) shouldBe Output.NONE
// Instant fire on the next frame after the reset.
machine.process(frame(Lid.NOT_IN_CASE, true)) shouldBe Output.SHOW
// The alternate lid value must already be in the baseline — no false dismiss.
machine.process(frame(Lid.OPEN, true)) shouldBe Output.NONE
machine.process(frame(Lid.NOT_IN_CASE, true)) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
}
@Test
fun `lid closing still dismisses despite flap-tolerant baseline`() {
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.NONE
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.OPEN),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.CLOSED),
)
) shouldBe Output.CANCEL
}
@Test
fun `case-only session ignores worn pods but dismisses on lid movement`() {
// Scope=CASE feeds only the case slot; wearing pods says nothing about the case charge.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.CASE to charging(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = emptySet(), lid = Lid.OPEN),
)
) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.CASE to charging(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.LEFT, Slot.RIGHT), lid = Lid.OPEN),
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.CASE to charging(1.0f)),
threshold = 1.0f,
activity = Activity(lid = Lid.CLOSED),
)
) shouldBe Output.CANCEL
}
@Test
fun `slot disconnected in the firing frame is baseline not activity`() {
machine.process(live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f)))
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.LEFT to idle(1.0f), Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(disconnectedSlots = setOf(Slot.LEFT)),
)
) shouldBe Output.SHOW
// LEFT was already out at fire time — only RIGHT leaving the case is new activity.
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(disconnectedSlots = setOf(Slot.LEFT)),
)
) shouldBe Output.NONE
machine.phase shouldBe Phase.FIRED
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.RIGHT to idle(1.0f)),
threshold = 1.0f,
activity = Activity(disconnectedSlots = setOf(Slot.LEFT, Slot.RIGHT)),
)
) shouldBe Output.CANCEL
}
@Test
fun `dismissed re-arms through a new below-threshold charge`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.HEADSET)),
)
) shouldBe Output.CANCEL
// Drained and plugged back in → fresh session that can fire again.
machine.process(live(1.0f, Slot.HEADSET to charging(0.5f))) shouldBe Output.NONE
machine.phase shouldBe Phase.CHARGING
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
}
@Test
fun `dismissed survives staleness and resets silently`() {
machine.process(live(1.0f, Slot.HEADSET to charging(1.0f))) shouldBe Output.SHOW
machine.process(
Input.LiveUpdate(
slots = mapOf(Slot.HEADSET to idle(1.0f)),
threshold = 1.0f,
activity = Activity(wornSlots = setOf(Slot.HEADSET)),
)
) shouldBe Output.CANCEL
machine.process(Input.StaleUpdate) shouldBe Output.NONE
machine.process(live(1.0f, Slot.HEADSET to idle(1.0f))) shouldBe Output.NONE
machine.phase shouldBe Phase.DISMISSED
machine.process(Input.Reset) shouldBe Output.NONE
}
@Test
fun `non-charging slots do not join the session`() {
// Case sits at 40% but isn't charging — only the pods gate the notification.
machine.process(
live(1.0f, Slot.LEFT to charging(0.9f), Slot.RIGHT to charging(0.9f), Slot.CASE to idle(0.4f))
)
machine.process(
live(1.0f, Slot.LEFT to charging(1.0f), Slot.RIGHT to charging(1.0f), Slot.CASE to idle(0.4f))
) shouldBe Output.SHOW
}
}