From 04ea3d0cd8de778b9a00e4fe64d8d985fee00d57 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 31 Mar 2026 19:10:59 +0200 Subject: [PATCH] feat: Add retry logic for AAP L2CAP connection failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../capod/reaction/core/aap/AapAutoConnect.kt | 28 +++++-- .../reaction/core/aap/AapAutoConnectTest.kt | 81 +++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) 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 e206d453..4b5102f2 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 @@ -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) } } 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 6fc1a0f9..7ccb55c0 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 @@ -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 {