Skip to content

Commit 1630593

Browse files
authored
Merge pull request #2349 from switchifyapp/feature/android-pairing-request-code-2348
Show pairing request code on Android
2 parents 5c7dc90 + 1540a8f commit 1630593

10 files changed

Lines changed: 263 additions & 18 deletions

File tree

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

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import kotlinx.coroutines.flow.StateFlow
88
import kotlinx.coroutines.flow.asStateFlow
99
import kotlinx.coroutines.flow.combine
1010
import kotlinx.coroutines.launch
11+
import java.util.UUID
12+
13+
data class PcApprovalCodeState(
14+
val pcName: String,
15+
val verificationCode: String
16+
)
1117

1218
data class PcConnectionUiState(
1319
val permissionRequired: Boolean = false,
@@ -16,7 +22,8 @@ data class PcConnectionUiState(
1622
val connectedDesktopId: String? = null,
1723
val isDiscovering: Boolean = false,
1824
val isBusy: Boolean = false,
19-
val message: String? = null
25+
val message: String? = null,
26+
val approvalCode: PcApprovalCodeState? = null
2027
)
2128

2229
data class PcRowState(
@@ -41,7 +48,8 @@ class PcConnectionViewModel(
4148
private val discoveryService: PcDiscovery = PcDiscoveryService(requireNotNull(context).applicationContext),
4249
private val tokenStore: PcPairingTokenStore = PcTokenStore(requireNotNull(context).applicationContext),
4350
private val identityRepository: PcDeviceIdentity = PcDeviceIdentityRepository(requireNotNull(context).applicationContext),
44-
private val connector: PcConnector = SwitchifyPcClient(identityRepository, tokenStore)
51+
private val connector: PcConnector = SwitchifyPcClient(identityRepository, tokenStore),
52+
private val requestNonceProvider: () -> String = { UUID.randomUUID().toString() }
4553
) : ViewModel() {
4654
private val _uiState = MutableStateFlow(PcConnectionUiState())
4755
val uiState: StateFlow<PcConnectionUiState> = _uiState.asStateFlow()
@@ -72,8 +80,20 @@ class PcConnectionViewModel(
7280

7381
fun requestAccess(pc: DiscoveredPc) {
7482
viewModelScope.launch {
75-
setBusy(pc.desktopId, PcRowStatus.WaitingApproval, "Waiting for approval on your PC...")
76-
when (val pairing = connector.requestApproval(pc)) {
83+
val deviceId = identityRepository.getDeviceId()
84+
val requestNonce = requestNonceProvider()
85+
val verificationCode = createPairingVerificationCode(
86+
desktopId = pc.desktopId,
87+
deviceId = deviceId,
88+
requestNonce = requestNonce
89+
)
90+
setBusy(
91+
desktopId = pc.desktopId,
92+
status = PcRowStatus.WaitingApproval,
93+
message = null,
94+
approvalCode = PcApprovalCodeState(pc.displayName, verificationCode)
95+
)
96+
when (val pairing = connector.requestApproval(pc, requestNonce)) {
7797
is PcPairingResult.Paired -> {
7898
when (val ping = connector.authenticatedPing(pc, pairing.token)) {
7999
is PcPingResult.Connected -> {
@@ -159,14 +179,27 @@ class PcConnectionViewModel(
159179
)
160180
}
161181

162-
private fun setBusy(desktopId: String, status: PcRowStatus, message: String?) {
182+
private fun setBusy(
183+
desktopId: String,
184+
status: PcRowStatus,
185+
message: String?,
186+
approvalCode: PcApprovalCodeState? = null
187+
) {
163188
rowStatuses.value = rowStatuses.value + (desktopId to status)
164-
_uiState.value = _uiState.value.copy(isBusy = true, message = message)
189+
_uiState.value = _uiState.value.copy(
190+
isBusy = true,
191+
message = message,
192+
approvalCode = approvalCode
193+
)
165194
}
166195

167196
private fun setIdle(desktopId: String, status: PcRowStatus, message: String?) {
168197
rowStatuses.value = rowStatuses.value + (desktopId to status)
169-
_uiState.value = _uiState.value.copy(isBusy = false, message = message)
198+
_uiState.value = _uiState.value.copy(
199+
isBusy = false,
200+
message = message,
201+
approvalCode = null
202+
)
170203
}
171204

172205
private fun discoveryStatusText(status: PcDiscoveryStatus, empty: Boolean): String {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.enaboapps.switchify.pc
2+
3+
import kotlin.math.abs
4+
5+
const val PAIRING_VERIFICATION_CODE_LENGTH = 6
6+
7+
fun createPairingVerificationCode(
8+
desktopId: String,
9+
deviceId: String,
10+
requestNonce: String
11+
): String {
12+
val canonical = "$desktopId\n$deviceId\n$requestNonce"
13+
var hash = 0x811C9DC5.toInt()
14+
canonical.forEach { char ->
15+
hash = hash xor char.code
16+
hash *= 16_777_619
17+
}
18+
val value = abs(hash.toLong()) % 1_000_000
19+
return value.toString().padStart(PAIRING_VERIFICATION_CODE_LENGTH, '0')
20+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
66
import kotlinx.coroutines.flow.StateFlow
77
import kotlinx.coroutines.flow.first
88
import kotlinx.coroutines.withTimeoutOrNull
9+
import java.util.UUID
910

1011
sealed class PcServiceConnectionState {
1112
data object Disconnected : PcServiceConnectionState()
@@ -54,7 +55,7 @@ class PcServiceConnectionController(
5455

5556
for (pc in discovered) {
5657
onWaitingForApproval()
57-
when (val result = connector.requestApproval(pc)) {
58+
when (val result = connector.requestApproval(pc, UUID.randomUUID().toString())) {
5859
is PcPairingResult.Paired -> {
5960
tokenStore.saveToken(result.desktopId, result.token, result.websocketUrl, pc.displayName)
6061
when (val ping = connectWithToken(pc, result.token)) {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ interface PcMouseControlConnection {
5252
}
5353

5454
interface PcConnector {
55-
suspend fun requestApproval(pc: DiscoveredPc): PcPairingResult
55+
suspend fun requestApproval(pc: DiscoveredPc, requestNonce: String): PcPairingResult
5656
suspend fun authenticatedPing(pc: DiscoveredPc, token: String): PcPingResult
5757
suspend fun openMouseControlSession(session: PcAuthenticatedSession): PcLiveControlResult
5858
suspend fun sendMouseCommand(session: PcAuthenticatedSession, command: PcMouseCommand): PcCommandResult
@@ -70,7 +70,7 @@ class SwitchifyPcClient(
7070
}
7171
}
7272

73-
override suspend fun requestApproval(pc: DiscoveredPc): PcPairingResult {
73+
override suspend fun requestApproval(pc: DiscoveredPc, requestNonce: String): PcPairingResult {
7474
val deviceId = identityRepository.getDeviceId()
7575
val deviceName = identityRepository.getDeviceName()
7676
for (urlString in candidateUrls(pc)) {
@@ -84,7 +84,7 @@ class SwitchifyPcClient(
8484
deviceId = deviceId,
8585
deviceName = deviceName,
8686
desktopId = pc.desktopId,
87-
requestNonce = UUID.randomUUID().toString()
87+
requestNonce = requestNonce
8888
)
8989
)
9090
)

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.enaboapps.switchify.screens.pc
22

33
import androidx.compose.foundation.layout.Arrangement
44
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.fillMaxWidth
56
import androidx.compose.foundation.layout.padding
67
import androidx.compose.material3.AlertDialog
78
import androidx.compose.material3.Button
@@ -16,13 +17,16 @@ import androidx.compose.runtime.getValue
1617
import androidx.compose.ui.Modifier
1718
import androidx.compose.ui.platform.LocalContext
1819
import androidx.compose.ui.res.stringResource
20+
import androidx.compose.ui.text.font.FontFamily
21+
import androidx.compose.ui.text.style.TextAlign
1922
import androidx.lifecycle.viewmodel.compose.viewModel
2023
import androidx.navigation.NavController
2124
import com.enaboapps.switchify.R
2225
import com.enaboapps.switchify.components.BaseView
2326
import com.enaboapps.switchify.components.PanelListRow
2427
import com.enaboapps.switchify.components.ScrollableView
2528
import com.enaboapps.switchify.components.Section
29+
import com.enaboapps.switchify.pc.PcApprovalCodeState
2630
import com.enaboapps.switchify.pc.PcConnectionViewModel
2731
import com.enaboapps.switchify.pc.PcRowState
2832
import com.enaboapps.switchify.pc.PcRowStatus
@@ -74,6 +78,10 @@ fun PcConnectionScreen(navController: NavController) {
7478
}
7579
}
7680

81+
uiState.approvalCode?.let { approvalCode ->
82+
PcApprovalCodeDialog(approvalCode)
83+
}
84+
7785
uiState.message?.let { message ->
7886
AlertDialog(
7987
onDismissRequest = viewModel::clearMessage,
@@ -88,6 +96,42 @@ fun PcConnectionScreen(navController: NavController) {
8896
}
8997
}
9098

99+
@Composable
100+
private fun PcApprovalCodeDialog(approvalCode: PcApprovalCodeState) {
101+
AlertDialog(
102+
onDismissRequest = {},
103+
confirmButton = {},
104+
title = { Text(stringResource(R.string.pc_pairing_code_title)) },
105+
text = {
106+
Column(verticalArrangement = Arrangement.spacedBy(Dimens.spaceM)) {
107+
Text(
108+
text = stringResource(R.string.pc_pairing_code_pc_name, approvalCode.pcName),
109+
style = MaterialTheme.typography.bodyMedium,
110+
color = MaterialTheme.colorScheme.onSurfaceVariant
111+
)
112+
Text(
113+
text = stringResource(R.string.pc_pairing_code_message),
114+
style = MaterialTheme.typography.bodyMedium
115+
)
116+
Text(
117+
text = approvalCode.verificationCode,
118+
modifier = Modifier
119+
.fillMaxWidth()
120+
.padding(vertical = Dimens.spaceS),
121+
style = MaterialTheme.typography.headlineLarge,
122+
fontFamily = FontFamily.Monospace,
123+
textAlign = TextAlign.Center
124+
)
125+
Text(
126+
text = stringResource(R.string.pc_pairing_code_waiting),
127+
style = MaterialTheme.typography.bodyMedium,
128+
color = MaterialTheme.colorScheme.onSurfaceVariant
129+
)
130+
}
131+
}
132+
)
133+
}
134+
91135
private fun PcRowState.perform(viewModel: PcConnectionViewModel) {
92136
when (actionText) {
93137
"Connect" -> viewModel.connectWithSavedToken(pc)

app/src/main/res/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,10 @@
264264
<string name="pc_connection_permission_action">Allow local network access</string>
265265
<string name="pc_connection_nearby_section">Nearby PCs</string>
266266
<string name="pc_connection_message_title">PC connection</string>
267+
<string name="pc_pairing_code_title">Check your PC</string>
268+
<string name="pc_pairing_code_pc_name">Connecting to %1$s</string>
269+
<string name="pc_pairing_code_message">Confirm this code matches the one shown on your PC before accepting.</string>
270+
<string name="pc_pairing_code_waiting">Waiting for approval on your PC...</string>
267271
<string name="menu_item_control_pc">Control PC</string>
268272
<string name="menu_item_control_pc_description">Control your connected computer.</string>
269273
<string name="menu_title_control_pc">Control PC</string>

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

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.enaboapps.switchify.pc
22

33
import kotlinx.coroutines.Dispatchers
44
import kotlinx.coroutines.ExperimentalCoroutinesApi
5+
import kotlinx.coroutines.CompletableDeferred
56
import kotlinx.coroutines.flow.MutableStateFlow
67
import kotlinx.coroutines.test.StandardTestDispatcher
78
import kotlinx.coroutines.test.advanceUntilIdle
@@ -74,6 +75,78 @@ class PcConnectionViewModelTest {
7475
assertEquals("desktop-1", viewModel.uiState.value.connectedDesktopId)
7576
}
7677

78+
@Test
79+
fun requestAccessShowsApprovalCodeWhileWaiting() = runTest(dispatcher) {
80+
val discovery = FakeDiscovery(listOf(pc))
81+
val tokens = FakeTokenStore()
82+
val pairingDeferred = CompletableDeferred<PcPairingResult>()
83+
val connector = FakeConnector(
84+
pairingDeferred = pairingDeferred,
85+
pingResult = PcPingResult.Connected("ws://192.168.1.20:7347")
86+
)
87+
val viewModel = viewModel(discovery, tokens, connector, requestNonceProvider = { "nonce-1" })
88+
advanceUntilIdle()
89+
90+
viewModel.requestAccess(pc)
91+
advanceUntilIdle()
92+
93+
assertEquals("215918", viewModel.uiState.value.approvalCode?.verificationCode)
94+
assertEquals("Switchify PC", viewModel.uiState.value.approvalCode?.pcName)
95+
assertEquals("Waiting for approval on your PC...", viewModel.uiState.value.discoveredPcs.first().summary)
96+
assertEquals("nonce-1", connector.requestNonces.single())
97+
98+
pairingDeferred.complete(PcPairingResult.Failed("Request rejected."))
99+
advanceUntilIdle()
100+
}
101+
102+
@Test
103+
fun pairingSuccessClearsApprovalCode() = runTest(dispatcher) {
104+
val discovery = FakeDiscovery(listOf(pc))
105+
val tokens = FakeTokenStore()
106+
val connector = FakeConnector(
107+
pairingResult = PcPairingResult.Paired("desktop-1", "token", "ws://192.168.1.20:7347"),
108+
pingResult = PcPingResult.Connected("ws://192.168.1.20:7347")
109+
)
110+
val viewModel = viewModel(discovery, tokens, connector, requestNonceProvider = { "nonce-1" })
111+
advanceUntilIdle()
112+
113+
viewModel.requestAccess(pc)
114+
advanceUntilIdle()
115+
116+
assertNull(viewModel.uiState.value.approvalCode)
117+
assertEquals("desktop-1", viewModel.uiState.value.connectedDesktopId)
118+
}
119+
120+
@Test
121+
fun pairingFailureClearsApprovalCodeAndShowsMessage() = runTest(dispatcher) {
122+
val discovery = FakeDiscovery(listOf(pc))
123+
val tokens = FakeTokenStore()
124+
val connector = FakeConnector(pairingResult = PcPairingResult.Failed("Request rejected."))
125+
val viewModel = viewModel(discovery, tokens, connector, requestNonceProvider = { "nonce-1" })
126+
advanceUntilIdle()
127+
128+
viewModel.requestAccess(pc)
129+
advanceUntilIdle()
130+
131+
assertNull(viewModel.uiState.value.approvalCode)
132+
assertEquals("Request rejected.", viewModel.uiState.value.message)
133+
}
134+
135+
@Test
136+
fun savedTokenPathDoesNotShowApprovalCode() = runTest(dispatcher) {
137+
val discovery = FakeDiscovery(listOf(pc))
138+
val tokens = FakeTokenStore(initialTokens = mutableMapOf("desktop-1" to "token"))
139+
val connector = FakeConnector(pingResult = PcPingResult.Connected("ws://192.168.1.20:7347"))
140+
val viewModel = viewModel(discovery, tokens, connector, requestNonceProvider = { "nonce-1" })
141+
advanceUntilIdle()
142+
143+
viewModel.connectWithSavedToken(pc)
144+
advanceUntilIdle()
145+
146+
assertNull(viewModel.uiState.value.approvalCode)
147+
assertEquals(0, connector.requestApprovalCalls)
148+
}
149+
77150
@Test
78151
fun invalidSavedTokenClearsTokenAndOffersRequestAccess() = runTest(dispatcher) {
79152
val discovery = FakeDiscovery(listOf(pc))
@@ -93,13 +166,15 @@ class PcConnectionViewModelTest {
93166
private fun viewModel(
94167
discovery: FakeDiscovery,
95168
tokens: FakeTokenStore,
96-
connector: FakeConnector
169+
connector: FakeConnector,
170+
requestNonceProvider: () -> String = { "nonce" }
97171
) = PcConnectionViewModel(
98172
context = null,
99173
discoveryService = discovery,
100174
tokenStore = tokens,
101175
identityRepository = FakeIdentity,
102-
connector = connector
176+
connector = connector,
177+
requestNonceProvider = requestNonceProvider
103178
)
104179

105180
private class FakeDiscovery(initialPcs: List<DiscoveredPc>) : PcDiscovery {
@@ -137,14 +212,17 @@ class PcConnectionViewModelTest {
137212

138213
private class FakeConnector(
139214
private val pairingResult: PcPairingResult = PcPairingResult.Failed("Request rejected."),
140-
private val pingResult: PcPingResult = PcPingResult.Failed("Found PC, but could not connect.")
215+
private val pingResult: PcPingResult = PcPingResult.Failed("Found PC, but could not connect."),
216+
private val pairingDeferred: CompletableDeferred<PcPairingResult>? = null
141217
) : PcConnector {
142218
var requestApprovalCalls = 0
143219
var pingCalls = 0
220+
val requestNonces = mutableListOf<String>()
144221

145-
override suspend fun requestApproval(pc: DiscoveredPc): PcPairingResult {
222+
override suspend fun requestApproval(pc: DiscoveredPc, requestNonce: String): PcPairingResult {
146223
requestApprovalCalls++
147-
return pairingResult
224+
requestNonces.add(requestNonce)
225+
return pairingDeferred?.await() ?: pairingResult
148226
}
149227

150228
override suspend fun authenticatedPing(pc: DiscoveredPc, token: String): PcPingResult {

0 commit comments

Comments
 (0)