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 198a5bab..17d15e7f 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 @@ -12,7 +12,19 @@ sealed class AapCommand { data class SetNcWithOneAirPod(val enabled: Boolean) : AapCommand() data class SetToneVolume(val level: Int) : AapCommand() data class SetVolumeSwipeLength(val value: AapSetting.VolumeSwipeLength.Value) : AapCommand() - data class SetEndCallMuteMic(val muteMic: AapSetting.EndCallMuteMic.MuteMicMode, val endCall: AapSetting.EndCallMuteMic.EndCallMode) : AapCommand() + data class SetEndCallMuteMic( + val muteMic: AapSetting.EndCallMuteMic.MuteMicMode, + val endCall: AapSetting.EndCallMuteMic.EndCallMode, + ) : AapCommand() { + init { + require( + (muteMic == AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS && + endCall == AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS) || + (muteMic == AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS && + endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS) + ) { "muteMic and endCall must be complementary press actions" } + } + } data class SetVolumeSwipe(val enabled: Boolean) : AapCommand() data class SetPersonalizedVolume(val enabled: Boolean) : AapCommand() data class SetAdaptiveAudioNoise(val level: Int) : AapCommand() 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 cbeb325c..1505052d 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 @@ -407,21 +407,41 @@ class DefaultAapDeviceProfile( 0x00, 0x00, 0x00, ) - /** EndCallMuteMic uses a special 2-byte format: [0x24] [0x21] [muteMic] [endCall] [0x00] */ - private fun buildEndCallMuteMicMessage(muteMic: AapSetting.EndCallMuteMic.MuteMicMode, endCall: AapSetting.EndCallMuteMic.EndCallMode): ByteArray = byteArrayOf( - 0x04, 0x00, 0x04, 0x00, - 0x09, 0x00, - SETTING_END_CALL_MUTE_MIC.toByte(), 0x21, - muteMic.wireValue.toByte(), endCall.wireValue.toByte(), - 0x00, - ) + /** + * EndCallMuteMic write uses compact format [0x24] [0x20] [combined] [0x00] [0x00]. + * 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. + */ + private fun buildEndCallMuteMicMessage( + muteMic: AapSetting.EndCallMuteMic.MuteMicMode, + endCall: AapSetting.EndCallMuteMic.EndCallMode, + ): ByteArray { + val combined = when { + muteMic == AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS && + endCall == AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS -> 0x02 + muteMic == AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS && + endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS -> 0x03 + else -> error("SetEndCallMuteMic invariant violated (validated by AapCommand.init)") + } + return byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x09, 0x00, + SETTING_END_CALL_MUTE_MIC.toByte(), 0x20, + combined.toByte(), + 0x00, 0x00, + ) + } private fun decodeEndCallMuteMic(payload: ByteArray): Pair, AapSetting>? { if (payload.size < 4) return null val subType = payload[1].toInt() and 0xFF return when (subType) { 0x21 -> { - // Standard format: byte 2 = muteMic, byte 3 = endCall + // Legacy doc-sourced format (LibrePods/MagicPodsCore). Never observed in real captures. + // Kept for forward-compat: if any unknown firmware emits this, decode still works. + // NOTE: writes must use compact 0x20 format — real devices silently ignore 0x21. val muteMic = AapSetting.EndCallMuteMic.MuteMicMode.fromWire(payload[2].toInt() and 0xFF) ?: return null val endCall = AapSetting.EndCallMuteMic.EndCallMode.fromWire(payload[3].toInt() and 0xFF) ?: 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 e34d8008..02b21728 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 @@ -7,6 +7,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.BaseAapSessionTest 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 @@ -270,12 +271,67 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { @Nested inner class EndCallMuteMicTests { @Test - fun `encode single press mute, double press end call`() { - val bytes = profile.encodeCommand(AapCommand.SetEndCallMuteMic(AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS, AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS)) - bytes[6] shouldBe 0x24.toByte() - bytes[7] shouldBe 0x21.toByte() - bytes[8] shouldBe 0x23.toByte() - bytes[9] shouldBe 0x02.toByte() + fun `encode single press mute, double press end call (compact 0x20)`() { + val bytes = profile.encodeCommand( + AapCommand.SetEndCallMuteMic( + AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS, + AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS, + ) + ) + bytes shouldBe byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x09, 0x00, + 0x24, 0x20, + 0x02, + 0x00, 0x00, + ) + } + + @Test + fun `encode double press mute, single press end call (compact 0x20)`() { + val bytes = profile.encodeCommand( + AapCommand.SetEndCallMuteMic( + AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS, + AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS, + ) + ) + bytes shouldBe byteArrayOf( + 0x04, 0x00, 0x04, 0x00, + 0x09, 0x00, + 0x24, 0x20, + 0x03, + 0x00, 0x00, + ) + } + + @Test + fun `encoded bytes round-trip through decoder`() { + listOf( + AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS, + AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS, + ).forEach { (mute, end) -> + val encoded = profile.encodeCommand(AapCommand.SetEndCallMuteMic(mute, end)) + val message = AapMessage.parse(encoded) ?: error("Failed to parse encoded bytes") + val decoded = decodeSetting(message) + decoded.muteMic shouldBe mute + decoded.endCall shouldBe end + } + } + + @Test + fun `SetEndCallMuteMic rejects non-complementary combination`() { + shouldThrow { + AapCommand.SetEndCallMuteMic( + AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS, + AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS, + ) + } + shouldThrow { + AapCommand.SetEndCallMuteMic( + AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS, + AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS, + ) + } } @Test