mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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
|
||||
|
||||
+5
-1
@@ -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()
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+14
@@ -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.
|
||||
|
||||
+25
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user