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
This commit is contained in:
darken
2026-04-24 17:27:48 +02:00
committed by Matthias Urhahn
parent 89e8b83be4
commit 59b3106c3b
21 changed files with 327 additions and 15 deletions
@@ -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),
),
),
),
@@ -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<Event>()
@@ -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 {
@@ -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,
)
}
@@ -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
@@ -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(
@@ -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",
)
@@ -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()
@@ -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,
)
}
@@ -68,6 +68,17 @@ class AapConnectionManager @Inject constructor(
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val offRejectedEvents: SharedFlow<BluetoothAddress> = _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<Pair<BluetoothAddress, AapCommand>>(extraBufferCapacity = 16)
val settingRejectedEvents: SharedFlow<Pair<BluetoothAddress, AapCommand>> =
_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" }
@@ -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 }
@@ -46,6 +46,7 @@ internal class AapConnection(
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
val offRejected: SharedFlow<Unit> get() = engine.offRejected
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
private var socket: BluetoothSocket? = null
private var readerJob: Job? = null
@@ -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<AapSetting.EarDetection>()
if (earDetection != null && !earDetection.isEitherPodInEar) {
val result = coordinator.enqueue(runtimeState.pendingCommands, command, podState)
@@ -53,6 +53,16 @@ internal class AapSessionEngine(
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val offRejected: SharedFlow<Unit> = _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<AapCommand>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val settingRejected: SharedFlow<AapCommand> = _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) {
@@ -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<AapSetting.AllowOffOption>()?.enabled == command.enabled }
is AapCommand.SetStemConfig -> { s -> s.setting<AapSetting.StemConfig>()?.claimedPressMask == command.claimedPressMask }
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting<AapSetting.DynamicEndOfCharge>()?.enabled == command.enabled }
is AapCommand.SetDeviceName -> null
}
}
@@ -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()
}
@@ -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()
@@ -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
+5
View File
@@ -283,6 +283,7 @@
<string name="pods_unknown_contact_dev">This is an unknown device, but it is using similar message format. Let\'s add support for it, contact me :)</string>
<string name="pods_none_label_short">No device</string>
<string name="pods_charging_label">Charging</string>
<string name="pods_charging_optimized_label">Optimized</string>
<string name="pods_inear_label">In ear</string>
<string name="pods_microphone_label">Microphone</string>
<string name="anc_mode_off">Off</string>
@@ -544,6 +545,10 @@
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth Settings</string>
<string name="device_settings_send_failed">Could not apply setting: %1$s</string>
<string name="device_settings_anc_off_rejected_message">Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control.</string>
<string name="device_settings_category_battery_label">Battery</string>
<string name="device_settings_charge_cap_label">Optimized Charge Limit</string>
<string name="device_settings_charge_cap_description">Learn your routine and pause charging around 80%% to extend battery life, topping the pods off before you\'re likely to use them.</string>
<string name="device_settings_charge_cap_rejected_message">Optimized Charge Limit couldn\'t be changed. This is an experimental feature — please report if it keeps failing.</string>
<string name="device_settings_category_connections_label">Connected Devices</string>
<string name="device_settings_connected_devices_description">Other devices currently connected to these AirPods</string>
<string name="device_settings_connected_device_label">Device %d</string>
@@ -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<AapSetting.DynamicEndOfCharge>(settingsMessage(0x3B, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<AapSetting.DynamicEndOfCharge>(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.
@@ -171,6 +171,28 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
@Test fun `NC one airpod OFF`() {
decodeSetting<AapSetting.NcWithOneAirPod>("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<AapSetting.DynamicEndOfCharge>("04 00 04 00 09 00 3B 01 00 00 00").enabled shouldBe true
}
@Test fun `dynamic end of charge OFF`() {
decodeSetting<AapSetting.DynamicEndOfCharge>("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<AapSetting.UnknownSetting>(settingsMessage(id, 0x01))
setting.settingId shouldBe id
@@ -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<out AapSetting> 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<AapCommand>()
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 {