mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
@@ -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 {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user