mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
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:
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+120
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+213
@@ -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))
|
||||
}
|
||||
+385
-74
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user