From 1931e60b074bffc8c32d798f4ee1d02c0ffa6f24 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 31 Mar 2026 22:04:29 +0200 Subject: [PATCH] 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 --- .../monitor/core/worker/MonitorService.kt | 12 +- .../capod/reaction/core/aap/AapAutoConnect.kt | 119 ++++++++++++------ .../reaction/core/aap/AapAutoConnectTest.kt | 83 +++++++++++- 3 files changed, 164 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 617fd463..469a4f20 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -34,6 +34,7 @@ import eu.darken.capod.monitor.core.primaryDevice import eu.darken.capod.monitor.ui.MonitorNotifications import eu.darken.capod.profiles.core.DeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.reaction.core.aap.AapAutoConnect import eu.darken.capod.reaction.core.aap.AapKeyPersister import eu.darken.capod.reaction.core.autoconnect.AutoConnect @@ -76,6 +77,7 @@ class MonitorService : Service() { @Inject lateinit var profilesRepo: DeviceProfilesRepo @Inject lateinit var aapAutoConnect: AapAutoConnect @Inject lateinit var aapKeyPersister: AapKeyPersister + @Inject lateinit var aapConnectionManager: AapConnectionManager private val monitorScope = MonitorCoroutineScope() private var monitoringJob: Job? = null @@ -215,8 +217,9 @@ class MonitorService : Service() { generalSettings.monitorMode.flow, profilesRepo.profiles, bluetoothManager.connectedDevices, - ) { monitorMode, profiles, connectedDevices -> - listOf(monitorMode, profiles, connectedDevices) + aapConnectionManager.allStates, + ) { monitorMode, profiles, connectedDevices, aapStates -> + listOf(monitorMode, profiles, connectedDevices, aapStates) } } } @@ -230,6 +233,9 @@ class MonitorService : Service() { @Suppress("UNCHECKED_CAST") val devices = arguments[2] as Collection + @Suppress("UNCHECKED_CAST") + val aapStates = arguments[3] as Map<*, *> + val connectedAddresses = devices.map { it.address }.toSet() val knownAddresses = profiles.mapNotNull { it.address }.toSet() log(TAG) { "Monitor mode: $monitorMode" } @@ -248,7 +254,7 @@ class MonitorService : Service() { log(TAG, WARN) { "Main device address not set, staying alive while any is connected" } } - knownAddresses.any { it in connectedAddresses } -> { + knownAddresses.any { it in connectedAddresses } || aapStates.isNotEmpty() -> { log(TAG) { "A device is connected, aborting any timeout." } } diff --git a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt index f96e2537..0f121301 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt @@ -12,15 +12,19 @@ import eu.darken.capod.pods.core.apple.aap.AapConnectionManager import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds import javax.inject.Inject import javax.inject.Singleton @@ -43,50 +47,73 @@ class AapAutoConnect @Inject constructor( private fun initialConnect(): Flow = combine( profilesRepo.profiles, bluetoothManager.connectedDevices, - ) { profiles, _ -> profiles } - .map { profiles -> + ) { profiles, connectedDevices -> + val connectedAddresses = connectedDevices.map { it.address }.toSet() + profiles.filter { it.address in connectedAddresses } + } + .mapLatest { profiles -> val bondedDevices = bluetoothManager.bondedDevices().first() - for (profile in profiles) { - val address = profile.address ?: continue - val bonded = bondedDevices.firstOrNull { it.address == address } ?: continue - - val currentState = aapManager.allStates.value[address] - if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) { - log(TAG, VERBOSE) { "AAP already connected/connecting to $address" } - continue - } - - log(TAG) { "AAP connecting to $address (${profile.label})" } - try { - aapManager.connect(address, bonded.internal, profile.model) - log(TAG) { "AAP connected to $address" } - } catch (e: Exception) { - log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" } - - for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) { - delay(delayMs) - - val retryState = aapManager.allStates.value[address] - if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) { - log(TAG) { "AAP initial retry: $address already reconnected, stopping" } - break - } - - try { - log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" } - aapManager.connect(address, bonded.internal, profile.model) - log(TAG) { "AAP connected to $address on retry ${attempt + 1}" } - break - } catch (retryException: Exception) { - log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" } - } - } + coroutineScope { + for (profile in profiles) { + launch { connectWithRetries(profile, bondedDevices) } } } } .setupCommonEventHandlers(TAG) { "initialConnect" } + private suspend fun connectWithRetries( + profile: eu.darken.capod.profiles.core.DeviceProfile, + bondedDevices: Set, + ) { + val address = profile.address ?: return + val bonded = bondedDevices.firstOrNull { it.address == address } ?: return + + val currentState = aapManager.allStates.value[address] + if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) { + log(TAG, VERBOSE) { "AAP already connected/connecting to $address" } + return + } + + log(TAG) { "AAP connecting to $address (${profile.label})" } + try { + withTimeout(CONNECT_TIMEOUT) { + aapManager.connect(address, bonded.internal, profile.model) + } + log(TAG) { "AAP connected to $address" } + } catch (e: Exception) { + log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" } + + for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) { + delay(delayMs) + + // Bail out if classic BT disconnected + val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet() + if (address !in currentConnected) { + log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" } + break + } + + val retryState = aapManager.allStates.value[address] + if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) { + log(TAG) { "AAP initial retry: $address already reconnected, stopping" } + break + } + + try { + log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" } + withTimeout(CONNECT_TIMEOUT) { + aapManager.connect(address, bonded.internal, profile.model) + } + log(TAG) { "AAP connected to $address on retry ${attempt + 1}" } + break + } catch (retryException: Exception) { + log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" } + } + } + } + } + private fun reconnectOnDisconnect(): Flow = aapManager.disconnectEvents .onEach { address -> if (!activeReconnects.add(address)) { @@ -113,6 +140,13 @@ class AapAutoConnect @Inject constructor( break } + // Check if still classically connected + val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet() + if (address !in currentConnected) { + log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" } + break + } + // Check if still visible in BLE val bleDevices = blePodMonitor.devices.first() if (bleDevices.none { it.meta?.profile?.address == address }) { @@ -129,7 +163,9 @@ class AapAutoConnect @Inject constructor( try { log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" } - aapManager.connect(address, bonded.internal, profile.model) + withTimeout(CONNECT_TIMEOUT) { + aapManager.connect(address, bonded.internal, profile.model) + } log(TAG) { "AAP reconnected to $address" } break } catch (e: Exception) { @@ -176,7 +212,7 @@ class AapAutoConnect @Inject constructor( profilesRepo.updateProfile(profile.copy(model = detectedModel)) log(TAG) { "AAP model corrected for $address: ${profile.model} -> $detectedModel" } - // Reconnect explicitly — initialConnect may be blocked by retry loops for other devices + // Reconnect explicitly val bonded = bluetoothManager.bondedDevices().first() .firstOrNull { it.address == address } if (bonded != null) { @@ -193,5 +229,6 @@ class AapAutoConnect @Inject constructor( companion object { private val TAG = logTag("Reaction", "AapAutoConnect") internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000) + private val CONNECT_TIMEOUT = 5.seconds } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt index 70e385dc..8b573892 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt @@ -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()) } 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() }