mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(reaction): Add charged notification
This commit is contained in:
@@ -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,
|
||||
|
||||
+75
@@ -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
|
||||
}
|
||||
+87
@@ -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),
|
||||
}
|
||||
+287
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+46
@@ -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()
|
||||
}
|
||||
}
|
||||
+136
@@ -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>()
|
||||
}
|
||||
}
|
||||
+540
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user