From 59b3106c3bb5e5f4aa73fa4a3f1eafe74828858a Mon Sep 17 00:00:00 2001 From: darken Date: Fri, 24 Apr 2026 17:06:40 +0200 Subject: [PATCH] feat(aap): Surface Optimized Charge indicator and Pro 3 toggle Adds a per-battery 'Optimized' chip on the overview card when pods report wire value 0x05 (CHARGING_OPTIMIZED), which was already decoded but collapsed into a plain 'Charging' in the UI. On AirPods Pro 3, also adds a user-facing toggle for the device-side Optimized Charge Limit (AAP setting 0x3B). - Decode setting 0x3B via decodeAppleBool so unknown values fall through instead of coercing to false - Bypass ear-detection queue for SetDynamicEndOfCharge so the toggle works while pods sit in the closed case - Expose per-slot ChargingState? on PodDevice; StatusChipRow renders 'Optimized' for CHARGING_OPTIMIZED, 'Charging' for CHARGING - New BatteryCard in device settings with experimental warning (pattern matches Sleep Detection) - Generic settingRejectedEvents flow alongside the existing offRejectedEvents so the toggle can show a dedicated snackbar on verification failure --- .../ui/devicesettings/DeviceSettingsScreen.kt | 22 +++++ .../devicesettings/DeviceSettingsViewModel.kt | 13 +++ .../ui/devicesettings/cards/BatteryCard.kt | 85 +++++++++++++++++++ .../main/ui/overview/cards/DualPodsCard.kt | 22 +++-- .../main/ui/overview/cards/SinglePodsCard.kt | 11 ++- .../overview/cards/components/StatusChip.kt | 32 ++++++- .../eu/darken/capod/monitor/core/PodDevice.kt | 13 +++ .../darken/capod/pods/core/apple/PodModel.kt | 8 ++ .../core/apple/aap/AapConnectionManager.kt | 19 +++++ .../capod/pods/core/apple/aap/AapPodState.kt | 7 ++ .../core/apple/aap/engine/AapConnection.kt | 1 + .../apple/aap/engine/AapOutboundController.kt | 6 +- .../core/apple/aap/engine/AapSessionEngine.kt | 12 +++ .../aap/engine/AapSettingsCoordinator.kt | 5 ++ .../core/apple/aap/protocol/AapCommand.kt | 1 + .../core/apple/aap/protocol/AapSetting.kt | 9 ++ .../aap/protocol/DefaultAapDeviceProfile.kt | 10 ++- app/src/main/res/values/strings.xml | 5 ++ .../DefaultAapDeviceProfileNewSettingsTest.kt | 14 +++ .../airpods/AirPodsPro3AapSessionTest.kt | 26 +++++- .../apple/aap/engine/AapSessionEngineTest.kt | 21 +++++ 21 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.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 39839f41..9c88c670 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 @@ -42,6 +42,7 @@ import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.settings.SettingsInfoBox import eu.darken.capod.common.settings.SettingsSection import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard +import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard @@ -84,6 +85,7 @@ fun DeviceSettingsScreenHost( var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) } val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message) + val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message) LaunchedEffect(Unit) { vm.events.collect { event -> @@ -105,6 +107,10 @@ fun DeviceSettingsScreenHost( DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> { snackbarHostState.showSnackbar(offRejectedMessage) } + + DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> { + snackbarHostState.showSnackbar(chargeCapRejectedMessage) + } } } } @@ -139,6 +145,7 @@ fun DeviceSettingsScreenHost( onListeningModeCycleChange = { vm.setListeningModeCycle(it) }, onAllowOffOptionChange = { vm.setAllowOffOption(it) }, onSleepDetectionChange = { vm.setSleepDetection(it) }, + onDynamicEndOfChargeChange = { vm.setDynamicEndOfCharge(it) }, onDeviceNameChange = { vm.setDeviceName(it) }, onPressControlsClick = { vm.navToPressControls() }, onForceConnect = { vm.forceConnect() }, @@ -175,6 +182,7 @@ fun DeviceSettingsScreen( onListeningModeCycleChange: (Int) -> Unit = {}, onAllowOffOptionChange: (Boolean) -> Unit = {}, onSleepDetectionChange: (Boolean) -> Unit = {}, + onDynamicEndOfChargeChange: (Boolean) -> Unit = {}, onDeviceNameChange: (String) -> Unit = {}, onPressControlsClick: () -> Unit = {}, onForceConnect: () -> Unit = {}, @@ -389,6 +397,19 @@ fun DeviceSettingsScreen( } } + // ── Battery ────────────────────────────────── + if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) { + item("battery_section") { + BatteryCard( + device = device, + features = features, + enabled = enabled, + onDynamicEndOfChargeChange = onDynamicEndOfChargeChange, + onOpenIssueTracker = onOpenIssueTracker, + ) + } + } + // ── Connections ─────────────────────────────── val connectedDevices = device.connectedDevices if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) { @@ -475,6 +496,7 @@ internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State( muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS, endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS, ), + AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = true), ), ), ), 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 4acf06d3..d933505f 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 @@ -79,6 +79,7 @@ class DeviceSettingsViewModel @Inject constructor( data class SendFailed(val command: AapCommand, val message: String?) : Event data object SystemRenameUnavailable : Event data object OffModeRejectedByDevice : Event + data object DynamicEndOfChargeRejectedByDevice : Event } val events = SingleEventFlow() @@ -91,6 +92,16 @@ class DeviceSettingsViewModel @Inject constructor( } } } + launch { + aapManager.settingRejectedEvents.collect { (address, command) -> + if (address != currentAddress()) return@collect + when (command) { + is AapCommand.SetDynamicEndOfCharge -> + events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice) + else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF) + } + } + } } val state = targetProfileId.flatMapLatest { profileId -> @@ -285,6 +296,8 @@ class DeviceSettingsViewModel @Inject constructor( fun setSleepDetection(enabled: Boolean) = send(AapCommand.SetSleepDetection(enabled)) + fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled)) + fun setDeviceName(name: String) = launch { val address = currentAddress() ?: return@launch try { diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt new file mode 100644 index 00000000..440011af --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt @@ -0,0 +1,85 @@ +package eu.darken.capod.main.ui.devicesettings.cards + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.BatteryChargingFull +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.darken.capod.R +import eu.darken.capod.common.compose.Preview2 +import eu.darken.capod.common.compose.PreviewWrapper +import eu.darken.capod.common.settings.InfoBoxType +import eu.darken.capod.common.settings.SettingsInfoBox +import eu.darken.capod.common.settings.SettingsSection +import eu.darken.capod.common.settings.SettingsSwitchItem +import eu.darken.capod.main.ui.devicesettings.previewFullState +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting + +/** + * Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise + * [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention + * used by every other boolean setting. The experimental warning renders below the toggle and + * only when enabled — consistent with Sleep Detection and Personalized Volume in the rest of + * the app. + */ +@Composable +internal fun BatteryCard( + device: PodDevice, + features: PodModel.Features, + enabled: Boolean, + onDynamicEndOfChargeChange: (Boolean) -> Unit = {}, + onOpenIssueTracker: () -> Unit = {}, +) { + if (!features.hasDynamicEndOfCharge) return + val cap = device.dynamicEndOfCharge ?: return + + SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) { + SettingsSwitchItem( + icon = Icons.TwoTone.BatteryChargingFull, + title = stringResource(R.string.device_settings_charge_cap_label), + subtitle = stringResource(R.string.device_settings_charge_cap_description), + checked = cap.enabled, + onCheckedChange = onDynamicEndOfChargeChange, + enabled = enabled, + ) + if (cap.enabled) { + SettingsInfoBox( + title = stringResource(R.string.device_settings_experimental_title), + text = stringResource(R.string.device_settings_experimental_description), + type = InfoBoxType.WARNING, + action = { + TextButton(onClick = onOpenIssueTracker) { + Text(stringResource(R.string.device_settings_experimental_action)) + } + }, + ) + } + } +} + +@Preview2 +@Composable +private fun BatteryCardEnabledPreview() = PreviewWrapper { + val state = previewFullState(isPro = true) + val device = state.device!! + BatteryCard( + device = device, + features = PodModel.Features(hasDynamicEndOfCharge = true), + enabled = true, + ) +} + +@Preview2 +@Composable +private fun BatteryCardDisabledPreview() = PreviewWrapper { + val state = previewFullState(isPro = true) + val device = state.device!! + BatteryCard( + device = device, + features = PodModel.Features(hasDynamicEndOfCharge = true), + enabled = false, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 40e020e5..2a2eb5c3 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -61,6 +61,7 @@ import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.cachedBatteryFormatted +import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState @@ -227,7 +228,8 @@ private fun ColumnScope.DualPodsCardExpanded( PodGauge( iconRes = device.leftPodIcon, batteryPercent = device.batteryLeft.toBatteryFloat(), - isCharging = device.isLeftPodCharging ?: false, + chargingState = device.leftPodChargingState + ?: device.isLeftPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }, isInEar = device.isLeftInEar ?: false, showEarDetection = device.hasEarDetection && device.hasDualPods, isMicrophone = device.isLeftPodMicrophone ?: false, @@ -238,7 +240,8 @@ private fun ColumnScope.DualPodsCardExpanded( PodGauge( iconRes = device.rightPodIcon, batteryPercent = device.batteryRight.toBatteryFloat(), - isCharging = device.isRightPodCharging ?: false, + chargingState = device.rightPodChargingState + ?: device.isRightPodCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }, isInEar = device.isRightInEar ?: false, showEarDetection = device.hasEarDetection && device.hasDualPods, isMicrophone = device.isRightPodMicrophone ?: false, @@ -292,7 +295,7 @@ private fun ColumnScope.DualPodsCardExpanded( private fun PodGauge( iconRes: Int, batteryPercent: Float, - isCharging: Boolean, + chargingState: AapPodState.ChargingState?, isInEar: Boolean, showEarDetection: Boolean, isMicrophone: Boolean, @@ -370,12 +373,13 @@ private fun PodGauge( // Status chips StatusChipRow( - isCharging = isCharging, + chargingState = chargingState, isInEar = isInEar, showEarDetection = showEarDetection, isMicrophone = isMicrophone, showMicrophone = showMicrophone, chargingLabel = stringResource(R.string.pods_charging_label), + chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label), inEarLabel = stringResource(R.string.pods_inear_label), microphoneLabel = stringResource(R.string.pods_microphone_label), ) @@ -420,11 +424,17 @@ private fun CaseRow( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - if (device.isCaseCharging == true) { - StatusChip( + when (val caseState = device.caseChargingState + ?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) { + AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip( + icon = Icons.TwoTone.BatteryChargingFull, + label = stringResource(R.string.pods_charging_optimized_label), + ) + AapPodState.ChargingState.CHARGING -> StatusChip( icon = Icons.TwoTone.BatteryChargingFull, label = stringResource(R.string.pods_charging_label), ) + else -> Unit } val lidState = device.caseLidState diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 3fc16f36..3c2e61d1 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -58,6 +58,7 @@ import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.cachedBatteryFormatted +import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent import java.time.Instant @@ -267,11 +268,17 @@ private fun ColumnScope.SinglePodsCardExpanded( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - if (device.isHeadsetBeingCharged == true) { - StatusChip( + when (val headsetState = device.headsetChargingState + ?: device.isHeadsetBeingCharged?.let { if (it) AapPodState.ChargingState.CHARGING else null }) { + AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip( + icon = Icons.TwoTone.BatteryChargingFull, + label = stringResource(R.string.pods_charging_optimized_label), + ) + AapPodState.ChargingState.CHARGING -> StatusChip( icon = Icons.TwoTone.BatteryChargingFull, label = stringResource(R.string.pods_charging_label), ) + else -> Unit } if (device.isBeingWorn == true) { StatusChip( diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/StatusChip.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/StatusChip.kt index 4914899a..19965fff 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/StatusChip.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/StatusChip.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper +import eu.darken.capod.pods.core.apple.aap.AapPodState @Composable fun StatusChip( @@ -60,12 +61,13 @@ fun StatusChip( @OptIn(ExperimentalLayoutApi::class) @Composable fun StatusChipRow( - isCharging: Boolean, + chargingState: AapPodState.ChargingState?, isInEar: Boolean, showEarDetection: Boolean, isMicrophone: Boolean, showMicrophone: Boolean, chargingLabel: String, + chargingOptimizedLabel: String, inEarLabel: String, microphoneLabel: String, modifier: Modifier = Modifier, @@ -75,11 +77,16 @@ fun StatusChipRow( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - if (isCharging) { - StatusChip( + when (chargingState) { + AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip( + icon = Icons.TwoTone.BatteryChargingFull, + label = chargingOptimizedLabel, + ) + AapPodState.ChargingState.CHARGING -> StatusChip( icon = Icons.TwoTone.BatteryChargingFull, label = chargingLabel, ) + else -> Unit } if (showMicrophone && isMicrophone) { StatusChip( @@ -106,12 +113,29 @@ private fun StatusChipChargingPreview() = PreviewWrapper { @Composable private fun StatusChipRowAllPreview() = PreviewWrapper { StatusChipRow( - isCharging = true, + chargingState = AapPodState.ChargingState.CHARGING, isInEar = true, showEarDetection = true, isMicrophone = true, showMicrophone = true, chargingLabel = "Charging", + chargingOptimizedLabel = "Optimized", + inEarLabel = "In Ear", + microphoneLabel = "Mic", + ) +} + +@Preview2 +@Composable +private fun StatusChipRowOptimizedPreview() = PreviewWrapper { + StatusChipRow( + chargingState = AapPodState.ChargingState.CHARGING_OPTIMIZED, + isInEar = false, + showEarDetection = true, + isMicrophone = true, + showMicrophone = true, + chargingLabel = "Charging", + chargingOptimizedLabel = "Optimized", inEarLabel = "In Ear", microphoneLabel = "Mic", ) 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 bbd05fc7..42f8b457 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 @@ -85,6 +85,7 @@ data class PodDevice( val hasEarDetection: Boolean get() = model.features.hasEarDetection val hasAncControl: Boolean get() = model.features.hasAncControl val hasDualMicrophone: Boolean get() = ble is HasDualMicrophone + val hasDynamicEndOfCharge: Boolean get() = model.features.hasDynamicEndOfCharge // Signal / timing val seenLastAt: Instant? @@ -186,6 +187,15 @@ data class PodDevice( val isHeadsetBeingCharged: Boolean? get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging + // Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED). + // Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip + // after the device went out of range. Existing isLeftCharging/etc. remain the Boolean + // collapse of CHARGING + CHARGING_OPTIMIZED for everyone who just cares "is it charging". + val leftPodChargingState: AapPodState.ChargingState? get() = aap?.leftChargingState + val rightPodChargingState: AapPodState.ChargingState? get() = aap?.rightChargingState + val caseChargingState: AapPodState.ChargingState? get() = aap?.caseChargingState + val headsetChargingState: AapPodState.ChargingState? get() = aap?.headsetChargingState + // Resolved primary pod: AAP cmd 0x08 preferred, BLE bit 5 fallback. private val resolvedPrimaryPod: DualBlePodSnapshot.Pod? get() = aap?.aapPrimaryPod?.pod?.let { aapPod -> @@ -337,6 +347,9 @@ data class PodDevice( val sleepDetection: AapSetting.SleepDetection? get() = aap?.setting() + val dynamicEndOfCharge: AapSetting.DynamicEndOfCharge? + get() = aap?.setting() + val connectedDevices: AapSetting.ConnectedDevices? get() = aap?.setting() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index f7f7f7c6..0d23321f 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -232,6 +232,7 @@ enum class PodModel( hasAllowOffOption = true, hasStemConfig = true, hasSleepDetection = true, + hasDynamicEndOfCharge = true, ), modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones leftPodIconRes = R.drawable.device_airpods_pro2_left, @@ -558,5 +559,12 @@ enum class PodModel( val hasAllowOffOption: Boolean = false, val hasStemConfig: Boolean = false, val hasSleepDetection: Boolean = false, + /** + * Apple's "Optimized Charge Limit" (AAP setting 0x3B). Distinct from the older + * "Optimized Battery Charging" — that earlier feature isn't exposed as a toggleable + * AAP setting. Enable on any model that's been confirmed (via capture) to push 0x3B + * on connect and accept writes to it. + */ + val hasDynamicEndOfCharge: Boolean = false, ) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt index 0dbbe124..aa7304db 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt @@ -68,6 +68,17 @@ class AapConnectionManager @Inject constructor( private val _offRejectedEvents = MutableSharedFlow(extraBufferCapacity = 16) val offRejectedEvents: SharedFlow = _offRejectedEvents.asSharedFlow() + /** + * Emits when any setting command failed verification on the device side. Unlike + * [offRejectedEvents] this covers every rejected write (including the ANC-OFF case); UI + * consumers filter by the command type they care about — e.g. the charge-cap toggle shows + * a snackbar only for [AapCommand.SetDynamicEndOfCharge]. + */ + private val _settingRejectedEvents = + MutableSharedFlow>(extraBufferCapacity = 16) + val settingRejectedEvents: SharedFlow> = + _settingRejectedEvents.asSharedFlow() + fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] } suspend fun connect( @@ -119,6 +130,14 @@ class AapConnectionManager @Inject constructor( } } + // Forward generic setting-rejection events from this connection (child coroutine). + // Covers every rejected write — consumers filter by the command they care about. + launch { + connection.settingRejected.collect { command -> + _settingRejectedEvents.tryEmit(address to command) + } + } + connection.state.collect { podState -> if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) { log(TAG) { "Connection to $address disconnected" } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt index 8aef76a0..2362c454 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt @@ -53,6 +53,13 @@ data class AapPodState( val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent + // Raw charging state per slot — the full enum, not collapsed to a Boolean. Lets the UI + // distinguish CHARGING_OPTIMIZED ("Optimized Charge Limit in effect") from plain CHARGING. + val leftChargingState: ChargingState? get() = batteries[BatteryType.LEFT]?.charging + val rightChargingState: ChargingState? get() = batteries[BatteryType.RIGHT]?.charging + val caseChargingState: ChargingState? get() = batteries[BatteryType.CASE]?.charging + val headsetChargingState: ChargingState? get() = batteries[BatteryType.SINGLE]?.charging + // Charging state from AAP battery val isLeftCharging: Boolean? get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt index ace5f5be..da3b4ee3 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt @@ -46,6 +46,7 @@ internal class AapConnection( val keysReceived: SharedFlow get() = engine.keysReceived val stemPressEvents: SharedFlow get() = engine.stemPressEvents val offRejected: SharedFlow get() = engine.offRejected + val settingRejected: SharedFlow get() = engine.settingRejected private var socket: BluetoothSocket? = null private var readerJob: Job? = null diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt index 7feaaf12..cdcda4e6 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt @@ -35,7 +35,11 @@ internal class AapOutboundController( runtimeState: OutboundRuntimeState, command: AapCommand, ): OutboundDecision { - if (command !is AapCommand.SetDeviceName) { + // SetDeviceName and SetDynamicEndOfCharge bypass ear-gating: + // - Rename is a user-initiated metadata change, independent of wear state. + // - Charge cap (setting 0x3B) is toggled while pods sit in the closed case; queueing + // it until worn would make the toggle look broken for its main use case. + if (command !is AapCommand.SetDeviceName && command !is AapCommand.SetDynamicEndOfCharge) { val earDetection = podState.setting() if (earDetection != null && !earDetection.isEitherPodInEar) { val result = coordinator.enqueue(runtimeState.pendingCommands, command, podState) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index fee3ca50..43638342 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -53,6 +53,16 @@ internal class AapSessionEngine( MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val offRejected: SharedFlow = _offRejected.asSharedFlow() + /** + * Fires whenever a write command fails verification after the coordinator's single retry — + * i.e. the device neither echoed the expected state nor retried into a matching one. + * Separate from [offRejected] which is specialised for the ANC-OFF UX path. Consumers filter + * by the command type they care about (e.g. the charge-cap toggle shows a snackbar). + */ + private val _settingRejected = + MutableSharedFlow(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val settingRejected: SharedFlow = _settingRejected.asSharedFlow() + private val hidTracker = HidTracker { msg -> log(TAG) { msg } } private val inboundInterpreter = AapInboundInterpreter(profile) private val ancController = AapAncController() @@ -419,10 +429,12 @@ internal class AapSessionEngine( } private fun handleRejectedCommand(command: AapCommand?) { + if (command == null) return if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) { applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc)) _offRejected.tryEmit(Unit) } + _settingRejected.tryEmit(command) } private fun scheduleTimer(key: EngineTimerKey, delayMs: Long) { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt index 8a2bc240..bbd26a54 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt @@ -172,6 +172,10 @@ internal class AapSettingsCoordinator( AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled) } + is AapCommand.SetDynamicEndOfCharge -> { + AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = command.enabled) + } + is AapCommand.SetDeviceName -> { val currentInfo = baseState.deviceInfo ?: return null return baseState.copy( @@ -205,6 +209,7 @@ internal class AapSettingsCoordinator( is AapCommand.SetAllowOffOption -> { s -> s.setting()?.enabled == command.enabled } is AapCommand.SetStemConfig -> { s -> s.setting()?.claimedPressMask == command.claimedPressMask } is AapCommand.SetSleepDetection -> { s -> s.setting()?.enabled == command.enabled } + is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting()?.enabled == command.enabled } is AapCommand.SetDeviceName -> null } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt index 17d15e7f..66cbc30f 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt @@ -34,5 +34,6 @@ sealed class AapCommand { data class SetAllowOffOption(val enabled: Boolean) : AapCommand() data class SetStemConfig(val claimedPressMask: Int) : AapCommand() data class SetSleepDetection(val enabled: Boolean) : AapCommand() + data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand() data class SetDeviceName(val name: String) : AapCommand() } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt index de30514e..d2e115d5 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapSetting.kt @@ -146,6 +146,15 @@ sealed class AapSetting { val enabled: Boolean, ) : AapSetting() + /** + * Apple's "Optimized Charge Limit" — AAP setting 0x3B, Apple-bool encoding. Distinct + * from the older "Optimized Battery Charging" which Apple doesn't expose as a + * user-controllable AAP setting. Supported models push this value on connect. + */ + data class DynamicEndOfCharge( + val enabled: Boolean, + ) : AapSetting() + data class InCaseTone( val enabled: Boolean, ) : AapSetting() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt index caaeede8..adc5fff8 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt @@ -33,7 +33,6 @@ class DefaultAapDeviceProfile( AapControlId.HEARING_ASSIST.value, // 0x33 AapControlId.HEARING_PROTECTION_PPE.value, // 0x37 AapControlId.PPE_CAP_LEVEL_CONFIG.value, // 0x38 - AapControlId.DYNAMIC_END_OF_CHARGE.value, // 0x3B AapControlId.UPLINK_EQ_BUD.value, // 0x3E ) @@ -99,6 +98,7 @@ class DefaultAapDeviceProfile( is AapCommand.SetAllowOffOption -> buildSettingsMessage(AapControlId.ALLOW_OFF_OPTION.value, encodeAppleBool(command.enabled)) is AapCommand.SetStemConfig -> buildSettingsMessage(AapControlId.RAW_GESTURES_CONFIG.value, command.claimedPressMask and 0x0F) is AapCommand.SetSleepDetection -> buildSettingsMessage(AapControlId.SLEEP_DETECTION.value, encodeAppleBool(command.enabled)) + is AapCommand.SetDynamicEndOfCharge -> buildSettingsMessage(AapControlId.DYNAMIC_END_OF_CHARGE.value, encodeAppleBool(command.enabled)) is AapCommand.SetDeviceName -> buildRenameMessage(command.name) } @@ -263,6 +263,14 @@ class DefaultAapDeviceProfile( val enabled = decodeAppleBool(value) ?: return null AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled) } + AapControlId.DYNAMIC_END_OF_CHARGE.value -> { + // Apple's "Optimized Charge Limit" — Pro 3 pushes this on connect as value 0x01 + // (enabled). decodeAppleBool rejects anything that isn't a confirmed bool so + // unknown encodings fall through to UnknownSetting logging rather than being + // coerced to false. + val enabled = decodeAppleBool(value) ?: return null + AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled) + } AapControlId.IN_CASE_TONE.value -> { // Decoded internally (never exposed in UI) to keep lastMessageAt fresh. // Originally labeled "Charging Sounds" — the real case tones are controlled diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 38877630..85e17093 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -283,6 +283,7 @@ This is an unknown device, but it is using similar message format. Let\'s add support for it, contact me :) No device Charging + Optimized In ear Microphone Off @@ -544,6 +545,10 @@ Bluetooth Settings Could not apply setting: %1$s Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control. + Battery + Optimized Charge Limit + Learn your routine and pause charging around 80%% to extend battery life, topping the pods off before you\'re likely to use them. + Optimized Charge Limit couldn\'t be changed. This is an experimental feature — please report if it keeps failing. Connected Devices Other devices currently connected to these AirPods Device %d diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt index 00a4d8a9..fb2596d9 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt @@ -117,6 +117,20 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { @Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x35, 0x00)).shouldBeNull() } } + // ── Dynamic End of Charge / "Optimized Charge Limit" (0x3B) ─ + // Apple-bool wire semantics matching every other boolean setting we've decoded. Real + // capture on a Pro 3 after handshake showed rawValue 0x01 (enabled). + + @Nested + inner class DynamicEndOfChargeTests { + @Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetDynamicEndOfCharge(true))[7] shouldBe 0x01.toByte() } + @Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetDynamicEndOfCharge(false))[7] shouldBe 0x02.toByte() } + @Test fun `encode carries the right setting id`() { profile.encodeCommand(AapCommand.SetDynamicEndOfCharge(true))[6] shouldBe 0x3B.toByte() } + @Test fun `decode enabled`() { decodeSetting(settingsMessage(0x3B, 0x01)).enabled shouldBe true } + @Test fun `decode disabled`() { decodeSetting(settingsMessage(0x3B, 0x02)).enabled shouldBe false } + @Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x3B, 0x00)).shouldBeNull() } + } + // ── In-Case Tone (0x31) ───────────────────────────────── // Decode path is kept internally even though the setting is no longer exposed in the UI. // See the IN_CASE_TONE branch in DefaultAapDeviceProfile.decodeSetting for rationale. diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt index 28c2ea0c..3df581d3 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt @@ -171,6 +171,28 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { @Test fun `NC one airpod OFF`() { decodeSetting("04 00 04 00 09 00 1B 02 00 00 00").enabled shouldBe false } + + // DynamicEndOfCharge — Apple's "Optimized Charge Limit", setting 0x3B. Real capture + // from a Pro 3 after handshake showed rawValue=0x01 (enabled). Apple-bool semantics + // are our current assumption; decodeAppleBool rejects values that aren't 0x01/0x02. + @Test fun `dynamic end of charge ON - observed on connect`() { + decodeSetting("04 00 04 00 09 00 3B 01 00 00 00").enabled shouldBe true + } + + @Test fun `dynamic end of charge OFF`() { + decodeSetting("04 00 04 00 09 00 3B 02 00 00 00").enabled shouldBe false + } + + @Test fun `dynamic end of charge - unknown raw value is not decoded as bool`() { + // 0x00 isn't Apple-bool — decoder must fall through (return null) instead of + // silently coercing to false. This keeps us honest about the wire format and + // lets the unhandled-setting path log the raw bytes for follow-up. + profile.decodeSetting( + eu.darken.capod.pods.core.apple.aap.protocol.AapPacket.Message.parse( + byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x3B, 0x00, 0x00, 0x00, 0x00) + )!! + ).shouldBeNull() + } } // ── ANC Mode Switching (verified audible) ──────────────── @@ -252,7 +274,9 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { @Test fun `unconfirmed settings IDs decode as UnknownSetting`() { - val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B) + // 0x3B (DYNAMIC_END_OF_CHARGE) was previously on this list but has since been + // promoted to the DynamicEndOfCharge decoder — see DynamicEndOfCharge tests above. + val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38) for (id in unconfirmedIds) { val setting = decodeSetting(settingsMessage(id, 0x01)) setting.settingId shouldBe id diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index 9bebdb10..f8b5d894 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -280,6 +280,27 @@ class AapSessionEngineTest : BaseTest() { engineWithEar.state.value.pendingSettingsCount shouldBe 1 } + @Test + fun `send bypasses ear gate for SetDynamicEndOfCharge`() = runTest(UnconfinedTestDispatcher()) { + // Unlike most settings, the charge-cap toggle is used while pods sit in the + // closed case. Queueing it until worn would make the toggle look broken. + val profile = mockProfile { + every { decodeSetting(any()) } returns (AapSetting.EarDetection::class as KClass to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + )) + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + engine.processMessage(dummyMessage()) // Set ear detection to IN_CASE + + val sentCommands = mutableListOf() + engine.send(AapCommand.SetDynamicEndOfCharge(false)) { sentCommands.add(it) } + + sentCommands shouldBe listOf(AapCommand.SetDynamicEndOfCharge(false)) + engine.state.value.pendingSettingsCount shouldBe 0 + } + @Test fun `send immediate when pod in ear`() = runTest(UnconfinedTestDispatcher()) { val profile = mockProfile {