Skip to content

Commit b7aff01

Browse files
authored
Merge pull request #2394 from switchifyapp/feature/multi-pc-chooser-2393
Support multiple PCs in scanned Control PC flow
2 parents 1f86f2a + 9729b38 commit b7aff01

8 files changed

Lines changed: 324 additions & 35 deletions

File tree

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

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,36 @@ class PcServiceConnectionController(
3232
private val _state = MutableStateFlow<PcServiceConnectionState>(PcServiceConnectionState.Disconnected)
3333
val state: StateFlow<PcServiceConnectionState> = _state
3434

35-
suspend fun connectOrRequestAccess(onWaitingForApproval: (PcApprovalCodeState) -> Unit = {}): PcServiceConnectResult {
36-
(PcConnectionStateHolder.connectionState.value as? PcConnectionState.Connected)?.let {
37-
_state.value = PcServiceConnectionState.Connected(it.session, it.displayName)
38-
return PcServiceConnectResult.Connected(it.session, it.displayName)
35+
/**
36+
* Discovers Switchify PCs on the local network.
37+
*
38+
* Waits up to [DISCOVERY_TIMEOUT_MS] for the first PC to resolve, then keeps a short
39+
* settle window open so slower PCs can join the list before it is returned. This avoids
40+
* deciding on a single PC just because it won the mDNS resolve race.
41+
*/
42+
suspend fun discoverPcs(): List<DiscoveredPc> {
43+
discovery.startDiscovery()
44+
try {
45+
var current = withTimeoutOrNull(DISCOVERY_TIMEOUT_MS) {
46+
discovery.pcs.first { pcs -> pcs.isNotEmpty() }
47+
} ?: return emptyList()
48+
while (true) {
49+
val grown = withTimeoutOrNull(SETTLE_WINDOW_MS) {
50+
discovery.pcs.first { pcs -> pcs.size > current.size }
51+
} ?: break
52+
current = grown
53+
}
54+
return current
55+
} finally {
56+
discovery.stopDiscovery()
3957
}
58+
}
59+
60+
suspend fun connectOrRequestAccess(onWaitingForApproval: (PcApprovalCodeState) -> Unit = {}): PcServiceConnectResult {
61+
existingConnection()?.let { return it }
4062

4163
_state.value = PcServiceConnectionState.Connecting
42-
discovery.startDiscovery()
43-
val discovered = withTimeoutOrNull(8_000) {
44-
discovery.pcs.first { pcs -> pcs.isNotEmpty() }
45-
}.orEmpty()
46-
discovery.stopDiscovery()
64+
val discovered = discoverPcs()
4765

4866
for (pc in discovered) {
4967
tokenStore.getToken(pc.desktopId)?.let { token ->
@@ -54,31 +72,21 @@ class PcServiceConnectionController(
5472
}
5573
}
5674

75+
var lastFailure: PcServiceConnectResult.Failed? = null
5776
for (pc in discovered) {
58-
val requestNonce = requestNonceProvider()
59-
val verificationCode = createPairingVerificationCode(
60-
desktopId = pc.desktopId,
61-
deviceId = identityRepository.getDeviceId(),
62-
requestNonce = requestNonce
63-
)
64-
onWaitingForApproval(PcApprovalCodeState(pc.displayName, verificationCode))
65-
when (val result = connector.requestApproval(pc, requestNonce)) {
66-
is PcPairingResult.Paired -> {
67-
tokenStore.saveToken(result.desktopId, result.token, result.websocketUrl, pc.displayName)
68-
when (val ping = connectWithToken(pc, result.token)) {
69-
is PcServiceConnectResult.Connected -> return ping
70-
is PcServiceConnectResult.Failed -> return ping
77+
when (val result = pairAndConnect(pc, onWaitingForApproval)) {
78+
is PcServiceConnectResult.Connected -> return result
79+
is PcServiceConnectResult.Failed -> {
80+
if (isUserDecision(result.reason)) {
81+
_state.value = PcServiceConnectionState.Failed(result.message)
82+
return result
7183
}
72-
}
73-
is PcPairingResult.Failed -> {
74-
val failure = PcServiceConnectResult.Failed(result.reason, result.message)
75-
_state.value = PcServiceConnectionState.Failed(result.message)
76-
return failure
84+
lastFailure = result
7785
}
7886
}
7987
}
8088

81-
val failure = if (discovered.isEmpty()) {
89+
val failure = lastFailure ?: if (discovered.isEmpty()) {
8290
PcServiceConnectResult.Failed(PcErrorReason.NoPcFound, "No Switchify PC found.")
8391
} else {
8492
PcServiceConnectResult.Failed(PcErrorReason.Failed, "Could not connect to PC.")
@@ -87,6 +95,34 @@ class PcServiceConnectionController(
8795
return failure
8896
}
8997

98+
/**
99+
* Connects to a specific, user-selected PC. Tries a saved token first; if the token is
100+
* missing or expired, falls through to a fresh pairing request against that PC.
101+
*/
102+
suspend fun connectTo(
103+
pc: DiscoveredPc,
104+
onWaitingForApproval: (PcApprovalCodeState) -> Unit = {}
105+
): PcServiceConnectResult {
106+
existingConnection()?.takeIf { it.session.desktopId == pc.desktopId }?.let { return it }
107+
108+
_state.value = PcServiceConnectionState.Connecting
109+
tokenStore.getToken(pc.desktopId)?.let { token ->
110+
when (val result = connectWithToken(pc, token)) {
111+
is PcServiceConnectResult.Connected -> return result
112+
is PcServiceConnectResult.Failed -> if (result.reason != PcErrorReason.AuthExpired) {
113+
_state.value = PcServiceConnectionState.Failed(result.message)
114+
return result
115+
}
116+
}
117+
}
118+
119+
val result = pairAndConnect(pc, onWaitingForApproval)
120+
if (result is PcServiceConnectResult.Failed) {
121+
_state.value = PcServiceConnectionState.Failed(result.message)
122+
}
123+
return result
124+
}
125+
90126
suspend fun sendCommand(command: PcControlCommand): PcCommandResult {
91127
val connected = PcConnectionStateHolder.connectionState.value as? PcConnectionState.Connected
92128
?: return PcCommandResult.AuthFailed()
@@ -104,6 +140,36 @@ class PcServiceConnectionController(
104140
connector.close()
105141
}
106142

143+
private fun existingConnection(): PcServiceConnectResult.Connected? {
144+
val connected = PcConnectionStateHolder.connectionState.value as? PcConnectionState.Connected ?: return null
145+
_state.value = PcServiceConnectionState.Connected(connected.session, connected.displayName)
146+
return PcServiceConnectResult.Connected(connected.session, connected.displayName)
147+
}
148+
149+
private suspend fun pairAndConnect(
150+
pc: DiscoveredPc,
151+
onWaitingForApproval: (PcApprovalCodeState) -> Unit
152+
): PcServiceConnectResult {
153+
val requestNonce = requestNonceProvider()
154+
val verificationCode = createPairingVerificationCode(
155+
desktopId = pc.desktopId,
156+
deviceId = identityRepository.getDeviceId(),
157+
requestNonce = requestNonce
158+
)
159+
onWaitingForApproval(PcApprovalCodeState(pc.displayName, verificationCode))
160+
return when (val result = connector.requestApproval(pc, requestNonce)) {
161+
is PcPairingResult.Paired -> {
162+
tokenStore.saveToken(result.desktopId, result.token, result.websocketUrl, pc.displayName)
163+
connectWithToken(pc, result.token)
164+
}
165+
is PcPairingResult.Failed -> PcServiceConnectResult.Failed(result.reason, result.message)
166+
}
167+
}
168+
169+
private fun isUserDecision(reason: PcErrorReason): Boolean {
170+
return reason == PcErrorReason.PairingRejected || reason == PcErrorReason.PairingRequestExpired
171+
}
172+
107173
private suspend fun connectWithToken(pc: DiscoveredPc, token: String): PcServiceConnectResult {
108174
return when (val result = connector.authenticatedPing(pc, token)) {
109175
is PcPingResult.Connected -> {
@@ -125,5 +191,7 @@ class PcServiceConnectionController(
125191

126192
companion object {
127193
private const val EXPIRED_MESSAGE = "Connection expired. Request access again."
194+
private const val DISCOVERY_TIMEOUT_MS = 8_000L
195+
private const val SETTLE_WINDOW_MS = 1_500L
128196
}
129197
}

app/src/main/java/com/enaboapps/switchify/service/menu/MenuManager.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import com.enaboapps.switchify.service.menu.menus.gestures.SwipeGesturesMenu
1212
import com.enaboapps.switchify.service.menu.menus.gestures.TapAndHoldGesturesMenu
1313
import com.enaboapps.switchify.service.menu.menus.gestures.TapGesturesMenu
1414
import com.enaboapps.switchify.service.menu.menus.gestures.PinchGesturesMenu
15+
import com.enaboapps.switchify.pc.DiscoveredPc
1516
import com.enaboapps.switchify.service.menu.menus.main.MainMenu
1617
import com.enaboapps.switchify.service.menu.menus.media.MediaControlMenu
1718
import com.enaboapps.switchify.service.menu.menus.favouriteapps.FavouriteAppsMenu
19+
import com.enaboapps.switchify.service.menu.menus.pc.ChoosePcMenu
1820
import com.enaboapps.switchify.service.menu.menus.scroll.ScrollMenu
1921
import com.enaboapps.switchify.service.menu.menus.settings.SettingsMenu
2022
import com.enaboapps.switchify.service.menu.menus.system.DeviceMenu
@@ -236,6 +238,16 @@ class MenuManager {
236238
openMenu(favouriteAppsMenu.build())
237239
}
238240

241+
/**
242+
* This function opens the PC chooser menu listing the given discovered PCs
243+
* @param pcs The PCs discovered on the local network
244+
* @param onSelect Invoked with the chosen PC after the menu closes
245+
*/
246+
fun openChoosePcMenu(pcs: List<DiscoveredPc>, onSelect: (DiscoveredPc) -> Unit) {
247+
val choosePcMenu = ChoosePcMenu(accessibilityService!!, pcs, onSelect)
248+
openMenu(choosePcMenu.build())
249+
}
250+
239251
/**
240252
* This function opens the menu
241253
* @param menu The menu to open

app/src/main/java/com/enaboapps/switchify/service/menu/menus/main/MainMenuStructure.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package com.enaboapps.switchify.service.menu.menus.main
33
import com.enaboapps.switchify.R
44
import com.enaboapps.switchify.backend.iap.IAPHandler
55
import com.enaboapps.switchify.backend.preferences.PreferenceManager
6+
import com.enaboapps.switchify.pc.DiscoveredPc
67
import com.enaboapps.switchify.pc.PcConnectionState
78
import com.enaboapps.switchify.pc.PcConnectionStateHolder
89
import com.enaboapps.switchify.pc.PcErrorReason
910
import com.enaboapps.switchify.pc.PcServiceConnectResult
11+
import com.enaboapps.switchify.pc.PcServiceConnectionController
1012
import com.enaboapps.switchify.service.actions.GlobalActionManager
1113
import com.enaboapps.switchify.service.core.ServiceCore
1214
import com.enaboapps.switchify.service.core.SwitchifyAccessibilityService
@@ -179,17 +181,38 @@ class MainMenuStructure(
179181
launchPcControlActivity()
180182
return
181183
}
184+
val controller = ServiceCore.getPcServiceConnectionController()
185+
if (controller == null) {
186+
showMessage(R.string.pc_control_no_pc_found, MessageSeverity.Warning)
187+
return
188+
}
189+
showMessage(R.string.pc_control_connecting, MessageSeverity.Info)
190+
coroutineScope.launch {
191+
val discovered = controller.discoverPcs()
192+
withContext(Dispatchers.Main) {
193+
when {
194+
discovered.isEmpty() -> showMessage(R.string.pc_control_no_pc_found, MessageSeverity.Warning)
195+
discovered.size == 1 -> connectToPcAndLaunch(controller, discovered.single())
196+
else -> MenuManager.getInstance().openChoosePcMenu(discovered) { pc ->
197+
connectToPcAndLaunch(controller, pc)
198+
}
199+
}
200+
}
201+
}
202+
}
203+
204+
private fun connectToPcAndLaunch(controller: PcServiceConnectionController, pc: DiscoveredPc) {
182205
showMessage(R.string.pc_control_connecting, MessageSeverity.Info)
183206
coroutineScope.launch {
184-
val result = ServiceCore.getPcServiceConnectionController()?.connectOrRequestAccess { approvalCode ->
207+
val result = controller.connectTo(pc) { approvalCode ->
185208
coroutineScope.launch(Dispatchers.Main) {
186209
showMessage(
187210
R.string.pc_control_pairing_code,
188211
arrayOf(approvalCode.verificationCode),
189212
MessageSeverity.Info
190213
)
191214
}
192-
} ?: PcServiceConnectResult.Failed(PcErrorReason.NoPcFound, "No Switchify PC found.")
215+
}
193216
withContext(Dispatchers.Main) {
194217
when (result) {
195218
is PcServiceConnectResult.Connected -> launchPcControlActivity()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.enaboapps.switchify.service.menu.menus.pc
2+
3+
import com.enaboapps.switchify.pc.DiscoveredPc
4+
import com.enaboapps.switchify.service.core.SwitchifyAccessibilityService
5+
import com.enaboapps.switchify.service.menu.menus.BaseMenu
6+
import com.enaboapps.switchify.service.menu.structure.MenuConstants
7+
8+
/**
9+
* Menu shown when more than one Switchify PC is discovered on the local
10+
* network, letting the user pick which PC to connect to. Selecting a row
11+
* closes the menu hierarchy and invokes [onSelect] with the chosen PC.
12+
*/
13+
class ChoosePcMenu(
14+
accessibilityService: SwitchifyAccessibilityService,
15+
pcs: List<DiscoveredPc>,
16+
onSelect: (DiscoveredPc) -> Unit
17+
) : BaseMenu(
18+
accessibilityService = accessibilityService,
19+
items = ChoosePcMenuStructure().getMenuItems(pcs, onSelect),
20+
menuId = MenuConstants.MenuIds.CHOOSE_PC_MENU,
21+
showNavMenuItems = true
22+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.enaboapps.switchify.service.menu.menus.pc
2+
3+
import com.enaboapps.switchify.R
4+
import com.enaboapps.switchify.pc.DiscoveredPc
5+
import com.enaboapps.switchify.service.menu.MenuItem
6+
7+
/**
8+
* Builds menu items for the "Choose PC" menu, one per discovered PC.
9+
*/
10+
class ChoosePcMenuStructure {
11+
12+
fun getMenuItems(
13+
pcs: List<DiscoveredPc>,
14+
onSelect: (DiscoveredPc) -> Unit
15+
): List<MenuItem> {
16+
return pcs.map { pc ->
17+
MenuItem(
18+
id = "choose_pc_${pc.desktopId}",
19+
userProvidedText = pc.displayName,
20+
descriptionResource = R.string.menu_item_choose_pc_description,
21+
drawableId = R.drawable.ic_control_pc,
22+
action = { onSelect(pc) }
23+
)
24+
}
25+
}
26+
}

app/src/main/java/com/enaboapps/switchify/service/menu/structure/MenuConstants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ object MenuConstants {
4646
const val GESTURE_PATTERNS_MENU = "gesture_patterns_menu"
4747
const val FINGER_MODE_MENU = "finger_mode_menu"
4848
const val AI_MENU = "ai_menu"
49+
const val CHOOSE_PC_MENU = "choose_pc_menu"
4950
}
5051

5152
fun getTitleResource(menuId: String?): Int? = when (menuId) {
@@ -65,6 +66,7 @@ object MenuConstants {
6566
MenuIds.FAVOURITE_APPS_MENU -> R.string.menu_title_favourite_apps
6667
MenuIds.GESTURE_PATTERNS_MENU -> R.string.gesture_patterns_title
6768
MenuIds.FINGER_MODE_MENU -> R.string.menu_item_finger_mode
69+
MenuIds.CHOOSE_PC_MENU -> R.string.menu_title_choose_pc
6870
else -> null
6971
}
7072

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,8 @@
277277
<string name="menu_item_control_pc">Control PC</string>
278278
<string name="menu_item_control_pc_description">Control your connected computer.</string>
279279
<string name="menu_title_control_pc">Control PC</string>
280+
<string name="menu_title_choose_pc">Choose PC</string>
281+
<string name="menu_item_choose_pc_description">Connect to this PC.</string>
280282
<string name="pc_control_connecting">Connecting to PC...</string>
281283
<string name="pc_control_connect_first">Connect to PC from Switchify first.</string>
282284
<string name="pc_control_waiting_approval">Waiting for approval on your PC...</string>

0 commit comments

Comments
 (0)