feat: Speed up AAP L2CAP reconnection

Only attempt AAP connections to classically-connected devices, eliminating futile retries for devices connected to other phones.

- Filter initialConnect() to devices in connectedAddresses

- Use mapLatest so stale retry loops cancel on state changes

- Parallelize per-profile connection attempts

- Add 5s connect timeout (best-effort) to cap retry cycles

- Add classic BT check to reconnectOnDisconnect()

- Keep MonitorService alive when AAP connections are active
This commit is contained in:
darken
2026-03-31 22:06:23 +02:00
committed by Matthias Urhahn
parent 7bdc9ca212
commit 1931e60b07
3 changed files with 164 additions and 50 deletions
@@ -100,9 +100,10 @@ class AapAutoConnectTest : BaseTest() {
inner class InitialConnect {
@Test
fun `connects when profiled device is bonded`() = runTest(testDispatcher) {
fun `connects when profiled device is classically connected`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
@@ -112,12 +113,27 @@ class AapAutoConnectTest : BaseTest() {
job.cancel()
}
@Test
fun `skips profiles not classically connected`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
// No classic BT connection
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
coVerify(exactly = 0) { aapManager.connect(any(), any(), any()) }
job.cancel()
}
@Test
fun `skips profiles without address`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val noAddressProfile = AppleDeviceProfile(label = "No Address", model = PodModel.AIRPODS_PRO3)
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(noAddressProfile)
advanceUntilIdle()
@@ -133,6 +149,7 @@ class AapAutoConnectTest : BaseTest() {
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
@@ -150,6 +167,7 @@ class AapAutoConnectTest : BaseTest() {
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
@@ -168,15 +186,15 @@ class AapAutoConnectTest : BaseTest() {
val job = launch { autoConnect.monitor().toList() }
advanceUntilIdle()
// Initial connect fires but L2CAP may fail (or succeed — either way, verify connect is called)
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
// Should NOT connect — device not classically connected
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
// Simulate classic BT connecting later
connectedDevicesFlow.value = listOf(testBondedDevice)
advanceUntilIdle()
// Should attempt connect again
coVerify(exactly = 2) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
// Now should attempt connect
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
job.cancel()
}
@@ -189,6 +207,7 @@ class AapAutoConnectTest : BaseTest() {
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
@@ -207,6 +226,7 @@ class AapAutoConnectTest : BaseTest() {
coEvery { aapManager.connect(any(), any(), any()) } throws RuntimeException("ACL connection failed")
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
@@ -227,6 +247,7 @@ class AapAutoConnectTest : BaseTest() {
}
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
@@ -253,6 +274,7 @@ class AapAutoConnectTest : BaseTest() {
}
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
@@ -264,9 +286,35 @@ class AapAutoConnectTest : BaseTest() {
job.cancel()
}
@Test
fun `stops retry when classic BT disconnects`() = runTest(testDispatcher) {
var callCount = 0
coEvery { aapManager.connect(any(), any(), any()) } coAnswers {
callCount++
if (callCount == 1) {
// Simulate classic BT disconnecting during the retry delay
connectedDevicesFlow.value = emptyList()
throw RuntimeException("ACL connection failed")
}
}
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
// 1 initial attempt, retries bail out because classic BT disconnected
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
job.cancel()
}
@Test
fun `does not retry when initial connect succeeds`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
connectedDevicesFlow.value = listOf(testBondedDevice)
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
@@ -291,6 +339,7 @@ class AapAutoConnectTest : BaseTest() {
allStatesFlow.value = mapOf(
testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY)
)
connectedDevicesFlow.value = listOf(testBondedDevice)
profilesFlow.value = listOf(testProfile)
return launch { autoConnect.monitor().toList() }
}
@@ -307,7 +356,6 @@ class AapAutoConnectTest : BaseTest() {
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()) }
@@ -332,6 +380,24 @@ class AapAutoConnectTest : BaseTest() {
job.cancel()
}
@Test
fun `reconnect stops when device no longer classically connected`() = runTest(testDispatcher) {
val autoConnect = createAutoConnect()
val job = setupForReconnect(autoConnect)
advanceUntilIdle()
// Remove classic BT connection, then disconnect
connectedDevicesFlow.value = emptyList()
allStatesFlow.value = emptyMap()
disconnectEventsFlow.tryEmit(testAddress)
advanceUntilIdle()
// Reconnect should not call connect since not classically connected
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()
@@ -387,6 +453,7 @@ class AapAutoConnectTest : BaseTest() {
address = testAddress,
)
profilesFlow.value = listOf(wrongModelProfile)
connectedDevicesFlow.value = listOf(testBondedDevice)
var capturedProfile: AppleDeviceProfile? = null
coEvery { profilesRepo.updateProfile(ofType<AppleDeviceProfile>()) } coAnswers {
@@ -417,6 +484,7 @@ class AapAutoConnectTest : BaseTest() {
fun `does not correct when model already matches`() = runTest(testDispatcher) {
// Profile already has correct model
profilesFlow.value = listOf(testProfile) // PodModel.AIRPODS_PRO3
connectedDevicesFlow.value = listOf(testBondedDevice)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
@@ -439,6 +507,7 @@ class AapAutoConnectTest : BaseTest() {
@Test
fun `does not correct when modelNumber is unrecognized`() = runTest(testDispatcher) {
profilesFlow.value = listOf(testProfile)
connectedDevicesFlow.value = listOf(testBondedDevice)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
@@ -465,6 +534,7 @@ class AapAutoConnectTest : BaseTest() {
address = testAddress,
)
profilesFlow.value = listOf(wrongModelProfile)
connectedDevicesFlow.value = listOf(testBondedDevice)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
@@ -498,6 +568,7 @@ class AapAutoConnectTest : BaseTest() {
address = testAddress,
)
profilesFlow.value = listOf(wrongModelProfile)
connectedDevicesFlow.value = listOf(testBondedDevice)
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }