feat: Add retry logic for AAP L2CAP connection failures

Initial connect had no retry — a single failed L2CAP attempt was silently swallowed. Reconnect-on-disconnect used separate longer backoff delays.

Both paths now share the same retry schedule (3s,3s,3s,5s,5s,10s,10s) giving 7 retries over ~39s. Initial connect checks if another path already reconnected before each retry.
This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent fef6c97d2d
commit 04ea3d0cd8
2 changed files with 104 additions and 5 deletions
@@ -51,12 +51,31 @@ class AapAutoConnect @Inject constructor(
continue
}
log(TAG) { "AAP connecting to $address (${profile.label})" }
try {
log(TAG) { "AAP connecting to $address (${profile.label})" }
aapManager.connect(address, bonded.internal, profile.model)
log(TAG) { "AAP connected to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "AAP connect failed for $address: ${e.message}" }
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}" }
}
}
}
}
}
@@ -69,9 +88,7 @@ class AapAutoConnect @Inject constructor(
return@onEach
}
val backoffDelays = longArrayOf(5_000, 10_000, 30_000, 60_000)
for ((attempt, delayMs) in backoffDelays.withIndex()) {
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
delay(delayMs)
// Check if still profiled
@@ -121,5 +138,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)
}
}
@@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.apple.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.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
@@ -197,6 +198,86 @@ class AapAutoConnectTest : BaseTest() {
}
}
@Nested
inner class InitialConnectRetry {
@Test
fun `retries on initial connect failure then stops`() = runTest(testDispatcher) {
coEvery { aapManager.connect(any(), any(), any()) } throws RuntimeException("ACL connection failed")
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
// 1 initial + 7 retries = 8 total
coVerify(exactly = 8) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
job.cancel()
}
@Test
fun `succeeds on second retry`() = runTest(testDispatcher) {
var callCount = 0
coEvery { aapManager.connect(any(), any(), any()) } coAnswers {
callCount++
if (callCount <= 2) throw RuntimeException("ACL connection failed")
}
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
// 1 initial fail + 1 retry fail + 1 retry success = 3
coVerify(exactly = 3) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
job.cancel()
}
@Test
fun `stops retry when already reconnected by another path`() = runTest(testDispatcher) {
var callCount = 0
coEvery { aapManager.connect(any(), any(), any()) } coAnswers {
callCount++
if (callCount == 1) {
// Simulate another path reconnecting during the delay
allStatesFlow.value = mapOf(
testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY)
)
throw RuntimeException("ACL connection failed")
}
}
val autoConnect = createAutoConnect()
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
// 1 initial attempt, retries bail out because allStates shows READY
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()
val job = launch { autoConnect.monitor().toList() }
profilesFlow.value = listOf(testProfile)
advanceUntilIdle()
// Only 1 call, no retries needed
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
job.cancel()
}
}
@Nested
inner class Reconnect {