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 d3b2c617..40009300 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 @@ -46,6 +46,7 @@ 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.BatteryHealthTexts import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard +import eu.darken.capod.main.ui.devicesettings.cards.CustomEqDebugCard import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard @@ -190,6 +191,7 @@ fun DeviceSettingsScreenHost( onOpenAapTracker = { vm.openAapCompatibilityTracker() }, onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) }, onResetBatteryEstimate = { vm.resetBatteryEstimate() }, + onCustomEqApply = { mode, low, mid, high -> vm.setCustomEq(mode, low, mid, high) }, ) } @@ -235,6 +237,7 @@ fun DeviceSettingsScreen( onOpenAapTracker: () -> Unit = {}, onBatteryEstimateEnabledChange: (Boolean) -> Unit = {}, onResetBatteryEstimate: () -> Unit = {}, + onCustomEqApply: (AapSetting.CustomEq.Mode, Int, Int, Int) -> Unit = { _, _, _, _ -> }, ) { val device = state.device val features = device?.model?.features @@ -518,6 +521,20 @@ fun DeviceSettingsScreen( } } } + + // Custom EQ evaluation control (debug only, opcode 0x63). The 0x63 wire format + // has never been confirmed on hardware, so this exists to find out whether a real + // device accepts it. Deliberately ungated by capability or model — gating on an + // unknown capability bit would defeat the test. + if (eu.darken.capod.BuildConfig.DEBUG) { + item("custom_eq_debug_section") { + CustomEqDebugCard( + device = device, + enabled = enabled, + onApply = onCustomEqApply, + ) + } + } } // Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last 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 bcfbbe46..9b99645c 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 @@ -347,6 +347,14 @@ class DeviceSettingsViewModel @Inject constructor( fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled)) + /** + * Debug-only evaluation control (see `CustomEqDebugCard`). One tap sends exactly one packet + * carrying the complete tuple — the 0x63 format is unconfirmed and the point is to observe + * how a real device answers a single write. + */ + fun setCustomEq(mode: AapSetting.CustomEq.Mode, low: Int, mid: Int, high: Int) = + send(AapCommand.SetCustomEq(mode, low, mid, high)) + 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/CustomEqDebugCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/CustomEqDebugCard.kt new file mode 100644 index 00000000..e71e04e2 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/CustomEqDebugCard.kt @@ -0,0 +1,214 @@ +package eu.darken.capod.main.ui.devicesettings.cards + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.GraphicEq +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import eu.darken.capod.common.compose.Preview2 +import eu.darken.capod.common.compose.PreviewWrapper +import eu.darken.capod.common.settings.SettingsSection +import eu.darken.capod.common.settings.SettingsSliderItem +import eu.darken.capod.main.ui.devicesettings.components.SegmentedSettingRow +import eu.darken.capod.main.ui.devicesettings.previewFullState +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting + +/** + * Debug-only evaluation control for Custom EQ (opcode 0x63), whose wire format has never been + * confirmed on hardware. It is a measurement instrument, not a shipped feature: the sliders and + * the mode toggle edit local draft state only, and exactly one packet leaves the device per + * Apply tap, so the logcat observation window stays readable. + * + * Every label below is hardcoded English on purpose. This card is throwaway instrumentation that + * only renders under [eu.darken.capod.BuildConfig.DEBUG]; routing its labels through the base + * locale would push a dozen strings to Crowdin and have translators work through them for every + * locale, for text no release build can ever show. + */ +@Composable +internal fun CustomEqDebugCard( + device: PodDevice, + enabled: Boolean, + onApply: (AapSetting.CustomEq.Mode, Int, Int, Int) -> Unit = { _, _, _, _ -> }, +) { + val reported = device.customEq + + // AapOutboundController ear-gates every command except SetDeviceName and SetDynamicEndOfCharge: + // with no pod in ear the write is queued and only flushed on the next in-ear event, so the + // packet would surface minutes later at an unrelated moment and poison the logcat window this + // card exists to produce. + // + // The controller reads the AAP EarDetection setting alone and does not gate while that setting + // is absent, so this mirror has to gate on the same source: hasAapEarDetection makes + // isEitherPodInEar return the AAP value without ever falling back to the BLE ear bits, which + // phantom-report "in ear" for pods resting in the case. + val wouldQueue = device.hasAapEarDetection && device.isEitherPodInEar != true + + var draftMode by remember(reported) { + mutableStateOf(reported?.mode ?: AapSetting.CustomEq.Mode.RECOMMENDED) + } + var draftLow by remember(reported) { mutableIntStateOf(reported?.low ?: NEUTRAL_BAND) } + var draftMid by remember(reported) { mutableIntStateOf(reported?.mid ?: NEUTRAL_BAND) } + var draftHigh by remember(reported) { mutableIntStateOf(reported?.high ?: NEUTRAL_BAND) } + + SettingsSection(title = "Custom EQ") { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = "Reported by device", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (reported != null) { + bandsText(reported.mode, reported.low, reported.mid, reported.high) + } else { + "Not reported" + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = "Draft to send", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = bandsText(draftMode, draftLow, draftMid, draftHigh), + style = MaterialTheme.typography.bodyMedium, + ) + } + + SegmentedSettingRow( + icon = Icons.TwoTone.GraphicEq, + title = "Mode", + options = AapSetting.CustomEq.Mode.entries.map { it.label to it }, + selected = draftMode, + onSelected = { draftMode = it }, + enabled = enabled, + ) + + BandSlider( + title = "Low", + value = draftLow, + onValueChange = { draftLow = it }, + enabled = enabled, + ) + BandSlider( + title = "Mid", + value = draftMid, + onValueChange = { draftMid = it }, + enabled = enabled, + ) + BandSlider( + title = "High", + value = draftHigh, + onValueChange = { draftHigh = it }, + enabled = enabled, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (wouldQueue) { + Text( + text = NOT_IN_EAR_NOTICE, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + ) + } + Button( + onClick = { onApply(draftMode, draftLow, draftMid, draftHigh) }, + enabled = enabled && !wouldQueue, + ) { + Text("Apply") + } + } + } +} + +@Composable +private fun BandSlider( + title: String, + value: Int, + onValueChange: (Int) -> Unit, + enabled: Boolean, +) { + SettingsSliderItem( + icon = Icons.TwoTone.GraphicEq, + title = title, + value = value.toFloat(), + // Draft only — dispatching from here (or from onValueChangeFinished) would flood the link + // with intermediate tuples. The Apply button is the sole sender. + onValueChange = { onValueChange(it.toInt()) }, + valueRange = 0f..100f, + steps = 99, + enabled = enabled, + valueLabel = { it.toInt().toString() }, + ) +} + +private const val NEUTRAL_BAND = 50 + +internal const val NOT_IN_EAR_NOTICE = + "No pod in ear, a write would be queued instead of sent. Wear a pod before applying." + +private fun bandsText(mode: AapSetting.CustomEq.Mode, low: Int, mid: Int, high: Int): String = + "${mode.label} · low $low / mid $mid / high $high" + +private val AapSetting.CustomEq.Mode.label: String + get() = when (this) { + AapSetting.CustomEq.Mode.RECOMMENDED -> "Recommended" + AapSetting.CustomEq.Mode.CUSTOM -> "Custom" + } + +@Preview2 +@Composable +private fun CustomEqDebugCardNotReportedPreview() = PreviewWrapper { + CustomEqDebugCard( + device = previewFullState(isPro = true).device!!, + enabled = true, + ) +} + +@Preview2 +@Composable +private fun CustomEqDebugCardReportedPreview() = PreviewWrapper { + val device = previewFullState(isPro = true).device!! + CustomEqDebugCard( + device = device.copy( + aap = device.aap!!.withSetting( + AapSetting.CustomEq::class, + AapSetting.CustomEq( + mode = AapSetting.CustomEq.Mode.CUSTOM, + low = 60, + mid = 50, + high = 35, + ), + ), + ), + enabled = true, + ) +} 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 df49a04b..731d8831 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 @@ -408,6 +408,9 @@ data class PodDevice( val pmeConfig: AapSetting.PmeConfig? get() = aap?.setting() + val customEq: AapSetting.CustomEq? + get() = aap?.setting() + val deviceInfo: AapDeviceInfo? get() = aap?.deviceInfo ?: cached?.deviceInfo 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 bbd26a54..43174981 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 @@ -176,6 +176,11 @@ internal class AapSettingsCoordinator( AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = command.enabled) } + // No optimistic update: an optimistic update would render the UI as though an + // unacknowledged write had succeeded, which would actively falsify the on-device + // evaluation this command exists to support. + is AapCommand.SetCustomEq -> return null + is AapCommand.SetDeviceName -> { val currentInfo = baseState.deviceInfo ?: return null return baseState.copy( @@ -211,5 +216,9 @@ internal class AapSettingsCoordinator( is AapCommand.SetSleepDetection -> { s -> s.setting()?.enabled == command.enabled } is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting()?.enabled == command.enabled } is AapCommand.SetDeviceName -> null + // Verification compares against a device-reported setting, and we have no evidence the + // device reports this one at all. A predicate here would manufacture spurious + // divergence-detected churn on every write. + is AapCommand.SetCustomEq -> 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 66cbc30f..0b29a955 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 @@ -36,4 +36,16 @@ sealed class AapCommand { data class SetSleepDetection(val enabled: Boolean) : AapCommand() data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand() data class SetDeviceName(val name: String) : AapCommand() + data class SetCustomEq( + val mode: AapSetting.CustomEq.Mode, + val low: Int, + val mid: Int, + val high: Int, + ) : AapCommand() { + init { + require(low in 0..100) { "low band out of range: $low" } + require(mid in 0..100) { "mid band out of range: $mid" } + require(high in 0..100) { "high band out of range: $high" } + } + } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt index 615af932..4be4dbfd 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessageType.kt @@ -101,8 +101,10 @@ enum class AapMessageType(val value: Int, val wiresharkName: String) { /** * PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment) — - * hearing-aid configuration for the iOS 18.1+ hearing-aid feature on AirPods - * Pro 2. Decoded as 4 × 8 Float32 values (see [AapSetting.PmeConfig]); see + * the **Headphone Accommodations** configuration, an iOS Accessibility feature + * with its own "Apply To: Phone / Media" toggles, which the iOS 18.1+ hearing-aid + * feature reuses. Not exclusively the hearing-aid audiogram. Decoded as the two + * apply-to flags plus 4 × 8 Float32 band gains (see [AapSetting.PmeConfig]); see * that data class for the layout rationale. "PME Config" is the label the * Wireshark AAP dissector uses for this opcode. */ @@ -125,6 +127,18 @@ enum class AapMessageType(val value: Int, val wiresharkName: String) { UNKNOWN_0X58(0x0058, "Unknown"), DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"), PERSONAL_TRANSLATION(0x0060, "Personal Translation"), + + /** + * Custom EQ — Apple's iOS 27 equalizer feature (announced WWDC 2026) for the H2 models + * (AirPods Pro 3, AirPods Pro 2, AirPods 4). Three bands (low / mid / high) plus a + * Recommended / Custom mode selector; see [AapSetting.CustomEq]. + * + * The wire format is sourced from librepods commit `7341e41` and is **unverified on real + * hardware** — no capture from any device we own has ever carried this opcode. Both the + * decoder and the encoder are written to fail loudly rather than guess (see + * [DefaultAapDeviceProfile]). + */ + CUSTOM_EQ(0x0063, "Custom EQ"), ; companion object { 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 d24a9ad4..5931f6f5 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 @@ -183,25 +183,58 @@ sealed class AapSetting { /** * Payload of message type 0x0053 — "PME Config" in the Wireshark AAP dissector. - * PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment): - * the hearing-aid configuration for Apple's iOS 18.1+ hearing-aid feature on - * AirPods Pro 2. + * PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment). * - * Decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile - * audiogram band gains (e.g. L/R × two environment profiles, 8 frequency - * bands). CAPod previously called this "EQ bands". + * This is the **Headphone Accommodations** configuration — an iOS Accessibility + * feature with its own "Apply To: Phone / Media" toggles, which the iOS 18.1+ + * hearing-aid feature reuses. It is not exclusively the hearing-aid audiogram. * - * Callers should treat all-zero [sets] as "no hearing-aid profile configured" - * — stock firmware reports zeros until the user runs Apple's Hearing Test / - * hearing-aid setup. + * [sets] is decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile + * band gains (e.g. L/R × two environment profiles, 8 frequency bands). CAPod + * previously called this "EQ bands". + * + * [applyToMedia] and [applyToPhone] mirror the two "Apply To" checkboxes. They + * describe **scope only** — which audio the accommodation is applied to. They say + * nothing about whether a profile exists: both can be false while band data is + * stored. [isAllZero] is likewise a pure band-data predicate; all-zero gains may + * be flat values rather than an absent profile. Stock firmware does report zeros + * before the user runs Apple's Hearing Test / Headphone Accommodations setup. */ data class PmeConfig( val sets: List>, + val applyToMedia: Boolean, + val applyToPhone: Boolean, ) : AapSetting() { val isAllZero: Boolean get() = sets.all { set -> set.all { it == 0f } } } + /** + * Payload of message type 0x0063 — Apple's iOS 27 "Custom EQ" (see [AapMessageType.CUSTOM_EQ]). + * + * [low], [mid] and [high] are the three band gains, `0..100` with `50` as the neutral + * (no-gain) position. [mode] selects between Apple's recommended curve and the user's + * own band values. + * + * The layout comes from librepods commit `7341e41` and has never been seen on hardware + * we own — treat any decoded value as unconfirmed. + */ + data class CustomEq( + val mode: Mode, + val low: Int, + val mid: Int, + val high: Int, + ) : AapSetting() { + enum class Mode(val wireValue: Int) { + RECOMMENDED(0x01), + CUSTOM(0x02); + + companion object { + fun fromWire(value: Int): Mode? = entries.firstOrNull { it.wireValue == value } + } + } + } + /** Per-pod placement reported by the device (command 0x06). */ data class EarDetection( val primaryPod: PodPlacement, 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 0a547229..28debf3b 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 @@ -100,6 +100,7 @@ class DefaultAapDeviceProfile( 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) + is AapCommand.SetCustomEq -> buildCustomEqMessage(command.mode, command.low, command.mid, command.high) } override fun decodeSetting(message: AapMessage): Pair, AapSetting>? { @@ -159,13 +160,19 @@ class DefaultAapDeviceProfile( } // 0x53 is "PME Config" per the Wireshark AAP dissector — Personal Medical - // Equipment (cf. PPE), i.e. the iOS 18.1+ hearing-aid profile on AirPods - // Pro 2. Decoded verbatim as 4 × 8 Float32 (per-ear × per-profile band - // gains); stock firmware reports all-zero until the user runs Apple's - // Hearing Test. 0x54 "Set Band Edges" is a neighbouring opcode with a - // different payload — not decoded here. + // Equipment (cf. PPE), i.e. the Headphone Accommodations configuration that + // the iOS 18.1+ hearing-aid feature reuses. Payload bytes 4 and 5 are the two + // "Apply To" scope flags (Media, Phone); the band gains follow at offset 6, + // decoded verbatim as 4 × 8 Float32 (per-ear × per-profile). Stock firmware + // reports all-zero gains until the user runs Apple's Hearing Test. 0x54 + // "Set Band Edges" is a neighbouring opcode with a different payload — not + // decoded here. if (message.commandType == AapMessageType.PME_CONFIG.value) { if (message.payload.size < 6 + 128) return null + // Plain 0x01 flags, NOT the Apple-bool 0x01/0x02 encoding used by the + // 0x09 control settings — don't route these through decodeAppleBool. + val applyToMedia = (message.payload[4].toInt() and 0xFF) == 0x01 + val applyToPhone = (message.payload[5].toInt() and 0xFF) == 0x01 val sets = mutableListOf>() var offset = 6 // skip header for (s in 0 until 4) { @@ -180,7 +187,11 @@ class DefaultAapDeviceProfile( } sets.add(bands) } - return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets) + return AapSetting.PmeConfig::class to AapSetting.PmeConfig( + sets = sets, + applyToMedia = applyToMedia, + applyToPhone = applyToPhone, + ) } // Conversation Awareness State is a separate command type (push-only). Two payload shapes @@ -206,6 +217,33 @@ class DefaultAapDeviceProfile( AapSetting.ConversationalAwarenessState(speaking, rawValue = status) } + // Custom EQ (0x63, iOS 27 / H2 models). Payload — everything after the 4-byte AAP + // packet-type/service header and the 2-byte opcode — is `05 00 01 `: + // bytes 0-1 are a little-endian declared length (5), byte 2 is a marker whose purpose is + // unidentified, byte 3 is the mode, bytes 4-6 are the three band values (0..100). + // + // Every field is validated and ANY mismatch returns null, which routes the frame to the + // engine's existing unknown-message logging (that prints full hex). We have never seen this + // opcode on hardware — the format is librepods `7341e41` hearsay — so on a firmware whose + // 0x63 dialect differs, preserving the unrecognised frame verbatim in the log beats + // mis-parsing it into plausible-looking values. The size check is deliberately an equality: + // `>= 7` would silently swallow trailing unknown bytes, which is exactly the dialect + // variation we want surfaced. Same shape-validation rationale as the 0x4B branch above. + if (message.commandType == AapMessageType.CUSTOM_EQ.value) { + val p = message.payload + if (p.size < 2) return null + val declaredLength = (p[0].toInt() and 0xFF) or ((p[1].toInt() and 0xFF) shl 8) + if (declaredLength != 5) return null + if (p.size != 2 + declaredLength) return null + if ((p[2].toInt() and 0xFF) != 0x01) return null + val mode = AapSetting.CustomEq.Mode.fromWire(p[3].toInt() and 0xFF) ?: return null + val low = p[4].toInt() and 0xFF + val mid = p[5].toInt() and 0xFF + val high = p[6].toInt() and 0xFF + if (low !in 0..100 || mid !in 0..100 || high !in 0..100) return null + return AapSetting.CustomEq::class to AapSetting.CustomEq(mode, low, mid, high) + } + if (message.commandType != AapMessageType.CONTROL.value) return null if (message.payload.size < 2) return null @@ -569,6 +607,34 @@ class DefaultAapDeviceProfile( ) + nameBytes } + private fun buildCustomEqMessage( + mode: AapSetting.CustomEq.Mode, + low: Int, + mid: Int, + high: Int, + ): ByteArray { + // Uses the opcode 0x63 format from librepods commit 7341e41. Unlike the settings writes + // above this is NOT a 0x09 control command — it carries its own message shape, so it can't + // go through buildSettingsMessage. + // + // Layout: header `04 00 04 00`, opcode `63 00`, little-endian declared length `05 00`, + // then `01` — a marker byte whose purpose is unidentified, carried verbatim because + // librepods sends it — the mode, and the three band values (0..100, 50 neutral). + // + // On-device acceptance is UNVERIFIED at time of writing: no device we own has ever emitted + // or acknowledged 0x63, and librepods itself never confirmed the format works. Nothing here + // assumes the write lands — there is deliberately no optimistic update and no verification + // predicate (see AapSettingsCoordinator). + return byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x63, 0x00, + 0x05, 0x00, + 0x01, + mode.wireValue.toByte(), + low.toByte(), mid.toByte(), high.toByte(), + ) + } + /** * Heuristic binary-prefix skip: walk forward until we hit a printable byte. * The real header schema is `[02 XX 00 04 00]` in every capture to date, but diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index 78c4edc1..9ae202ce 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -19,6 +19,7 @@ import eu.darken.capod.monitor.core.battery.DrainProfile import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo @@ -307,6 +308,23 @@ class DeviceSettingsViewModelTest : BaseTest() { coVerify { aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName")) } } + @Test + fun `setCustomEq sends exactly one SetCustomEq carrying the drafted tuple`() = runVmTest { + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.setCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 55, high = 90) + + coVerify(exactly = 1) { + aapManager.sendCommand( + testAddress, + AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 55, high = 90), + ) + } + coVerify(exactly = 1) { aapManager.sendCommand(any(), any()) } + } + @Test fun `setDeviceName when no target address is a no-op`() = runVmTest { val vm = createViewModel() diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/CustomEqDebugCardTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/CustomEqDebugCardTest.kt new file mode 100644 index 00000000..c10bf388 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/CustomEqDebugCardTest.kt @@ -0,0 +1,111 @@ +package eu.darken.capod.main.ui.devicesettings.cards + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import eu.darken.capod.common.compose.PreviewWrapper +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import io.kotest.matchers.shouldBe +import org.junit.Test +import testhelpers.compose.BaseComposeRobolectricTest + +/** + * AapOutboundController queues every ear-gated command while no pod is worn, and the settings + * coordinator collapses repeated ones to the latest tuple, so a tap made with the pods out would + * emit its packet at some unrelated later moment. That destroys the logcat observation window this + * debug card exists to produce, hence Apply has to be unavailable while a write would be queued. + * + * The controller only gates when the AAP EarDetection setting is present, so with no such report + * the write goes out immediately and Apply must stay available. + */ +class CustomEqDebugCardTest : BaseComposeRobolectricTest() { + + private val applyButton = hasText("Apply") and hasClickAction() + + private var applies = 0 + + private fun device(earDetection: AapSetting.EarDetection?) = PodDevice( + profileId = "test", + ble = null, + aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = earDetection?.let { mapOf(AapSetting.EarDetection::class to it) } ?: emptyMap(), + ), + ) + + private fun setContent(earDetection: AapSetting.EarDetection?) { + composeRule.setContent { + PreviewWrapper { + // Scrollable, because the card is taller than the test window and the Apply row + // sits at its bottom. Without a scroll the taps would land off-screen and every + // "no command was sent" assertion would pass for the wrong reason. + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + CustomEqDebugCard( + device = device(earDetection), + enabled = true, + onApply = { _, _, _, _ -> applies++ }, + ) + } + } + } + } + + private fun clickApply() { + composeRule.onNode(applyButton).performScrollTo().performClick() + } + + @Test + fun `apply is available while a pod is in ear`() { + setContent( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + ) + + composeRule.onNode(applyButton).assertIsEnabled() + composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertDoesNotExist() + + clickApply() + composeRule.runOnIdle { applies shouldBe 1 } + } + + @Test + fun `apply is unavailable with both pods out, with a notice next to the button`() { + setContent( + AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + ) + + composeRule.onNode(applyButton).assertIsNotEnabled() + composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertExists() + + clickApply() + composeRule.runOnIdle { applies shouldBe 0 } + } + + @Test + fun `apply is available while no ear detection is reported at all`() { + // No 0x06 report yet, so AapOutboundController's ear gate does not engage and the write + // is sent right away. Blocking here would be both pointless and factually wrong. + setContent(null) + + composeRule.onNode(applyButton).assertIsEnabled() + composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertDoesNotExist() + + clickApply() + composeRule.runOnIdle { applies shouldBe 1 } + } +} 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 df27cb5b..25a07980 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 @@ -186,6 +186,198 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { } } + // ── PME Config / Headphone Accommodations (0x53) ──────── + + @Nested + inner class PmeConfigTests { + + /** + * Build a 0x53 frame: 4 unknown header bytes, the two apply-to flags at + * offsets 4 and 5, then 4 × 8 little-endian Float32 band gains. + */ + private fun pmeMessage( + applyToMediaByte: Int, + applyToPhoneByte: Int, + sets: List>, + ): AapMessage { + val payload = mutableListOf(0x00, 0x00, 0x00, 0x00) + payload.add(applyToMediaByte.toByte()) + payload.add(applyToPhoneByte.toByte()) + for (set in sets) { + for (band in set) { + val bits = band.toRawBits() + for (shift in 0..3) payload.add(((bits shr (shift * 8)) and 0xFF).toByte()) + } + } + val header = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x53, 0x00) + return AapMessage.parse(header + payload.toByteArray())!! + } + + private val zeroSets = List(4) { List(8) { 0f } } + + @Test + fun `decode both apply-to flags set`() { + val config = decodeSetting(pmeMessage(0x01, 0x01, zeroSets)) + config.applyToMedia shouldBe true + config.applyToPhone shouldBe true + } + + @Test + fun `decode media only`() { + val config = decodeSetting(pmeMessage(0x01, 0x00, zeroSets)) + config.applyToMedia shouldBe true + config.applyToPhone shouldBe false + } + + @Test + fun `decode phone only`() { + val config = decodeSetting(pmeMessage(0x00, 0x01, zeroSets)) + config.applyToMedia shouldBe false + config.applyToPhone shouldBe true + } + + @Test + fun `decode neither flag set`() { + val config = decodeSetting(pmeMessage(0x00, 0x00, zeroSets)) + config.applyToMedia shouldBe false + config.applyToPhone shouldBe false + } + + @Test + fun `flags are plain 0x01 flags, not Apple-bool`() { + // Apple-bool would read 0x02 as "false" too, but so does a plain flag check — + // what matters is that anything other than 0x01 is false, including 0x02. + val config = decodeSetting(pmeMessage(0x02, 0x02, zeroSets)) + config.applyToMedia shouldBe false + config.applyToPhone shouldBe false + } + + @Test + fun `band data still decodes from offset 6`() { + val sets = List(4) { setIndex -> List(8) { band -> (setIndex * 8 + band).toFloat() + 0.5f } } + val config = decodeSetting(pmeMessage(0x01, 0x00, sets)) + config.sets shouldBe sets + config.isAllZero shouldBe false + } + + @Test + fun `all-zero band data reports isAllZero regardless of flags`() { + decodeSetting(pmeMessage(0x01, 0x01, zeroSets)).isAllZero shouldBe true + } + + @Test + fun `decode rejects truncated payload`() { + val header = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x53, 0x00) + val short = AapMessage.parse(header + ByteArray(6 + 127))!! + profile.decodeSetting(short).shouldBeNull() + } + } + + // ── Custom EQ (0x63) ──────────────────────────────────── + + @Nested + inner class CustomEqTests { + @Test + fun `decode custom mode frame`() { + val eq = decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32 64")) + eq.mode shouldBe AapSetting.CustomEq.Mode.CUSTOM + eq.low shouldBe 10 + eq.mid shouldBe 50 + eq.high shouldBe 100 + } + + @Test + fun `decode recommended mode frame`() { + val eq = decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 01 32 32 32")) + eq.mode shouldBe AapSetting.CustomEq.Mode.RECOMMENDED + eq.low shouldBe 50 + eq.mid shouldBe 50 + eq.high shouldBe 50 + } + + @Test + fun `decode rejects wrong declared length`() { + profile.decodeSetting(aapMessage("04 00 04 00 63 00 04 00 01 02 0A 32 64")).shouldBeNull() + } + + @Test + fun `decode rejects unknown marker byte`() { + profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 02 02 0A 32 64")).shouldBeNull() + } + + @Test + fun `decode rejects unknown mode`() { + profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 03 0A 32 64")).shouldBeNull() + } + + @Test + fun `decode rejects band above 100`() { + profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 65 64")).shouldBeNull() + } + + @Test + fun `decode rejects truncated payload`() { + profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32")).shouldBeNull() + } + + @Test + fun `decode rejects trailing extra bytes`() { + profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32 64 00")).shouldBeNull() + } + + @Test + fun `encode locks in the full wire format`() { + val bytes = profile.encodeCommand( + AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 50, high = 100), + ) + val expected = byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x63, 0x00, + 0x05, 0x00, + 0x01, + 0x02, + 0x0A, 0x32, 0x64, + ) + bytes shouldBe expected + } + + @Test + fun `encode recommended mode`() { + val bytes = profile.encodeCommand( + AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.RECOMMENDED, low = 0, mid = 0, high = 0), + ) + bytes shouldBe byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x63, 0x00, + 0x05, 0x00, + 0x01, + 0x01, + 0x00, 0x00, 0x00, + ) + } + + @Test + fun `command rejects band below range`() { + assertThrows { + AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = -1, mid = 50, high = 50) + } + } + + @Test + fun `command rejects band above range`() { + assertThrows { + AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 50, mid = 101, high = 50) + } + } + + @Test + fun `command accepts range boundaries`() { + val command = AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 0, mid = 100, high = 0) + command.low shouldBe 0 + command.mid shouldBe 100 + } + } + // ── Stem Press Events (0x19) ──────────────────────────── @Nested diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt index 25a09b9b..ff4eed14 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt @@ -259,6 +259,26 @@ class AapSettingsCoordinatorTest : BaseTest() { result.deviceInfo!!.name shouldBe "New Name" } + @Test + fun `SetCustomEq produces no optimistic update`() { + val coord = createCoordinator() + val state = stateWithSetting( + AapSetting.CustomEq::class to AapSetting.CustomEq( + mode = AapSetting.CustomEq.Mode.RECOMMENDED, + low = 50, + mid = 50, + high = 50, + ), + ) + + val result = coord.optimisticUpdate( + state, + AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 20, high = 30), + ) + + result.shouldBeNull() + } + @Test fun `does not mutate input state`() { val coord = createCoordinator() @@ -281,6 +301,14 @@ class AapSettingsCoordinatorTest : BaseTest() { coord.verificationFor(AapCommand.SetDeviceName("test")).shouldBeNull() } + @Test + fun `verificationFor returns null for SetCustomEq`() { + val coord = createCoordinator() + coord.verificationFor( + AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 20, high = 30) + ).shouldBeNull() + } + @Test fun `verificationFor returns correct check for ANC mode`() { val coord = createCoordinator()