feat: Add AAP L2CAP protocol support for reading and writing AirPods settings

Full integration of the Apple Accessory Protocol (AAP) over L2CAP, enabling direct communication with AirPods for 1% battery granularity, ANC mode control, Conversation Awareness toggle, and private key exchange for BLE encrypted battery.
This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent 3fa01bb510
commit c1cd99f701
61 changed files with 2354 additions and 289 deletions
@@ -125,6 +125,7 @@ class OverviewViewModelTest : BaseTest() {
upgradeRepo = upgradeRepo,
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
aapManager = mockk(relaxed = true),
)
@Nested
@@ -7,10 +7,9 @@ import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.HasEarDetectionDual
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionState
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
import eu.darken.capod.pods.core.apple.protocol.aap.AncModeValue
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
@@ -69,18 +68,18 @@ class PodDeviceTest : BaseTest() {
@Test
fun `AAP settings are available when connected`() {
val aap = AapPodState(
connectionState = AapConnectionState.READY,
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
AncModeValue.TRANSPARENCY,
listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY, AncModeValue.ADAPTIVE),
AapSetting.AncMode.Value.TRANSPARENCY,
listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE),
),
),
)
val device = PodDevice(ble = mockDualPod(), aap = aap)
device.isAapConnected shouldBe true
device.ancMode.shouldNotBeNull()
device.ancMode!!.current shouldBe AncModeValue.TRANSPARENCY
device.ancMode!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
}
@Test
@@ -192,4 +191,57 @@ class PodDeviceTest : BaseTest() {
val device = PodDevice(ble = null, aap = null)
device.rawDataHex shouldBe emptyList()
}
@Test
fun `battery falls back to BLE when AAP battery is null`() {
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap)
device.batteryLeft shouldBe 0.8f
device.isAapConnected shouldBe true
}
@Test
fun `AAP battery preferred over BLE battery`() {
val aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
batteries = mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.79f, AapPodState.ChargingState.NOT_CHARGING),
),
)
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap)
device.batteryLeft shouldBe 0.79f // AAP 1% granularity wins over BLE 10%
}
@Test
fun `AAP charging preferred over BLE charging`() {
val mock = mockk<DualApplePods>(relaxed = true) {
every { model } returns PodModel.AIRPODS_PRO3
every { (this@mockk as HasChargeDetectionDual).isLeftPodCharging } returns false
}
val aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
batteries = mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING_OPTIMIZED),
),
)
val device = PodDevice(ble = mock, aap = aap)
device.isLeftPodCharging shouldBe true // AAP CHARGING_OPTIMIZED counts as charging
}
@Test
fun `address returns bonded address from profile, not BLE RPA`() {
val bondedAddress = "CC:22:FE:25:69:63"
val bleRpa = "5A:3B:1C:2D:4E:6F"
val profile = mockk<eu.darken.capod.profiles.core.AppleDeviceProfile>(relaxed = true) {
every { address } returns bondedAddress
}
val ble = mockk<DualApplePods>(relaxed = true) {
every { model } returns PodModel.AIRPODS_PRO3
every { this@mockk.address } returns bleRpa
every { meta } returns eu.darken.capod.pods.core.apple.ApplePods.AppleMeta(profile = profile)
}
val device = PodDevice(ble = ble, aap = null)
device.address shouldBe bondedAddress
device.bleAddress shouldBe bleRpa
}
}
@@ -9,7 +9,7 @@ import io.kotest.matchers.types.instanceOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsFactoryTest : BaseAirPodsTest() {
class AirPodsFactoryTest : BaseBlePodsTest() {
@Test
fun `create AirPodsGen1`() = runTest {
@@ -20,7 +20,7 @@ import testhelpers.BaseTest
import java.time.Instant
import javax.inject.Singleton
abstract class BaseAirPodsTest : BaseTest() {
abstract class BaseBlePodsTest : BaseTest() {
@Singleton
@Component(modules = [AppleFactoryModule::class, SerializationModule::class])
interface AppleFactoryTestComponent {
@@ -4,7 +4,7 @@ import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BasicSingleApplePodsTest : BaseAirPodsTest() {
class BasicSingleApplePodsTest : BaseBlePodsTest() {
@Test
fun `test mapping`() = runTest {
@@ -5,7 +5,7 @@ import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class DualApplePodsTest : BaseAirPodsTest() {
class DualApplePodsTest : BaseBlePodsTest() {
@Test
fun `test bit mapping`() = runTest {
@@ -4,7 +4,7 @@ import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class SingleApplePodsTest : BaseAirPodsTest() {
class SingleApplePodsTest : BaseBlePodsTest() {
@Test
fun `default bit mapping Max`() = runTest {
@@ -1,14 +1,14 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen1Test : BaseAirPodsTest() {
class AirPodsGen1Test : BaseBlePodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
@Test
@@ -1,14 +1,14 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen2Test : BaseAirPodsTest() {
class AirPodsGen2Test : BaseBlePodsTest() {
@Test
fun `random Neighbor AirPodsGen2`() = runTest {
@@ -1,14 +1,14 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen3Test : BaseAirPodsTest() {
class AirPodsGen3Test : BaseBlePodsTest() {
@Test
fun `AirPods Gen3`() = runTest {
@@ -1,14 +1,14 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen4AncTest : BaseAirPodsTest() {
class AirPodsGen4AncTest : BaseBlePodsTest() {
@Test
fun `AirPods Gen4 with ANC via log from #226`() = runTest {
@@ -1,14 +1,14 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsGen4Test : BaseAirPodsTest() {
class AirPodsGen4Test : BaseBlePodsTest() {
@Test
fun `AirPods Gen4 via log from #225`() = runTest {
@@ -2,13 +2,13 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.isBitSet
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsMaxTest : BaseAirPodsTest() {
class AirPodsMaxTest : BaseBlePodsTest() {
// Test data from https://github.com/adolfintel/OpenPods/issues/124
@Test
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsMaxUsbcTest : BaseAirPodsTest() {
class AirPodsMaxUsbcTest : BaseBlePodsTest() {
// Test data from https://github.com/d4rken-org/capod/issues/236
@Test
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsPro2Test : BaseAirPodsTest() {
class AirPodsPro2Test : BaseBlePodsTest() {
/**
* https://github.com/d4rken-org/capod/issues/31#issuecomment-1256791084
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsPro2UsbcTest : BaseAirPodsTest() {
class AirPodsPro2UsbcTest : BaseBlePodsTest() {
/**
* https://github.com/d4rken-org/capod/issues/164
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsPro3Test : BaseAirPodsTest() {
class AirPodsPro3Test : BaseBlePodsTest() {
/**
* Test case for AirPods Pro 3 - placeholder with correct device code
@@ -2,14 +2,14 @@ package eu.darken.capod.pods.core.apple.airpods
import eu.darken.capod.common.toHex
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import eu.darken.capod.profiles.core.AppleDeviceProfile
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AirPodsProTest : BaseAirPodsTest() {
class AirPodsProTest : BaseBlePodsTest() {
@Test
fun `test AirPods Pro - default changed and in case`() = runTest {
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsFitProTest : BaseAirPodsTest() {
class BeatsFitProTest : BaseBlePodsTest() {
/**
* From https://github.com/d4rken-org/capod/issues/33#issuecomment-1256235651
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsFlexText : BaseAirPodsTest() {
class BeatsFlexText : BaseBlePodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsSolo3Test : BaseAirPodsTest() {
class BeatsSolo3Test : BaseBlePodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsStudio3Test : BaseAirPodsTest() {
class BeatsStudio3Test : BaseBlePodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class BeatsXTest : BaseAirPodsTest() {
class BeatsXTest : BaseBlePodsTest() {
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
@Test
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeats3Test : BaseAirPodsTest() {
class PowerBeats3Test : BaseBlePodsTest() {
// TODO This is handcrafted data, get actual data for tests
@Test
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeats4Test : BaseAirPodsTest() {
class PowerBeats4Test : BaseBlePodsTest() {
@Test
fun `playing music`() = runTest {
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeatsPro2Test : BaseAirPodsTest() {
class PowerBeatsPro2Test : BaseBlePodsTest() {
@Test
fun `test PowerBeatsPro2`() = runTest {
@@ -1,13 +1,13 @@
package eu.darken.capod.pods.core.apple.beats
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.HasAppleColor
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class PowerBeatsProTest : BaseAirPodsTest() {
class PowerBeatsProTest : BaseBlePodsTest() {
@Test
fun `test PowerBeatsPro`() = runTest {
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen1Test : BaseAirPodsTest() {
class FakeAirPodsGen1Test : BaseBlePodsTest() {
@Test
fun `charging in box`() = runTest {
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen2Test : BaseAirPodsTest() {
class FakeAirPodsGen2Test : BaseBlePodsTest() {
@Test
fun `charging in box`() = runTest {
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsGen3Test : BaseAirPodsTest() {
class FakeAirPodsGen3Test : BaseBlePodsTest() {
@Test
fun `charging in case`() = runTest {
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsPro2Test : BaseAirPodsTest() {
class FakeAirPodsPro2Test : BaseBlePodsTest() {
@Test
fun `guessed data`() = runTest {
@@ -1,12 +1,12 @@
package eu.darken.capod.pods.core.apple.misc
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class FakeAirPodsProTest : BaseAirPodsTest() {
class FakeAirPodsProTest : BaseBlePodsTest() {
@Test
fun `guessed data`() = runTest {
@@ -0,0 +1,120 @@
package eu.darken.capod.pods.core.apple.protocol.aap
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.pods.core.PodModel
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.maps.shouldBeEmpty
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class AapConnectionManagerTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var socketFactory: L2capSocketFactory
private lateinit var manager: AapConnectionManager
private val testAddress = "AA:BB:CC:DD:EE:FF"
private val testDevice: BluetoothDevice = mockk(relaxed = true) {
every { address } returns testAddress
}
@BeforeEach
fun setup() {
socketFactory = mockk(relaxed = true)
manager = AapConnectionManager(
socketFactory = socketFactory,
scope = testScope,
)
}
@Nested
inner class InitialState {
@Test
fun `allStates starts empty`() = testScope.runTest {
manager.allStates.value.shouldBeEmpty()
}
}
@Nested
inner class SendCommand {
@Test
fun `sendCommand throws when not connected`() = testScope.runTest {
shouldThrow<IllegalStateException> {
manager.sendCommand(testAddress, AapCommand.SetAncMode(AapSetting.AncMode.Value.ON))
}
}
}
@Nested
inner class Disconnect {
@Test
fun `disconnect on unknown address is no-op`() = testScope.runTest {
manager.disconnect(testAddress)
manager.allStates.value.shouldBeEmpty()
}
}
@Nested
inner class Connect {
@Test
fun `connect failure cleans up state`() = testScope.runTest {
every { socketFactory.createSocket(any(), any()) } throws IOException("Connection refused")
shouldThrow<IOException> {
manager.connect(testAddress, testDevice, PodModel.AIRPODS_PRO3)
}
manager.allStates.value.shouldBeEmpty()
}
@Test
fun `remote disconnect cleans up allStates`() = testScope.runTest {
// Empty inputStream → readLoop gets -1 immediately → DISCONNECTED
val socket = mockk<BluetoothSocket>(relaxed = true) {
every { outputStream } returns ByteArrayOutputStream()
every { inputStream } returns ByteArrayInputStream(byteArrayOf())
}
every { socketFactory.createSocket(any(), any()) } returns socket
manager.connect(testAddress, testDevice, PodModel.AIRPODS_PRO3)
advanceUntilIdle()
// readLoop exited, collector cleaned up
manager.allStates.value.containsKey(testAddress) shouldBe false
}
@Test
fun `can reconnect after remote disconnect`() = testScope.runTest {
val socket = mockk<BluetoothSocket>(relaxed = true) {
every { outputStream } returns ByteArrayOutputStream()
every { inputStream } returns ByteArrayInputStream(byteArrayOf())
}
every { socketFactory.createSocket(any(), any()) } returns socket
manager.connect(testAddress, testDevice, PodModel.AIRPODS_PRO3)
advanceUntilIdle()
// Stale entry should be cleaned up — second connect should not throw "Already connected"
manager.connect(testAddress, testDevice, PodModel.AIRPODS_PRO3)
advanceUntilIdle()
}
}
}
@@ -11,61 +11,137 @@ class AapPodStateTest : BaseTest() {
@Test
fun `setting lookup returns typed setting`() {
val state = AapPodState(
connectionState = AapConnectionState.READY,
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(AncModeValue.ON, listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY)),
AapSetting.AncMode::class to AapSetting.AncMode(
AapSetting.AncMode.Value.ON,
listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY),
),
)
)
val anc = state.setting<AapSetting.AncMode>()
anc.shouldNotBeNull()
anc.current shouldBe AncModeValue.ON
anc.current shouldBe AapSetting.AncMode.Value.ON
}
@Test
fun `setting lookup returns null for missing type`() {
val state = AapPodState(connectionState = AapConnectionState.READY)
val state = AapPodState(connectionState = AapPodState.ConnectionState.READY)
state.setting<AapSetting.AncMode>().shouldBeNull()
}
@Test
fun `withSetting merges into existing settings`() {
val state = AapPodState(
connectionState = AapConnectionState.READY,
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(AncModeValue.ON, listOf(AncModeValue.ON)),
AapSetting.AncMode::class to AapSetting.AncMode(AapSetting.AncMode.Value.ON, listOf(AapSetting.AncMode.Value.ON)),
)
)
val updated = state.withSetting(
AapSetting.ConversationalAwareness::class,
AapSetting.ConversationalAwareness(true),
)
// Original setting preserved
updated.setting<AapSetting.AncMode>().shouldNotBeNull()
// New setting added
updated.setting<AapSetting.ConversationalAwareness>()!!.enabled shouldBe true
}
@Test
fun `withSetting replaces existing setting of same type`() {
val state = AapPodState(
connectionState = AapConnectionState.READY,
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(AncModeValue.ON, listOf(AncModeValue.ON)),
AapSetting.AncMode::class to AapSetting.AncMode(AapSetting.AncMode.Value.ON, listOf(AapSetting.AncMode.Value.ON)),
)
)
val updated = state.withSetting(
AapSetting.AncMode::class,
AapSetting.AncMode(AncModeValue.TRANSPARENCY, listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY)),
AapSetting.AncMode(AapSetting.AncMode.Value.TRANSPARENCY, listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY)),
)
updated.setting<AapSetting.AncMode>()!!.current shouldBe AncModeValue.TRANSPARENCY
updated.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
updated.settings.size shouldBe 1
}
@Test
fun `default state is disconnected with no data`() {
val state = AapPodState()
state.connectionState shouldBe AapConnectionState.DISCONNECTED
state.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
state.deviceInfo.shouldBeNull()
state.settings shouldBe emptyMap()
state.batteries shouldBe emptyMap()
}
@Test
fun `battery accessors map to correct types`() {
val state = AapPodState(
batteries = mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.NOT_CHARGING),
AapPodState.BatteryType.RIGHT to AapPodState.Battery(AapPodState.BatteryType.RIGHT, 0.77f, AapPodState.ChargingState.CHARGING),
AapPodState.BatteryType.CASE to AapPodState.Battery(AapPodState.BatteryType.CASE, 0.48f, AapPodState.ChargingState.CHARGING),
),
)
state.batteryLeft shouldBe 0.8f
state.batteryRight shouldBe 0.77f
state.batteryCase shouldBe 0.48f
state.batteryHeadset.shouldBeNull()
}
@Test
fun `battery accessors null when type not present`() {
val state = AapPodState()
state.batteryLeft.shouldBeNull()
state.batteryRight.shouldBeNull()
state.batteryCase.shouldBeNull()
state.batteryHeadset.shouldBeNull()
}
@Test
fun `headset battery for single-pod devices`() {
val state = AapPodState(
batteries = mapOf(
AapPodState.BatteryType.SINGLE to AapPodState.Battery(AapPodState.BatteryType.SINGLE, 0.6f, AapPodState.ChargingState.NOT_CHARGING),
),
)
state.batteryHeadset shouldBe 0.6f
state.batteryLeft.shouldBeNull()
}
@Test
fun `isCharging true for CHARGING state`() {
val state = AapPodState(
batteries = mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING),
),
)
state.isLeftCharging shouldBe true
}
@Test
fun `isCharging true for CHARGING_OPTIMIZED state`() {
val state = AapPodState(
batteries = mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING_OPTIMIZED),
),
)
state.isLeftCharging shouldBe true
}
@Test
fun `isCharging false for NOT_CHARGING`() {
val state = AapPodState(
batteries = mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.NOT_CHARGING),
),
)
state.isLeftCharging shouldBe false
}
@Test
fun `isCharging null when battery type absent`() {
val state = AapPodState()
state.isLeftCharging.shouldBeNull()
state.isRightCharging.shouldBeNull()
state.isCaseCharging.shouldBeNull()
state.isHeadsetCharging.shouldBeNull()
}
}
@@ -0,0 +1,213 @@
package eu.darken.capod.pods.core.apple.protocol.aap
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState.BatteryType
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState.ChargingState
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.AdaptiveAudioNoise
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.AncMode
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ConversationalAwareness
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ConversationalAwarenessState
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic.EndCallMode
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic.MuteMicMode
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.NcWithOneAirPod
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PersonalizedVolume
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PressHoldDuration
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PressSpeed
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ToneVolume
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.VolumeSwipe
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.VolumeSwipeLength
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
/**
* Tests the full AAP decode pipeline using real captured data from AirPods Pro 3.
* Each test uses exact bytes observed on a live device via L2CAP SEQPACKET reads.
*
* Device: AirPods Pro 3 (model A3064, product ID 0x2720)
* Phone: Pixel 8 (Android 17)
* Captured: 2026-03-30
*/
class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
override val podModel = PodModel.AIRPODS_PRO3
// ── Handshake ────────────────────────────────────────────
@Test
fun `handshake response - 18 bytes`() {
val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
msg.commandType shouldBe 0x0000
msg.raw.size shouldBe 18
msg.payload.size shouldBe 12
}
// ── Device Info ──────────────────────────────────────────
@Test
fun `device info - 246 bytes`() {
val msg = aapMessage(
"04 00 04 00 1D 00 02 ED 00 04 00",
"41 69 72 50 6F 64 73 20 50 72 6F 20 33 00", // "AirPods Pro 3"
"41 33 30 36 34 00", // "A3064"
"41 70 70 6C 65 20 49 6E 63 2E 00", // "Apple Inc."
"46 59 4C 39 37 33 30 33 54 39 00", // serial
"38 31 2E 32 36 37 35 30 30 30 30 37 35 30 30 30 30 30 30 2E 36 35 30 33 00", // firmware
"38 31 2E 32 36 37 35 30 30 30 30 37 35 30 30 30 30 30 30 2E 36 35 30 33 00",
"31 2E 30 2E 30 00",
"63 6F 6D 2E 61 70 70 6C 65 2E 61 63 63 65 73 73 6F 72 79 2E 75 70 64 61 74 65 72 2E 61 70 70 2E 37 31 00",
"47 4D 50 48 4E 5A 31 36 50 35 5A 30 30 30 30 55 48 5A 00", // left serial
"47 4D 56 48 4E 58 31 35 55 45 44 30 30 30 30 55 48 59 00", // right serial
"38 34 35 34 36 32 34 00",
"AE 84 20 32 A7 3E 46 46 A8 EA 0B 13 A9 27 8B 79 0D D3 55 98 AC 3D 1A 4F 2F 89 BE 5C 65 F5 FF 1C D9 AE",
"31 37 36 37 33 36 34 30 37 34 00 31 37 36 37 33 36 34 30 37 34 00",
)
val info = profile.decodeDeviceInfo(msg)!!
info.name shouldBe "AirPods Pro 3"
info.modelNumber shouldBe "A3064"
info.manufacturer shouldBe "Apple Inc."
}
// ── Battery ──────────────────────────────────────────────
@Nested
inner class BatterySessionTests {
@Test
fun `in case charging - pods CHARGING_OPTIMIZED, case CHARGING`() {
val r =
profile.decodeBattery(aapMessage("04 00 04 00 04 00 03 04 01 50 05 01 02 01 4F 05 01 08 01 30 01 01"))!!
r[BatteryType.LEFT]!!.let { it.percent shouldBe 0.8f; it.charging shouldBe ChargingState.CHARGING_OPTIMIZED }
r[BatteryType.RIGHT]!!.let { it.percent shouldBe 0.79f; it.charging shouldBe ChargingState.CHARGING_OPTIMIZED }
r[BatteryType.CASE]!!.let { it.percent shouldBe 0.48f; it.charging shouldBe ChargingState.CHARGING }
}
@Test
fun `out of case - pods NOT_CHARGING, case DISCONNECTED`() {
val r =
profile.decodeBattery(aapMessage("04 00 04 00 04 00 03 02 01 4D 02 01 04 01 50 02 01 08 01 00 04 01"))!!
r[BatteryType.RIGHT]!!.charging shouldBe ChargingState.NOT_CHARGING
r[BatteryType.LEFT]!!.charging shouldBe ChargingState.NOT_CHARGING
r[BatteryType.CASE]!!.charging shouldBe ChargingState.DISCONNECTED
}
}
// ── Private Keys ─────────────────────────────────────────
@Test
fun `private key response - 47 bytes with IRK and ENC`() {
val result = profile.decodePrivateKeyResponse(
aapMessage(
"04 00 04 00 31 00 02 " +
"01 00 10 00 7C 64 9F C2 6C C1 07 2F 07 A2 BD 34 3A FA 8B A1 " +
"04 00 10 00 A3 52 94 92 78 52 5F F0 95 E3 A6 C7 10 32 29 8B"
)
)!!
result.irk!!.size shouldBe 16
result.irk!![0] shouldBe 0x7C.toByte()
result.encKey!!.size shouldBe 16
result.encKey!![0] shouldBe 0xA3.toByte()
}
// ── Settings Push ────────────────────────────────────────
@Nested
inner class SettingsSessionTests {
@Test fun `ANC mode ON`() {
val anc = decodeSetting<AncMode>("04 00 04 00 09 00 0D 02 00 00 00")
anc.current shouldBe AncMode.Value.ON
anc.supported shouldContainExactly listOf(
AncMode.Value.ON,
AncMode.Value.TRANSPARENCY,
AncMode.Value.ADAPTIVE
)
}
@Test fun `press hold duration DEFAULT`() {
decodeSetting<PressHoldDuration>("04 00 04 00 09 00 18 00 00 00 00").value shouldBe PressHoldDuration.Value.DEFAULT
}
@Test fun `press speed DEFAULT`() {
decodeSetting<PressSpeed>("04 00 04 00 09 00 17 00 00 00 00").value shouldBe PressSpeed.Value.DEFAULT
}
@Test fun `volume swipe ON`() {
decodeSetting<VolumeSwipe>("04 00 04 00 09 00 25 01 00 00 00").enabled shouldBe true
}
@Test fun `volume swipe length DEFAULT`() {
decodeSetting<VolumeSwipeLength>("04 00 04 00 09 00 23 00 00 00 00").value shouldBe VolumeSwipeLength.Value.DEFAULT
}
@Test fun `tone volume 80`() {
decodeSetting<ToneVolume>("04 00 04 00 09 00 1F 50 50 00 00").level shouldBe 0x50
}
@Test fun `end call mute mic - subtype 0x00`() {
decodeSetting<EndCallMuteMic>("04 00 04 00 09 00 24 00 03 00 00").let { it.muteMic shouldBe MuteMicMode.DOUBLE_PRESS; it.endCall shouldBe EndCallMode.SINGLE_PRESS }
}
@Test fun `conversational awareness OFF`() {
decodeSetting<ConversationalAwareness>("04 00 04 00 09 00 28 02 00 00 00").enabled shouldBe false
}
@Test fun `conversational awareness ON after reconnect`() {
decodeSetting<ConversationalAwareness>("04 00 04 00 09 00 28 01 00 00 00").enabled shouldBe true
}
@Test fun `personalized volume OFF`() {
decodeSetting<PersonalizedVolume>("04 00 04 00 09 00 26 02 00 00 00").enabled shouldBe false
}
@Test fun `adaptive audio noise 50`() {
decodeSetting<AdaptiveAudioNoise>("04 00 04 00 09 00 2E 32 00 00 00").level shouldBe 0x32
}
@Test fun `NC one airpod OFF`() {
decodeSetting<NcWithOneAirPod>("04 00 04 00 09 00 1B 02 00 00 00").enabled shouldBe false
}
}
// ── ANC Mode Switching (verified audible) ────────────────
@Nested
inner class AncModeSwitchingTests {
@Test fun `device echoes TRANSPARENCY`() {
decodeSetting<AncMode>("04 00 04 00 09 00 0D 03 00 00 00").current shouldBe AncMode.Value.TRANSPARENCY
}
@Test fun `device echoes ADAPTIVE`() {
decodeSetting<AncMode>("04 00 04 00 09 00 0D 04 00 00 00").current shouldBe AncMode.Value.ADAPTIVE
}
}
// ── Conversation Awareness State (0x4B) ──────────────────
@Test
fun `conversation awareness state - not speaking`() {
decodeSetting<ConversationalAwarenessState>("04 00 04 00 4B 00 00").speaking shouldBe false
}
// ── Unhandled Messages ───────────────────────────────────
@Nested
inner class UnhandledMessageTests {
@Test fun `cmd 0x002B init exchange`() {
profile.decodeSetting(aapMessage("04 00 04 00 2B 00 01 22 00 E9 B4 03")).shouldBeNull()
}
@Test fun `cmd 0x0006 ear detection`() {
profile.decodeSetting(aapMessage("04 00 04 00 06 00 02 02")).shouldBeNull()
}
@Test
fun `unknown settings IDs return null`() {
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x35, 0x3E, 0x37, 0x38, 0x3B)
for (id in unknownIds) {
profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull()
}
}
}
}
@@ -0,0 +1,70 @@
package eu.darken.capod.pods.core.apple.protocol.aap
import eu.darken.capod.pods.core.PodModel
import testhelpers.BaseTest
/**
* Shared base for AAP protocol tests. Provides hex parsing, message builders,
* and type-safe setting extraction — mirrors [eu.darken.capod.pods.core.apple.BaseBlePodsTest]
* for the BLE path.
*
* Subclasses set [podModel] to get a correctly-configured [profile].
*/
abstract class BaseAapSessionTest : BaseTest() {
abstract val podModel: PodModel
protected val profile: DefaultAapDeviceProfile by lazy { DefaultAapDeviceProfile(podModel) }
// ── Hex parsing ──────────────────────────────────────────
/** Parse hex string(s) into an [AapMessage]. Multiple args are concatenated. */
protected fun aapMessage(vararg hexParts: String): AapMessage {
val bytes = hexParts.joinToString(" ")
.split(" ")
.filter { it.isNotBlank() }
.map { it.toInt(16).toByte() }
.toByteArray()
return AapMessage.parse(bytes) ?: error("Failed to parse AapMessage from: ${hexParts.joinToString(" ")}")
}
// ── Message builders (hide protocol header bytes) ────────
/**
* Build a settings message: header + cmd 0x09 + settingId + values + padding.
* The `04 00 04 00` header and `09 00` command type are added automatically.
*/
protected fun settingsMessage(settingId: Int, vararg values: Int): AapMessage {
val bytes = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00,
settingId.toByte(),
*values.map { it.toByte() }.toByteArray(),
0x00, 0x00, 0x00,
)
return AapMessage.parse(bytes)!!
}
/**
* Build a battery message: header + cmd 0x04 + entryBytes.
* [entryBytes] should start with the count byte followed by 5-byte entries.
*/
protected fun batteryMessage(vararg entryBytes: Int): AapMessage {
val payload = entryBytes.map { it.toByte() }.toByteArray()
val header = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x04, 0x00)
return AapMessage.parse(header + payload)!!
}
// ── Type-safe setting decode ─────────────────────────────
/** Decode a setting from an [AapMessage], asserting the result is non-null and of type [T]. */
protected inline fun <reified T : AapSetting> decodeSetting(msg: AapMessage): T {
val (_, setting) = profile.decodeSetting(msg)
?: error("decodeSetting returned null for cmd=0x${"%04X".format(msg.commandType)}")
return setting as? T
?: error("Expected ${T::class.simpleName} but got ${setting::class.simpleName}")
}
/** Decode a setting from a hex string, asserting the result is non-null and of type [T]. */
protected inline fun <reified T : AapSetting> decodeSetting(hex: String): T =
decodeSetting(aapMessage(hex))
}
@@ -1,14 +1,34 @@
package eu.darken.capod.pods.core.apple.protocol.aap
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState.BatteryType
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState.ChargingState
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.AncMode
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.AdaptiveAudioNoise
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ConversationalAwareness
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ConversationalAwarenessState
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic.EndCallMode
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic.MuteMicMode
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.NcWithOneAirPod
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PersonalizedVolume
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PressHoldDuration
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PressSpeed
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ToneVolume
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.VolumeSwipe
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.VolumeSwipeLength
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
import testhelpers.BaseTest
class DefaultAapDeviceProfileTest : BaseTest() {
class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
private val profile = DefaultAapDeviceProfile()
override val podModel = PodModel.AIRPODS_PRO2
// ── Handshake ────────────────────────────────────────────
@Test
fun `encode handshake is 16 bytes`() {
@@ -18,96 +38,387 @@ class DefaultAapDeviceProfileTest : BaseTest() {
handshake[4] shouldBe 0x01.toByte()
}
@Test
fun `encode SetAncMode ON`() {
val bytes = profile.encodeCommand(AapCommand.SetAncMode(AncModeValue.ON))
bytes.size shouldBe 11
bytes[4] shouldBe 0x09.toByte() // command type low byte
bytes[5] shouldBe 0x00.toByte() // command type high byte
bytes[6] shouldBe 0x0D.toByte() // setting ID = ANC mode
bytes[7] shouldBe 0x02.toByte() // value = ON
// ── Notification Enable ──────────────────────────────────
@Nested
inner class NotificationEnableTests {
@Test
fun `returns two packets`() {
profile.encodeNotificationEnable().size shouldBe 2
}
@Test
fun `first packet has 0xef filter`() {
val p = profile.encodeNotificationEnable()[0]
p[4] shouldBe 0x0f.toByte()
p[8] shouldBe 0xef.toByte()
}
@Test
fun `second packet has 0xff filter`() {
val p = profile.encodeNotificationEnable()[1]
p[4] shouldBe 0x0f.toByte()
p[8] shouldBe 0xff.toByte()
}
}
@Test
fun `encode SetAncMode TRANSPARENCY`() {
val bytes = profile.encodeCommand(AapCommand.SetAncMode(AncModeValue.TRANSPARENCY))
bytes[6] shouldBe 0x0D.toByte()
bytes[7] shouldBe 0x03.toByte()
// ── InitExt ──────────────────────────────────────────────
@Nested
inner class InitExtTests {
@Test fun `returned for Pro 2`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO2).encodeInitExt().shouldNotBeNull() }
@Test fun `returned for Pro 3`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO3).encodeInitExt().shouldNotBeNull() }
@Test fun `returned for AP4 ANC`() { DefaultAapDeviceProfile(PodModel.AIRPODS_GEN4_ANC).encodeInitExt().shouldNotBeNull() }
@Test fun `null for basic AirPods`() { DefaultAapDeviceProfile(PodModel.AIRPODS_GEN3).encodeInitExt().shouldBeNull() }
@Test fun `null for Pro 1`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO).encodeInitExt().shouldBeNull() }
@Test fun `null for Max`() { DefaultAapDeviceProfile(PodModel.AIRPODS_MAX).encodeInitExt().shouldBeNull() }
@Test
fun `has correct command byte`() {
profile.encodeInitExt()!![4] shouldBe 0x4d.toByte()
}
}
@Test
fun `encode SetAncMode ADAPTIVE`() {
val bytes = profile.encodeCommand(AapCommand.SetAncMode(AncModeValue.ADAPTIVE))
bytes[7] shouldBe 0x04.toByte()
// ── Supported ANC Modes per model ────────────────────────
@Nested
inner class SupportedAncModesTests {
private fun ancModesFor(model: PodModel): List<AncMode.Value> {
val p = DefaultAapDeviceProfile(model)
return decodeSetting<AncMode>(settingsMessage(0x0D, 0x02)).let {
// Can't use the outer profile, need per-model profile
(p.decodeSetting(settingsMessage(0x0D, 0x02))!!.second as AncMode).supported
}
}
@Test
fun `Pro 2 supports ON, TRANSPARENCY, ADAPTIVE`() {
ancModesFor(PodModel.AIRPODS_PRO2) shouldContainExactly listOf(
AncMode.Value.ON, AncMode.Value.TRANSPARENCY, AncMode.Value.ADAPTIVE,
)
}
@Test
fun `Pro 1 supports ON, TRANSPARENCY only`() {
ancModesFor(PodModel.AIRPODS_PRO) shouldContainExactly listOf(
AncMode.Value.ON, AncMode.Value.TRANSPARENCY,
)
}
@Test
fun `basic AirPods have empty supported modes`() {
ancModesFor(PodModel.AIRPODS_GEN1) shouldBe emptyList()
}
@Test
fun `Max supports ON, TRANSPARENCY only`() {
ancModesFor(PodModel.AIRPODS_MAX) shouldContainExactly listOf(
AncMode.Value.ON, AncMode.Value.TRANSPARENCY,
)
}
}
@Test
fun `encode SetConversationalAwareness enabled`() {
val bytes = profile.encodeCommand(AapCommand.SetConversationalAwareness(true))
bytes[6] shouldBe 0x18.toByte() // setting ID
bytes[7] shouldBe 0x01.toByte() // enabled
// ── ANC Mode encode/decode ───────────────────────────────
@Nested
inner class AncModeTests {
@Test fun `encode OFF`() { profile.encodeCommand(AapCommand.SetAncMode(AncMode.Value.OFF))[7] shouldBe 0x01.toByte() }
@Test fun `encode ON`() { profile.encodeCommand(AapCommand.SetAncMode(AncMode.Value.ON))[7] shouldBe 0x02.toByte() }
@Test fun `encode TRANSPARENCY`() { profile.encodeCommand(AapCommand.SetAncMode(AncMode.Value.TRANSPARENCY))[7] shouldBe 0x03.toByte() }
@Test fun `encode ADAPTIVE`() { profile.encodeCommand(AapCommand.SetAncMode(AncMode.Value.ADAPTIVE))[7] shouldBe 0x04.toByte() }
@Test fun `decode OFF`() { decodeSetting<AncMode>(settingsMessage(0x0D, 0x01)).current shouldBe AncMode.Value.OFF }
@Test fun `decode ON`() { decodeSetting<AncMode>(settingsMessage(0x0D, 0x02)).current shouldBe AncMode.Value.ON }
@Test fun `decode TRANSPARENCY`() { decodeSetting<AncMode>(settingsMessage(0x0D, 0x03)).current shouldBe AncMode.Value.TRANSPARENCY }
@Test fun `decode ADAPTIVE`() { decodeSetting<AncMode>(settingsMessage(0x0D, 0x04)).current shouldBe AncMode.Value.ADAPTIVE }
@Test fun `decode unknown wire value returns null`() { profile.decodeSetting(settingsMessage(0x0D, 0x99)).shouldBeNull() }
@Test
fun `round-trip all modes`() {
for (mode in AncMode.Value.entries) {
val encoded = profile.encodeCommand(AapCommand.SetAncMode(mode))
val decoded = decodeSetting<AncMode>(AapMessage.parse(encoded)!!)
decoded.current shouldBe mode
}
}
}
@Test
fun `encode SetConversationalAwareness disabled`() {
val bytes = profile.encodeCommand(AapCommand.SetConversationalAwareness(false))
bytes[6] shouldBe 0x18.toByte()
bytes[7] shouldBe 0x00.toByte()
// ── Conversational Awareness ─────────────────────────────
@Nested
inner class ConversationalAwarenessTests {
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetConversationalAwareness(true))[7] shouldBe 0x01.toByte() }
@Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetConversationalAwareness(false))[7] shouldBe 0x02.toByte() }
@Test fun `decode enabled`() { decodeSetting<ConversationalAwareness>(settingsMessage(0x28, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<ConversationalAwareness>(settingsMessage(0x28, 0x02)).enabled shouldBe false }
@Test fun `decode unknown value returns null`() { profile.decodeSetting(settingsMessage(0x28, 0x00)).shouldBeNull() }
}
@Test
fun `decode ANC mode setting`() {
val msg = AapMessage.parse(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0D, 0x02, 0x00, 0x00, 0x00)
)!!
val result = profile.decodeSetting(msg)
result.shouldNotBeNull()
val (key, setting) = result
key shouldBe AapSetting.AncMode::class
(setting as AapSetting.AncMode).current shouldBe AncModeValue.ON
// ── Press Speed ──────────────────────────────────────────
@Nested
inner class PressSpeedTests {
@Test fun `encode default`() { profile.encodeCommand(AapCommand.SetPressSpeed(PressSpeed.Value.DEFAULT))[7] shouldBe 0x00.toByte() }
@Test fun `encode slower`() { profile.encodeCommand(AapCommand.SetPressSpeed(PressSpeed.Value.SLOWER))[7] shouldBe 0x01.toByte() }
@Test fun `encode slowest`() { profile.encodeCommand(AapCommand.SetPressSpeed(PressSpeed.Value.SLOWEST))[7] shouldBe 0x02.toByte() }
@Test fun `decode default`() { decodeSetting<PressSpeed>(settingsMessage(0x17, 0x00)).value shouldBe PressSpeed.Value.DEFAULT }
@Test fun `decode slower`() { decodeSetting<PressSpeed>(settingsMessage(0x17, 0x01)).value shouldBe PressSpeed.Value.SLOWER }
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x17, 0x99)).shouldBeNull() }
@Test
fun `round-trip all values`() {
for (v in PressSpeed.Value.entries) {
val encoded = profile.encodeCommand(AapCommand.SetPressSpeed(v))
decodeSetting<PressSpeed>(AapMessage.parse(encoded)!!).value shouldBe v
}
}
}
@Test
fun `decode transparency mode`() {
val msg = AapMessage.parse(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0D, 0x03, 0x00, 0x00, 0x00)
)!!
val (_, setting) = profile.decodeSetting(msg)!!
(setting as AapSetting.AncMode).current shouldBe AncModeValue.TRANSPARENCY
// ── Press & Hold Duration ────────────────────────────────
@Nested
inner class PressHoldDurationTests {
@Test fun `encode shorter`() { profile.encodeCommand(AapCommand.SetPressHoldDuration(PressHoldDuration.Value.SHORTER))[7] shouldBe 0x01.toByte() }
@Test fun `decode shortest`() { decodeSetting<PressHoldDuration>(settingsMessage(0x18, 0x02)).value shouldBe PressHoldDuration.Value.SHORTEST }
@Test
fun `round-trip all values`() {
for (v in PressHoldDuration.Value.entries) {
val encoded = profile.encodeCommand(AapCommand.SetPressHoldDuration(v))
decodeSetting<PressHoldDuration>(AapMessage.parse(encoded)!!).value shouldBe v
}
}
}
@Test
fun `decode conversational awareness`() {
val msg = AapMessage.parse(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x18, 0x01, 0x00, 0x00, 0x00)
)!!
val (key, setting) = profile.decodeSetting(msg)!!
key shouldBe AapSetting.ConversationalAwareness::class
(setting as AapSetting.ConversationalAwareness).enabled shouldBe true
// ── NC with One AirPod ───────────────────────────────────
@Nested
inner class NcOneAirPodTests {
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetNcWithOneAirPod(true))[7] shouldBe 0x01.toByte() }
@Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetNcWithOneAirPod(false))[7] shouldBe 0x02.toByte() }
@Test fun `decode enabled`() { decodeSetting<NcWithOneAirPod>(settingsMessage(0x1B, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<NcWithOneAirPod>(settingsMessage(0x1B, 0x02)).enabled shouldBe false }
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x1B, 0x00)).shouldBeNull() }
}
@Test
fun `decode unknown setting returns null`() {
val msg = AapMessage.parse(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x7F.toByte(), 0x01, 0x00, 0x00, 0x00)
)!!
profile.decodeSetting(msg).shouldBeNull()
// ── Tone Volume ──────────────────────────────────────────
@Nested
inner class ToneVolumeTests {
@Test fun `encode level 50`() { profile.encodeCommand(AapCommand.SetToneVolume(50))[7] shouldBe 50.toByte() }
@Test fun `encode clamps to min 15`() { profile.encodeCommand(AapCommand.SetToneVolume(0))[7] shouldBe 0x0F.toByte() }
@Test fun `encode clamps to max 100`() { profile.encodeCommand(AapCommand.SetToneVolume(200))[7] shouldBe 0x64.toByte() }
@Test fun `decode level`() { decodeSetting<ToneVolume>(settingsMessage(0x1F, 50)).level shouldBe 50 }
}
@Test
fun `decode non-settings message returns null`() {
val msg = AapMessage.parse(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1D, 0x00, 0x01, 0x02, 0x03, 0x04)
)!!
profile.decodeSetting(msg).shouldBeNull()
// ── Volume Swipe Length ──────────────────────────────────
@Nested
inner class VolumeSwipeLengthTests {
@Test fun `encode longest`() { profile.encodeCommand(AapCommand.SetVolumeSwipeLength(VolumeSwipeLength.Value.LONGEST))[7] shouldBe 0x02.toByte() }
@Test fun `decode longer`() { decodeSetting<VolumeSwipeLength>(settingsMessage(0x23, 0x01)).value shouldBe VolumeSwipeLength.Value.LONGER }
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x23, 0x99)).shouldBeNull() }
@Test
fun `round-trip all values`() {
for (v in VolumeSwipeLength.Value.entries) {
val encoded = profile.encodeCommand(AapCommand.SetVolumeSwipeLength(v))
decodeSetting<VolumeSwipeLength>(AapMessage.parse(encoded)!!).value shouldBe v
}
}
}
@Test
fun `round-trip encode then decode ANC mode`() {
val command = AapCommand.SetAncMode(AncModeValue.TRANSPARENCY)
val encoded = profile.encodeCommand(command)
val msg = AapMessage.parse(encoded)!!
val (_, setting) = profile.decodeSetting(msg)!!
(setting as AapSetting.AncMode).current shouldBe AncModeValue.TRANSPARENCY
// ── Volume Swipe ─────────────────────────────────────────
@Nested
inner class VolumeSwipeTests {
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetVolumeSwipe(true))[7] shouldBe 0x01.toByte() }
@Test fun `decode disabled`() { decodeSetting<VolumeSwipe>(settingsMessage(0x25, 0x02)).enabled shouldBe false }
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x25, 0x00)).shouldBeNull() }
}
// ── Personalized Volume ──────────────────────────────────
@Nested
inner class PersonalizedVolumeTests {
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetPersonalizedVolume(true))[7] shouldBe 0x01.toByte() }
@Test fun `decode disabled`() { decodeSetting<PersonalizedVolume>(settingsMessage(0x26, 0x02)).enabled shouldBe false }
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x26, 0x00)).shouldBeNull() }
}
// ── Adaptive Audio Noise ─────────────────────────────────
@Nested
inner class AdaptiveAudioNoiseTests {
@Test fun `encode level 50`() { profile.encodeCommand(AapCommand.SetAdaptiveAudioNoise(50))[7] shouldBe 50.toByte() }
@Test fun `encode clamps to 0`() { profile.encodeCommand(AapCommand.SetAdaptiveAudioNoise(-5))[7] shouldBe 0x00.toByte() }
@Test fun `encode clamps to 100`() { profile.encodeCommand(AapCommand.SetAdaptiveAudioNoise(150))[7] shouldBe 0x64.toByte() }
@Test fun `decode level`() { decodeSetting<AdaptiveAudioNoise>(settingsMessage(0x2E, 64)).level shouldBe 64 }
}
// ── EndCall / MuteMic ────────────────────────────────────
@Nested
inner class EndCallMuteMicTests {
@Test
fun `encode single press mute, double press end call`() {
val bytes = profile.encodeCommand(AapCommand.SetEndCallMuteMic(MuteMicMode.SINGLE_PRESS, EndCallMode.DOUBLE_PRESS))
bytes[6] shouldBe 0x24.toByte()
bytes[7] shouldBe 0x21.toByte()
bytes[8] shouldBe 0x23.toByte()
bytes[9] shouldBe 0x02.toByte()
}
@Test
fun `decode standard format 0x21`() {
val ecm = decodeSetting<EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 21 22 03 00"))
ecm.muteMic shouldBe MuteMicMode.DOUBLE_PRESS
ecm.endCall shouldBe EndCallMode.SINGLE_PRESS
}
@Test
fun `decode compact format 0x20 combined 0x02`() {
val ecm = decodeSetting<EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 20 02 00 00"))
ecm.muteMic shouldBe MuteMicMode.SINGLE_PRESS
ecm.endCall shouldBe EndCallMode.DOUBLE_PRESS
}
@Test
fun `decode compact format 0x20 combined 0x03`() {
val ecm = decodeSetting<EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 20 03 00 00"))
ecm.muteMic shouldBe MuteMicMode.DOUBLE_PRESS
ecm.endCall shouldBe EndCallMode.SINGLE_PRESS
}
@Test
fun `decode compact format subtype 0x00 from real Pro 3`() {
val ecm = decodeSetting<EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 00 03 00 00"))
ecm.muteMic shouldBe MuteMicMode.DOUBLE_PRESS
ecm.endCall shouldBe EndCallMode.SINGLE_PRESS
}
@Test
fun `decode unknown combined returns null`() {
profile.decodeSetting(aapMessage("04 00 04 00 09 00 24 20 05 00 00")).shouldBeNull()
}
}
// ── Conversation Awareness State (0x4B) ──────────────────
@Nested
inner class ConversationAwarenessStateTests {
@Test fun `speaking start`() { decodeSetting<ConversationalAwarenessState>(aapMessage("04 00 04 00 4B 00 01")).speaking shouldBe true }
@Test fun `speaking stop`() { decodeSetting<ConversationalAwarenessState>(aapMessage("04 00 04 00 4B 00 04")).speaking shouldBe false }
@Test fun `empty payload returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 4B 00")).shouldBeNull() }
}
// ── Battery ──────────────────────────────────────────────
@Nested
inner class BatteryTests {
@Test
fun `decode single left pod`() {
val r = profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 85, 0x02, 0x00))!!
r[BatteryType.LEFT]!!.percent shouldBe 0.85f
r[BatteryType.LEFT]!!.charging shouldBe ChargingState.NOT_CHARGING
}
@Test
fun `decode dual pods plus case`() {
val r = profile.decodeBattery(batteryMessage(
0x03,
0x02, 0x00, 90, 0x01, 0x00,
0x04, 0x00, 80, 0x02, 0x00,
0x08, 0x00, 50, 0x02, 0x00,
))!!
r.size shouldBe 3
r[BatteryType.RIGHT]!!.percent shouldBe 0.9f
r[BatteryType.RIGHT]!!.charging shouldBe ChargingState.CHARGING
r[BatteryType.LEFT]!!.percent shouldBe 0.8f
r[BatteryType.CASE]!!.percent shouldBe 0.5f
}
@Test
fun `decode headset (single)`() {
val r = profile.decodeBattery(batteryMessage(0x01, 0x01, 0x00, 60, 0x04, 0x00))!!
r[BatteryType.SINGLE]!!.percent shouldBe 0.6f
r[BatteryType.SINGLE]!!.charging shouldBe ChargingState.DISCONNECTED
}
@Test fun `skips percent above 100`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 127, 0x02, 0x00))!! shouldBe emptyMap() }
@Test fun `skips percent 255`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 0xFF, 0x04, 0x00))!! shouldBe emptyMap() }
@Test fun `empty count`() { profile.decodeBattery(batteryMessage(0x00))!! shouldBe emptyMap() }
@Test fun `skips unknown type`() { profile.decodeBattery(batteryMessage(0x01, 0x10, 0x00, 50, 0x02, 0x00))!! shouldBe emptyMap() }
@Test fun `handles truncated entry`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 50))!! shouldBe emptyMap() }
@Test fun `non-battery message returns null`() { profile.decodeBattery(settingsMessage(0x0D, 0x02)).shouldBeNull() }
@Test fun `zero percent is valid`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 0, 0x01, 0x00))!![BatteryType.LEFT]!!.percent shouldBe 0f }
@Test fun `100 percent is valid`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 100, 0x02, 0x00))!![BatteryType.LEFT]!!.percent shouldBe 1f }
@Test
fun `CHARGING_OPTIMIZED state 0x05`() {
val r = profile.decodeBattery(batteryMessage(0x03, 0x04, 0x01, 80, 0x05, 0x01, 0x02, 0x01, 79, 0x05, 0x01, 0x08, 0x01, 48, 0x01, 0x01))!!
r[BatteryType.LEFT]!!.charging shouldBe ChargingState.CHARGING_OPTIMIZED
r[BatteryType.RIGHT]!!.charging shouldBe ChargingState.CHARGING_OPTIMIZED
r[BatteryType.CASE]!!.charging shouldBe ChargingState.CHARGING
}
@Test
fun `pods out of case, case disconnected`() {
val r = profile.decodeBattery(batteryMessage(0x03, 0x04, 0x01, 80, 0x02, 0x01, 0x02, 0x01, 77, 0x02, 0x01, 0x08, 0x01, 0, 0x04, 0x01))!!
r[BatteryType.CASE]!!.percent shouldBe 0f
r[BatteryType.CASE]!!.charging shouldBe ChargingState.DISCONNECTED
}
@Test
fun `real 22-byte message from Pro 3`() {
val msg = aapMessage("04 00 04 00 04 00 03 04 01 50 05 01 02 01 4F 05 01 08 01 30 01 01")
val r = profile.decodeBattery(msg)!!
r.size shouldBe 3
r[BatteryType.LEFT]!!.percent shouldBe 0.8f
r[BatteryType.RIGHT]!!.percent shouldBe 0.79f
r[BatteryType.CASE]!!.percent shouldBe 0.48f
}
}
// ── Private Keys ─────────────────────────────────────────
@Nested
inner class PrivateKeyTests {
@Test
fun `encode request`() {
val bytes = profile.encodePrivateKeyRequest()!!
bytes[4] shouldBe 0x30.toByte()
bytes.size shouldBe 8
}
@Test
fun `decode response with IRK and ENC`() {
val irk = ByteArray(16) { 0x11.toByte() }
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.parse(raw)!!)!!
result.irk shouldBe irk
result.encKey shouldBe enc
}
@Test
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.parse(raw)!!)!!
result.irk shouldBe irk
result.encKey.shouldBeNull()
}
@Test fun `unknown key type returns null`() { profile.decodePrivateKeyResponse(aapMessage("04 00 04 00 31 00 01 07 00 10 00 ${" 00".repeat(16).trim()}")).shouldBeNull() }
@Test fun `wrong key length returns null`() { profile.decodePrivateKeyResponse(aapMessage("04 00 04 00 31 00 01 01 00 08 00 ${" 00".repeat(8).trim()}")).shouldBeNull() }
@Test fun `non-key message returns null`() { profile.decodePrivateKeyResponse(settingsMessage(0x0D, 0x02)).shouldBeNull() }
}
// ── Edge Cases ───────────────────────────────────────────
@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() }
}
@@ -0,0 +1,260 @@
package eu.darken.capod.reaction.core.aap
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.monitor.core.BlePodMonitor
import eu.darken.capod.pods.core.BlePodSnapshot
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class AapAutoConnectTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var aapManager: AapConnectionManager
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var blePodMonitor: BlePodMonitor
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
private lateinit var disconnectEventsFlow: MutableSharedFlow<String>
private lateinit var allStatesFlow: MutableStateFlow<Map<String, AapPodState>>
private val testAddress = "AA:BB:CC:DD:EE:FF"
private val testProfile = AppleDeviceProfile(
label = "Test AirPods",
model = PodModel.AIRPODS_PRO3,
address = testAddress,
)
private val testBondedDevice: BluetoothDevice2 = mockk(relaxed = true) {
every { address } returns testAddress
}
@BeforeEach
fun setup() {
profilesFlow = MutableStateFlow(emptyList())
disconnectEventsFlow = MutableSharedFlow(extraBufferCapacity = 16)
allStatesFlow = MutableStateFlow(emptyMap())
aapManager = mockk(relaxed = true) {
every { allStates } returns allStatesFlow
every { disconnectEvents } returns disconnectEventsFlow
}
profilesRepo = mockk {
every { profiles } returns profilesFlow
}
bluetoothManager = mockk {
every { bondedDevices() } returns flowOf(setOf(testBondedDevice))
}
blePodMonitor = mockk {
every { devices } returns flowOf(
listOf(mockk<BlePodSnapshot>(relaxed = true) { every { address } returns testAddress })
)
}
}
private fun createAutoConnect() = AapAutoConnect(
aapManager = aapManager,
profilesRepo = profilesRepo,
bluetoothManager = bluetoothManager,
blePodMonitor = blePodMonitor,
)
@Nested
inner class InitialConnect {
@Test
fun `connects when profiled device is bonded`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
job.cancel()
}
@Test
fun `skips profiles without address`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val noAddressProfile = AppleDeviceProfile(label = "No Address", model = PodModel.AIRPODS_PRO3)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(noAddressProfile)
advanceUntilIdle()
coVerify(exactly = 0) { aapManager.connect(any(), any(), any()) }
job.cancel()
}
@Test
fun `skips profiles not in bonded devices`() = runTest(testDispatcher) {
every { bluetoothManager.bondedDevices() } returns flowOf(emptySet())
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
coVerify(exactly = 0) { aapManager.connect(any(), any(), any()) }
job.cancel()
}
@Test
fun `skips already connected devices`() = runTest(testDispatcher) {
allStatesFlow.value = mapOf(
testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY)
)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
coVerify(exactly = 0) { aapManager.connect(any(), any(), any()) }
job.cancel()
}
@Test
fun `does not skip disconnected devices`() = runTest(testDispatcher) {
allStatesFlow.value = mapOf(
testAddress to AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
job.cancel()
}
}
@Nested
inner class Reconnect {
/**
* For reconnect tests: set the device as "already connected" in allStates
* so initialConnect() skips it, then clear allStates before emitting disconnect event.
*/
private fun kotlinx.coroutines.test.TestScope.setupForReconnect(autoConnect: AapAutoConnect): kotlinx.coroutines.Job {
// Pre-set as connected so initialConnect doesn't fire for this address
allStatesFlow.value = mapOf(
testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY)
)
profilesFlow.value = listOf(testProfile)
return launch { autoConnect.monitor().toList() }
}
@Test
fun `reconnect stops when device no longer profiled`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val job = setupForReconnect(autoConnect)
advanceUntilIdle()
// Clear profiles, then disconnect
profilesFlow.value = emptyList()
allStatesFlow.value = emptyMap()
disconnectEventsFlow.tryEmit(testAddress)
advanceUntilIdle()
// connect should only have been called by the profile change trigger (which also finds no profiles)
// The reconnect loop should not call connect since profile is gone
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
job.cancel()
}
@Test
fun `reconnect stops when device no longer bonded`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val job = setupForReconnect(autoConnect)
advanceUntilIdle()
// Remove bonded device, then disconnect
every { bluetoothManager.bondedDevices() } returns flowOf(emptySet())
allStatesFlow.value = emptyMap()
disconnectEventsFlow.tryEmit(testAddress)
advanceUntilIdle()
// Reconnect should not call connect since not bonded
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
job.cancel()
}
@Test
fun `reconnect stops when device no longer visible in BLE`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val job = setupForReconnect(autoConnect)
advanceUntilIdle()
// Remove from BLE scans, then disconnect
every { blePodMonitor.devices } returns flowOf(emptyList())
allStatesFlow.value = emptyMap()
disconnectEventsFlow.tryEmit(testAddress)
advanceUntilIdle()
// Reconnect should not call connect since not visible in BLE
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
job.cancel()
}
@Test
fun `reconnect stops when device already reconnected`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val job = setupForReconnect(autoConnect)
advanceUntilIdle()
// Keep as READY, emit disconnect event
// allStatesFlow still shows READY → reconnect should skip
disconnectEventsFlow.tryEmit(testAddress)
advanceUntilIdle()
// Should not attempt connect — already connected
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
job.cancel()
}
}
}