From fd6a9667e20622233bd684755636e1413cef5500 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 15 Apr 2026 13:47:55 +0200 Subject: [PATCH] fix(aap): Correct EndCallMuteMic mapping based on real-device behavior Swap the logical mapping for `EndCallMuteMic` settings to align with hardware states observed on AirPods Pro devices. Updated session tests for Pro 1, 2, and 3 to reflect the corrected protocol behavior. --- .../aap/protocol/DefaultAapDeviceProfile.kt | 12 ++++--- .../devices/DefaultAapDeviceProfileTest.kt | 33 +++++++++---------- .../airpods/AirPodsPro2UsbcAapSessionTest.kt | 6 ++-- .../airpods/AirPodsPro3AapSessionTest.kt | 6 ++-- .../airpods/AirPodsProAapSessionTest.kt | 4 +-- 5 files changed, 31 insertions(+), 30 deletions(-) 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 dd3adf98..ffef71ea 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 @@ -409,7 +409,9 @@ class DefaultAapDeviceProfile( * The LibrePods-documented 0x21 "standard" format is silently ignored by real firmware * — no captured session (Pro 1, Pro 2 USB-C, Pro 3) has ever emitted it. Real devices * emit subtype 0x20 or 0x00; writes using 0x20 are accepted and persisted. - * Combined-byte mapping mirrors decodeEndCallMuteMic's compact branch. + * Combined-byte mapping is based on real-device correlation, not the older third-party docs: + * on AirPods Pro 2 USB-C, compact value 0x03 matched the macOS UI state + * "Single press to mute microphone / Double press to end call" (2026-04-15 capture). */ private fun buildEndCallMuteMicMessage( muteMic: AapSetting.EndCallMuteMic.MuteMicMode, @@ -417,9 +419,9 @@ class DefaultAapDeviceProfile( ): ByteArray { val combined = when { muteMic == AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS && - endCall == AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS -> 0x02 + endCall == AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS -> 0x03 muteMic == AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS && - endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS -> 0x03 + endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS -> 0x02 else -> error("SetEndCallMuteMic invariant violated (validated by AapCommand.init)") } return byteArrayOf( @@ -449,8 +451,8 @@ class DefaultAapDeviceProfile( // 0x20: observed on Pro 1 (fw 51.9.6, 2026-03-31). Same device can send either subtype across sessions. val combined = payload[2].toInt() and 0xFF val (muteMic, endCall) = when (combined) { - 0x02 -> AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS - 0x03 -> AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS + 0x02 -> AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS + 0x03 -> AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS else -> return null } AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall) diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt index b67b1d3c..3e261300 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt @@ -10,7 +10,6 @@ import eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.nulls.shouldBeNull -import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -132,7 +131,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { fun `round-trip all modes`() { for (mode in AapSetting.AncMode.Value.entries) { val encoded = profile.encodeCommand(AapCommand.SetAncMode(mode)) - val decoded = decodeSetting(AapMessage.Companion.parse(encoded)!!) + val decoded = decodeSetting(AapMessage.parse(encoded)!!) decoded.current shouldBe mode } } @@ -166,7 +165,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { fun `round-trip all values`() { for (v in AapSetting.PressSpeed.Value.entries) { val encoded = profile.encodeCommand(AapCommand.SetPressSpeed(v)) - decodeSetting(AapMessage.Companion.parse(encoded)!!).value shouldBe v + decodeSetting(AapMessage.parse(encoded)!!).value shouldBe v } } } @@ -184,7 +183,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { fun `round-trip all values`() { for (v in AapSetting.PressHoldDuration.Value.entries) { val encoded = profile.encodeCommand(AapCommand.SetPressHoldDuration(v)) - decodeSetting(AapMessage.Companion.parse(encoded)!!).value shouldBe v + decodeSetting(AapMessage.parse(encoded)!!).value shouldBe v } } } @@ -224,7 +223,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { fun `round-trip all values`() { for (v in AapSetting.VolumeSwipeLength.Value.entries) { val encoded = profile.encodeCommand(AapCommand.SetVolumeSwipeLength(v)) - decodeSetting(AapMessage.Companion.parse(encoded)!!).value shouldBe v + decodeSetting(AapMessage.parse(encoded)!!).value shouldBe v } } } @@ -259,7 +258,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { @Test fun `encode decode round-trip`() { for (ui in listOf(0, 25, 50, 75, 100)) { val encoded = profile.encodeCommand(AapCommand.SetAdaptiveAudioNoise(ui)) - decodeSetting(AapMessage.Companion.parse(encoded)!!).level shouldBe ui + decodeSetting(AapMessage.parse(encoded)!!).level shouldBe ui } } } @@ -280,7 +279,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { 0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x24, 0x20, - 0x02, + 0x03, 0x00, 0x00, ) } @@ -297,7 +296,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { 0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x24, 0x20, - 0x03, + 0x02, 0x00, 0x00, ) } @@ -342,22 +341,22 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { @Test fun `decode compact format 0x20 combined 0x02`() { val ecm = decodeSetting(aapMessage("04 00 04 00 09 00 24 20 02 00 00")) - ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS - ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS + ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS + ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS } @Test fun `decode compact format 0x20 combined 0x03`() { val ecm = decodeSetting(aapMessage("04 00 04 00 09 00 24 20 03 00 00")) - ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS - ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS + ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS + ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS } @Test fun `decode compact format subtype 0x00 from real Pro 3`() { val ecm = decodeSetting(aapMessage("04 00 04 00 09 00 24 00 03 00 00")) - ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS - ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS + ecm.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS + ecm.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS } @Test @@ -462,7 +461,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { val enc = ByteArray(16) { 0x22.toByte() } val payload = byteArrayOf(0x02, 0x01, 0x00, 16, 0x00, *irk, 0x04, 0x00, 16, 0x00, *enc) val raw = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x31, 0x00) + payload - val result = profile.decodePrivateKeyResponse(AapMessage.Companion.parse(raw)!!)!! + val result = profile.decodePrivateKeyResponse(AapMessage.parse(raw)!!)!! result.irk shouldBe irk result.encKey shouldBe enc } @@ -471,7 +470,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { fun `decode response with only IRK`() { val irk = ByteArray(16) { 0xAA.toByte() } val raw = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x31, 0x00, 0x01, 0x01, 0x00, 16, 0x00, *irk) - val result = profile.decodePrivateKeyResponse(AapMessage.Companion.parse(raw)!!)!! + val result = profile.decodePrivateKeyResponse(AapMessage.parse(raw)!!)!! result.irk shouldBe irk result.encKey.shouldBeNull() } @@ -573,4 +572,4 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { @Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() } @Test fun `non-settings command returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 1D 00 01 02 03 04")).shouldBeNull() } @Test fun `payload too short returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 09 00 0D")).shouldBeNull() } -} \ No newline at end of file +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt index 0b6e6417..6ac50d32 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro2UsbcAapSessionTest.kt @@ -142,8 +142,8 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() { @Test fun `end call mute mic - subtype 0x00`() { decodeSetting("04 00 04 00 09 00 24 00 03 00 00").let { - it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS - it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS + it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS + it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS } } @@ -243,7 +243,7 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() { @Test fun `unknown settings IDs return null`() { - val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x3E) + val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33) for (id in unknownIds) { profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull() } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt index 616af54d..5534fd3e 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsPro3AapSessionTest.kt @@ -134,7 +134,7 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { } @Test fun `end call mute mic - subtype 0x00`() { - decodeSetting("04 00 04 00 09 00 24 00 03 00 00").let { it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS; it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS } + decodeSetting("04 00 04 00 09 00 24 00 03 00 00").let { it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS; it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS } } @Test fun `conversational awareness OFF`() { @@ -237,10 +237,10 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() { @Test fun `unknown settings IDs return null`() { - val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x3E, 0x37, 0x38, 0x3B) + val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B) for (id in unknownIds) { profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull() } } } -} \ No newline at end of file +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt index 272c0c33..b76ba02c 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/airpods/AirPodsProAapSessionTest.kt @@ -137,8 +137,8 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() { @Test fun `end call mute mic - subtype 0x20`() { decodeSetting("04 00 04 00 09 00 24 20 03 00 00").let { - it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS - it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS + it.muteMic shouldBe AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS + it.endCall shouldBe AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS } } }