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
@@ -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<BluetoothDevice2>
@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." }
}
@@ -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<Unit> = 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<eu.darken.capod.common.bluetooth.BluetoothDevice2>,
) {
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<Unit> = 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
}
}
@@ -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() }