mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user