Skip to content

Commit 99ae693

Browse files
authored
Retry PC auth before clearing saved pairing
- Retry transient PC auth failures up to five total attempts before clearing saved tokens - Apply auth-only retries to saved-token reconnects, QR/manual connect, service commands, and live PC control sessions - Cover retry exhaustion, retry recovery, and non-auth failures that keep saved pairings intact 🤖 Auto-generated
1 parent de67147 commit 99ae693

7 files changed

Lines changed: 263 additions & 11 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.enaboapps.switchify.pc
2+
3+
internal const val PC_AUTH_RETRY_ATTEMPTS = 5
4+
5+
internal suspend fun <T> retryPcAuthFailure(
6+
attempts: Int = PC_AUTH_RETRY_ATTEMPTS,
7+
block: suspend () -> T,
8+
isAuthFailure: (T) -> Boolean
9+
): T {
10+
require(attempts > 0)
11+
var result = block()
12+
var attempt = 1
13+
while (attempt < attempts && isAuthFailure(result)) {
14+
result = block()
15+
attempt++
16+
}
17+
return result
18+
}

app/src/main/java/com/enaboapps/switchify/pc/PcConnectionViewModel.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,10 @@ class PcConnectionViewModel(
266266
)
267267
when (val pairing = connector.requestApproval(pc, requestNonce)) {
268268
is PcPairingResult.Paired -> {
269-
when (val ping = connector.authenticatedPing(pc, pairing.token)) {
269+
when (val ping = retryPcAuthFailure(
270+
block = { connector.authenticatedPing(pc, pairing.token) },
271+
isAuthFailure = { it is PcPingResult.AuthFailed }
272+
)) {
270273
is PcPingResult.Connected -> {
271274
tokenStore.saveToken(pc.desktopId, pairing.token, ping.websocketUrl, pc.displayName)
272275
tokenRevision.update { it + 1 }
@@ -296,7 +299,10 @@ class PcConnectionViewModel(
296299
return
297300
}
298301
setBusy(pc.desktopId, PcRowStatus.Connecting, null)
299-
when (val result = connector.authenticatedPing(pc, token)) {
302+
when (val result = retryPcAuthFailure(
303+
block = { connector.authenticatedPing(pc, token) },
304+
isAuthFailure = { it is PcPingResult.AuthFailed }
305+
)) {
300306
is PcPingResult.Connected -> {
301307
tokenStore.saveToken(pc.desktopId, token, result.websocketUrl, pc.displayName)
302308
tokenRevision.update { it + 1 }

app/src/main/java/com/enaboapps/switchify/pc/PcServiceConnectionController.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ class PcServiceConnectionController(
126126
suspend fun sendCommand(command: PcControlCommand): PcCommandResult {
127127
val connected = PcConnectionStateHolder.connectionState.value as? PcConnectionState.Connected
128128
?: return PcCommandResult.AuthFailed()
129-
val result = connector.sendCommand(connected.session, command)
129+
val result = retryPcAuthFailure(
130+
block = { connector.sendCommand(connected.session, command) },
131+
isAuthFailure = { it is PcCommandResult.AuthFailed }
132+
)
130133
if (result is PcCommandResult.AuthFailed) {
131134
tokenStore.clearToken(connected.session.desktopId)
132135
PcConnectionStateHolder.setDisconnected()
@@ -171,7 +174,10 @@ class PcServiceConnectionController(
171174
}
172175

173176
private suspend fun connectWithToken(pc: DiscoveredPc, token: String): PcServiceConnectResult {
174-
return when (val result = connector.authenticatedPing(pc, token)) {
177+
return when (val result = retryPcAuthFailure(
178+
block = { connector.authenticatedPing(pc, token) },
179+
isAuthFailure = { it is PcPingResult.AuthFailed }
180+
)) {
175181
is PcPingResult.Connected -> {
176182
tokenStore.saveToken(pc.desktopId, token, result.websocketUrl, pc.displayName)
177183
val session = PcAuthenticatedSession(pc.desktopId, identityRepository.getDeviceId(), result.websocketUrl)

app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.enaboapps.switchify.pc.PcTokenStore
1818
import com.enaboapps.switchify.pc.SwitchifyPcClient
1919
import com.enaboapps.switchify.pc.PC_KEYBOARD_TYPE_TEXT_MAX_LENGTH
2020
import com.enaboapps.switchify.pc.isSafePcTypedText
21+
import com.enaboapps.switchify.pc.retryPcAuthFailure
2122
import kotlinx.coroutines.CompletableDeferred
2223
import kotlinx.coroutines.Deferred
2324
import kotlinx.coroutines.async
@@ -200,7 +201,14 @@ class PcMouseControlViewModel(
200201
_uiState.update { it.copy(isBusy = true, busyCommand = command) }
201202
viewModelScope.launch {
202203
val connection = liveConnection ?: ensureLiveConnection(connected.session).await()
203-
val result = connection?.sendCommand(command) ?: PcCommandResult.Failed()
204+
val result = if (connection == null) {
205+
PcCommandResult.Failed()
206+
} else {
207+
retryPcAuthFailure(
208+
block = { connection.sendCommand(command) },
209+
isAuthFailure = { it is PcCommandResult.AuthFailed }
210+
)
211+
}
204212
when (result) {
205213
PcCommandResult.Ack -> {
206214
_uiState.update(onAck)
@@ -262,7 +270,10 @@ class PcMouseControlViewModel(
262270
closeLiveConnection()
263271
liveSession = session
264272
return viewModelScope.async {
265-
when (val result = connector.openControlSession(session)) {
273+
when (val result = retryPcAuthFailure(
274+
block = { connector.openControlSession(session) },
275+
isAuthFailure = { it is PcLiveControlResult.AuthFailed }
276+
)) {
266277
is PcLiveControlResult.Connected -> {
267278
liveConnection = result.connection
268279
movementSteps = result.connection.pointerProfile?.toMouseMovementSteps() ?: FALLBACK_MOVEMENT_STEPS

app/src/test/java/com/enaboapps/switchify/pc/PcConnectionViewModelTest.kt

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,48 @@ class PcConnectionViewModelTest {
158158
viewModel.connectWithSavedToken(pc)
159159
advanceUntilIdle()
160160

161+
assertEquals(PC_AUTH_RETRY_ATTEMPTS, connector.pingCalls)
161162
assertNull(tokens.getToken("desktop-1"))
162163
assertEquals("Connection expired. Request access again.", viewModel.uiState.value.message)
163164
assertEquals("Request access", viewModel.uiState.value.discoveredPcs.first().actionText)
164165
}
165166

167+
@Test
168+
fun savedTokenAuthFailureThenSuccessKeepsToken() = runTest(dispatcher) {
169+
val discovery = FakeDiscovery(listOf(pc))
170+
val tokens = FakeTokenStore(initialTokens = mutableMapOf("desktop-1" to "token"))
171+
val connector = FakeConnector(
172+
pingResults = List(PC_AUTH_RETRY_ATTEMPTS - 1) { PcPingResult.AuthFailed() } +
173+
PcPingResult.Connected("ws://192.168.1.20:7347")
174+
)
175+
val viewModel = viewModel(discovery, tokens, connector)
176+
advanceUntilIdle()
177+
178+
viewModel.connectWithSavedToken(pc)
179+
advanceUntilIdle()
180+
181+
assertEquals(PC_AUTH_RETRY_ATTEMPTS, connector.pingCalls)
182+
assertEquals("token", tokens.getToken("desktop-1"))
183+
assertEquals("desktop-1", viewModel.uiState.value.connectedDesktopId)
184+
assertEquals("Connected", viewModel.uiState.value.discoveredPcs.first().actionText)
185+
}
186+
187+
@Test
188+
fun savedTokenNonAuthFailureDoesNotClearToken() = runTest(dispatcher) {
189+
val discovery = FakeDiscovery(listOf(pc))
190+
val tokens = FakeTokenStore(initialTokens = mutableMapOf("desktop-1" to "token"))
191+
val connector = FakeConnector(pingResult = PcPingResult.Failed(PcErrorReason.Unreachable, "Found PC, but could not connect."))
192+
val viewModel = viewModel(discovery, tokens, connector)
193+
advanceUntilIdle()
194+
195+
viewModel.connectWithSavedToken(pc)
196+
advanceUntilIdle()
197+
198+
assertEquals(1, connector.pingCalls)
199+
assertEquals("token", tokens.getToken("desktop-1"))
200+
assertEquals("Found PC, but could not connect.", viewModel.uiState.value.message)
201+
}
202+
166203
@Test
167204
fun savedPairingsListsStoredPcWhenNotDiscovered() = runTest(dispatcher) {
168205
val discovery = FakeDiscovery(emptyList())
@@ -314,6 +351,47 @@ class PcConnectionViewModelTest {
314351
assertEquals("desktop-1", viewModel.uiState.value.connectedDesktopId)
315352
}
316353

354+
@Test
355+
fun postPairingAuthFailureRetriesBeforeClearingToken() = runTest(dispatcher) {
356+
val discovery = FakeDiscovery(listOf(pc))
357+
val tokens = FakeTokenStore()
358+
val connector = FakeConnector(
359+
pairingResult = PcPairingResult.Paired("desktop-1", "token", "ws://192.168.1.20:7347"),
360+
pingResult = PcPingResult.AuthFailed()
361+
)
362+
val viewModel = viewModel(discovery, tokens, connector)
363+
advanceUntilIdle()
364+
365+
viewModel.requestAccess(pc)
366+
advanceUntilIdle()
367+
368+
assertEquals(PC_AUTH_RETRY_ATTEMPTS, connector.pingCalls)
369+
assertNull(tokens.getToken("desktop-1"))
370+
assertEquals("Connection expired. Request access again.", viewModel.uiState.value.message)
371+
}
372+
373+
@Test
374+
fun postPairingAuthFailureThenSuccessSavesToken() = runTest(dispatcher) {
375+
val discovery = FakeDiscovery(listOf(pc))
376+
val tokens = FakeTokenStore()
377+
val connector = FakeConnector(
378+
pairingResult = PcPairingResult.Paired("desktop-1", "token", "ws://192.168.1.20:7347"),
379+
pingResults = listOf(
380+
PcPingResult.AuthFailed(),
381+
PcPingResult.Connected("ws://192.168.1.20:7347")
382+
)
383+
)
384+
val viewModel = viewModel(discovery, tokens, connector)
385+
advanceUntilIdle()
386+
387+
viewModel.requestAccess(pc)
388+
advanceUntilIdle()
389+
390+
assertEquals(2, connector.pingCalls)
391+
assertEquals("token", tokens.getToken("desktop-1"))
392+
assertEquals("desktop-1", viewModel.uiState.value.connectedDesktopId)
393+
}
394+
317395
@Test
318396
fun connectFromQrPayloadWithSavedTokenPingsWithoutPairing() = runTest(dispatcher) {
319397
val discovery = FakeDiscovery(emptyList())
@@ -476,8 +554,10 @@ class PcConnectionViewModelTest {
476554
private class FakeConnector(
477555
private val pairingResult: PcPairingResult = PcPairingResult.Failed(PcErrorReason.PairingRejected, "Request rejected."),
478556
private val pingResult: PcPingResult = PcPingResult.Failed(PcErrorReason.Unreachable, "Found PC, but could not connect."),
479-
private val pairingDeferred: CompletableDeferred<PcPairingResult>? = null
557+
private val pairingDeferred: CompletableDeferred<PcPairingResult>? = null,
558+
pingResults: List<PcPingResult> = emptyList()
480559
) : PcConnector {
560+
private val queuedPingResults = ArrayDeque(pingResults)
481561
var requestApprovalCalls = 0
482562
var pingCalls = 0
483563
val requestNonces = mutableListOf<String>()
@@ -494,7 +574,7 @@ class PcConnectionViewModelTest {
494574
override suspend fun authenticatedPing(pc: DiscoveredPc, token: String): PcPingResult {
495575
pingCalls++
496576
pingPcs.add(pc)
497-
return pingResult
577+
return queuedPingResults.removeFirstOrNull() ?: pingResult
498578
}
499579

500580
override suspend fun openControlSession(session: PcAuthenticatedSession): PcLiveControlResult {

app/src/test/java/com/enaboapps/switchify/pc/PcServiceConnectionControllerTest.kt

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,42 @@ class PcServiceConnectionControllerTest {
9292

9393
assertTrue(result is PcServiceConnectResult.Failed)
9494
assertEquals(PcErrorReason.AuthExpired, (result as PcServiceConnectResult.Failed).reason)
95+
assertEquals(PC_AUTH_RETRY_ATTEMPTS, connector.pingCalls)
9596
assertNull(tokens.getToken("desktop-1"))
9697
assertTrue(PcConnectionStateHolder.connectionState.value is PcConnectionState.Disconnected)
9798
}
9899

100+
@Test
101+
fun savedTokenAuthFailureThenSuccessDoesNotClearToken() = runTest(dispatcher) {
102+
val tokens = FakeTokenStore(mutableMapOf("desktop-1" to "token"))
103+
val connector = FakeConnector(
104+
pingResult = PcPingResult.AuthFailed(),
105+
pingResults = List(PC_AUTH_RETRY_ATTEMPTS - 1) { PcPingResult.AuthFailed() } +
106+
PcPingResult.Connected("ws://192.168.1.20:7347")
107+
)
108+
val controller = controller(tokens, connector)
109+
110+
val result = controller.connectOrRequestAccess()
111+
112+
assertTrue(result is PcServiceConnectResult.Connected)
113+
assertEquals(PC_AUTH_RETRY_ATTEMPTS, connector.pingCalls)
114+
assertEquals(0, connector.pairingCalls)
115+
assertEquals("token", tokens.getToken("desktop-1"))
116+
}
117+
118+
@Test
119+
fun savedTokenNonAuthFailureDoesNotClearToken() = runTest(dispatcher) {
120+
val tokens = FakeTokenStore(mutableMapOf("desktop-1" to "token"))
121+
val connector = FakeConnector(PcPingResult.Failed(PcErrorReason.Unreachable, "Found PC, but could not connect."))
122+
val controller = controller(tokens, connector)
123+
124+
val result = controller.connectOrRequestAccess()
125+
126+
assertTrue(result is PcServiceConnectResult.Failed)
127+
assertEquals(1, connector.pingCalls)
128+
assertEquals("token", tokens.getToken("desktop-1"))
129+
}
130+
99131
@Test
100132
fun discoverPcsGathersLateResolvingPc() = runTest(dispatcher) {
101133
val discovery = FakeDiscovery(listOf(pc))
@@ -153,7 +185,7 @@ class PcServiceConnectionControllerTest {
153185
assertTrue(result is PcServiceConnectResult.Connected)
154186
assertEquals("Switchify PC", approvalCode?.pcName)
155187
assertEquals(1, connector.pairingCalls)
156-
assertEquals(2, connector.pingCalls)
188+
assertEquals(PC_AUTH_RETRY_ATTEMPTS + 1, connector.pingCalls)
157189
assertEquals("new-token", tokens.getToken("desktop-1"))
158190
assertTrue(PcConnectionStateHolder.connectionState.value is PcConnectionState.Connected)
159191
}
@@ -278,8 +310,10 @@ class PcServiceConnectionControllerTest {
278310
private val pingResult: PcPingResult,
279311
private val pairingResult: PcPairingResult = PcPairingResult.Failed(PcErrorReason.Failed, "unused"),
280312
private val pingResultsByToken: Map<String, PcPingResult> = emptyMap(),
281-
private val pairingResultsByDesktop: Map<String, PcPairingResult> = emptyMap()
313+
private val pairingResultsByDesktop: Map<String, PcPairingResult> = emptyMap(),
314+
pingResults: List<PcPingResult> = emptyList()
282315
) : PcConnector {
316+
private val queuedPingResults = ArrayDeque(pingResults)
283317
var pingCalls = 0
284318
var pairingCalls = 0
285319
val requestNonces = mutableListOf<String>()
@@ -292,6 +326,7 @@ class PcServiceConnectionControllerTest {
292326

293327
override suspend fun authenticatedPing(pc: DiscoveredPc, token: String): PcPingResult {
294328
pingCalls++
329+
queuedPingResults.removeFirstOrNull()?.let { return it }
295330
return pingResultsByToken[token] ?: pingResult
296331
}
297332

0 commit comments

Comments
 (0)