From 2804543b9d39994f5426ae3e1b0abb4861a5f00e Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 10 Jun 2026 17:54:14 +0200 Subject: [PATCH] feat(reaction): Add charged notification --- .../ui/devicesettings/DeviceSettingsScreen.kt | 10 + .../devicesettings/DeviceSettingsViewModel.kt | 19 + .../ui/devicesettings/cards/ReactionsCard.kt | 52 ++ .../dialogs/ChargedSlotScopeDialog.kt | 75 +++ .../eu/darken/capod/monitor/core/PodDevice.kt | 8 + .../monitor/core/worker/MonitorService.kt | 26 + .../capod/profiles/core/AppleDeviceProfile.kt | 10 + .../capod/profiles/core/ReactionConfig.kt | 10 + .../reaction/core/charged/ChargedReaction.kt | 199 +++++++ .../charged/ChargedReactionNotifications.kt | 87 +++ .../reaction/core/charged/ChargedSlotScope.kt | 16 + .../charged/ChargingSessionStateMachine.kt | 287 ++++++++++ app/src/main/res/values/strings.xml | 11 + .../AppleDeviceProfileSerializationTest.kt | 46 ++ .../core/charged/ChargedReactionTest.kt | 175 ++++++ .../charged/ChargingActivityMapperTest.kt | 136 +++++ .../ChargingSessionStateMachineTest.kt | 540 ++++++++++++++++++ 17 files changed, 1707 insertions(+) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/dialogs/ChargedSlotScopeDialog.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedReaction.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedReactionNotifications.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedSlotScope.kt create mode 100644 app/src/main/java/eu/darken/capod/reaction/core/charged/ChargingSessionStateMachine.kt create mode 100644 app/src/test/java/eu/darken/capod/profiles/core/AppleDeviceProfileSerializationTest.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/charged/ChargedReactionTest.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/charged/ChargingActivityMapperTest.kt create mode 100644 app/src/test/java/eu/darken/capod/reaction/core/charged/ChargingSessionStateMachineTest.kt diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 31e3fcc5..dd1f9f85 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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, ) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 09d1dfbf..adbc66f0 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt index 494a4ad4..9da5cd49 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/dialogs/ChargedSlotScopeDialog.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/dialogs/ChargedSlotScopeDialog.kt new file mode 100644 index 00000000..8150f5da --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/dialogs/ChargedSlotScopeDialog.kt @@ -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 = {}, + ) +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 94c77238..29b7c7c2 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 4a4fc36e..b67ab13a 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -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 { diff --git a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt index 5a1e941a..fd53ddbb 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt @@ -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" + diff --git a/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt b/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt index c88139e4..60d09de8 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedReaction.kt new file mode 100644 index 00000000..2ec8d1bf --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedReaction.kt @@ -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 = flow { + // Machines live in the collection scope: a monitor (service) restart starts fresh sessions. + val machines = mutableMapOf() + val configs = mutableMapOf() + combine( + profilesRepo.profiles, + deviceMonitor.devicesWithProfiles(), + ) { profiles, devices -> profiles.filterIsInstance() 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 { + val slots = mutableMapOf() + 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 +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedReactionNotifications.kt b/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedReactionNotifications.kt new file mode 100644 index 00000000..7b8f9653 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedReactionNotifications.kt @@ -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 + } +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedSlotScope.kt b/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedSlotScope.kt new file mode 100644 index 00000000..9a2d9bee --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargedSlotScope.kt @@ -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), +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargingSessionStateMachine.kt b/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargingSessionStateMachine.kt new file mode 100644 index 00000000..205ca9ef --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/charged/ChargingSessionStateMachine.kt @@ -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? = null, + val lid: Lid? = null, + /** Slots physically absent from the case (AAP ChargingState.DISCONNECTED). */ + val disconnectedSlots: Set = 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, + 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() + + /** + * 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() + + // Activity baselines frozen at fire; see class docs. + private val baselineLids = mutableSetOf() + private var baselineWorn: Set? = null + private var baselineDisconnected: Set = 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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13da108d..b17cb5cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,13 @@ Show connection popup Show a popup when the device connects for the first time. Popups are only shown when you are not in the app. + Charged notification + Show a notification once everything that is charging reaches the target charge level. + Target charge level + Monitored batteries + Pods only + Case only + Pods and case Experimental feature This feature hasn\'t been tested on all devices yet. If something isn\'t working correctly, please open an issue with a debug log. @@ -540,6 +547,10 @@ Sleep Detection Paused by Sleep Detection %1$s reported you fell asleep, so your music was paused. You can disable this in the device settings. + Charged Notification + Charging complete + %1$s is fully charged. + %1$s has been charged to %2$d%%. Rename Device name Rename diff --git a/app/src/test/java/eu/darken/capod/profiles/core/AppleDeviceProfileSerializationTest.kt b/app/src/test/java/eu/darken/capod/profiles/core/AppleDeviceProfileSerializationTest.kt new file mode 100644 index 00000000..d84ecfa8 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/profiles/core/AppleDeviceProfileSerializationTest.kt @@ -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(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(json.encodeToString(profile)) + + decoded.notifyWhenCharged shouldBe true + decoded.chargedThreshold shouldBe 80 + decoded.chargedSlotScope shouldBe ChargedSlotScope.PODS + decoded.reactionConfig shouldBe profile.reactionConfig + } +} diff --git a/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargedReactionTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargedReactionTest.kt new file mode 100644 index 00000000..a28158f2 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargedReactionTest.kt @@ -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> + private lateinit var devicesFlow: MutableStateFlow> + 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( + 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() + 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().size shouldBe 1 + job.cancel() + } + + @Test + fun `disabling the toggle cancels the notification`() = runTest(UnconfinedTestDispatcher()) { + val events = mutableListOf() + 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().size shouldBe 1 + + profilesFlow.value = listOf(profile(notify = false)) + runCurrent() + + events.filterIsInstance().size shouldBe 1 + job.cancel() + } + + @Test + fun `scope change resets a fired session and cancels`() = runTest(UnconfinedTestDispatcher()) { + val events = mutableListOf() + 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().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().size shouldBe 1 + // No re-fire under the new scope (device has no case slot). + events.filterIsInstance().size shouldBe 1 + job.cancel() + } + + @Test + fun `deleted profile cancels its notification`() = runTest(UnconfinedTestDispatcher()) { + val events = mutableListOf() + 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().size shouldBe 1 + + profilesFlow.value = emptyList() + runCurrent() + + events.filterIsInstance().size shouldBe 1 + job.cancel() + } + + @Test + fun `caseless model normalizes a stored CASE scope to pods and still fires`() = + runTest(UnconfinedTestDispatcher()) { + val events = mutableListOf() + // 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().size shouldBe 1 + job.cancel() + } + + @Test + fun `non-enabled profiles never emit`() = runTest(UnconfinedTestDispatcher()) { + val events = mutableListOf() + 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() + } +} diff --git a/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargingActivityMapperTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargingActivityMapperTest.kt new file mode 100644 index 00000000..d58886fb --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargingActivityMapperTest.kt @@ -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() + } + + 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() + } +} diff --git a/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargingSessionStateMachineTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargingSessionStateMachineTest.kt new file mode 100644 index 00000000..fe30c4ea --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/charged/ChargingSessionStateMachineTest.kt @@ -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) = + 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 + } +}