Skip to content

Commit 6c3edb9

Browse files
authored
Merge pull request #2337 from switchifyapp/feature/pc-pointer-profile-movement-2336
Use PC pointer profile for mouse movement
2 parents 4d516d2 + 822bd0f commit 6c3edb9

9 files changed

Lines changed: 287 additions & 8 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.enaboapps.switchify.pc
2+
3+
data class PcPointerMovementProfile(
4+
val displayId: String,
5+
val scaleFactor: Double,
6+
val bounds: PcPointerBounds,
7+
val maxDelta: Int,
8+
val recommendedDeltas: PcPointerDeltas
9+
)
10+
11+
data class PcPointerBounds(
12+
val x: Int,
13+
val y: Int,
14+
val width: Int,
15+
val height: Int
16+
)
17+
18+
data class PcPointerDeltas(
19+
val small: Int,
20+
val medium: Int,
21+
val large: Int
22+
)

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import javax.crypto.spec.SecretKeySpec
1010
sealed class PcProtocolResponse {
1111
data class Ack(val id: String) : PcProtocolResponse()
1212
data class PairingComplete(val id: String, val desktopId: String, val deviceId: String, val token: String) : PcProtocolResponse()
13+
data class PointerProfile(val id: String, val profile: PcPointerMovementProfile) : PcProtocolResponse()
1314
data class Error(val id: String?, val code: String, val message: String) : PcProtocolResponse()
1415
data object Invalid : PcProtocolResponse()
1516
}
@@ -49,6 +50,17 @@ object PcProtocol {
4950
)
5051
}
5152

53+
fun pointerProfile(id: String, deviceId: String, token: String, timestamp: Long): String {
54+
return authenticatedCommand(
55+
id = id,
56+
deviceId = deviceId,
57+
token = token,
58+
timestamp = timestamp,
59+
type = "pointer.profile",
60+
payload = JSONObject()
61+
)
62+
}
63+
5264
fun authenticatedCommand(
5365
id: String,
5466
deviceId: String,
@@ -98,6 +110,7 @@ object PcProtocol {
98110
?: PcProtocolResponse.Invalid
99111
}
100112
"pairing.complete" -> parsePairingComplete(json)
113+
"pointer.profile" -> parsePointerProfile(json)
101114
"error" -> {
102115
val error = json.optJSONObject("error") ?: return@runCatching PcProtocolResponse.Invalid
103116
PcProtocolResponse.Error(
@@ -168,4 +181,48 @@ object PcProtocol {
168181
}
169182
return PcProtocolResponse.PairingComplete(id, desktopId, deviceId, token)
170183
}
184+
185+
private fun parsePointerProfile(json: JSONObject): PcProtocolResponse {
186+
if (!json.optBoolean("ok") || !json.isNull("error")) return PcProtocolResponse.Invalid
187+
val id = json.optString("id").takeIf { it.isNotBlank() } ?: return PcProtocolResponse.Invalid
188+
val payload = json.optJSONObject("payload") ?: return PcProtocolResponse.Invalid
189+
val displayId = payload.optString("displayId").takeIf { it.isNotBlank() } ?: return PcProtocolResponse.Invalid
190+
val scaleFactor = payload.optDouble("scaleFactor")
191+
val boundsJson = payload.optJSONObject("bounds") ?: return PcProtocolResponse.Invalid
192+
val maxDelta = payload.optInt("maxDelta")
193+
val deltasJson = payload.optJSONObject("recommendedDeltas") ?: return PcProtocolResponse.Invalid
194+
val bounds = PcPointerBounds(
195+
x = boundsJson.optInt("x"),
196+
y = boundsJson.optInt("y"),
197+
width = boundsJson.optInt("width"),
198+
height = boundsJson.optInt("height")
199+
)
200+
val deltas = PcPointerDeltas(
201+
small = deltasJson.optInt("small"),
202+
medium = deltasJson.optInt("medium"),
203+
large = deltasJson.optInt("large")
204+
)
205+
if (
206+
!scaleFactor.isFinite() ||
207+
scaleFactor <= 0.0 ||
208+
bounds.width <= 0 ||
209+
bounds.height <= 0 ||
210+
maxDelta <= 0 ||
211+
deltas.small <= 0 ||
212+
deltas.medium <= 0 ||
213+
deltas.large <= 0
214+
) {
215+
return PcProtocolResponse.Invalid
216+
}
217+
return PcProtocolResponse.PointerProfile(
218+
id = id,
219+
profile = PcPointerMovementProfile(
220+
displayId = displayId,
221+
scaleFactor = scaleFactor,
222+
bounds = bounds,
223+
maxDelta = maxDelta,
224+
recommendedDeltas = deltas
225+
)
226+
)
227+
}
171228
}

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

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ sealed class PcLiveControlResult {
4646
}
4747

4848
interface PcMouseControlConnection {
49+
val pointerProfile: PcPointerMovementProfile?
4950
suspend fun sendMouseCommand(command: PcMouseCommand): PcCommandResult
5051
fun close()
5152
}
@@ -193,7 +194,8 @@ class SwitchifyPcClient(
193194
LiveMouseControlConnection(
194195
webSocketSession = webSocketSession,
195196
authenticatedSession = session,
196-
token = token
197+
token = token,
198+
pointerProfile = requestPointerProfile(webSocketSession, session, token)
197199
)
198200
)
199201
is PcProtocolResponse.Error -> {
@@ -209,7 +211,8 @@ class SwitchifyPcClient(
209211
} catch (error: Throwable) {
210212
if (error is CancellationException) throw error
211213
webSocketSession?.close()
212-
PcLiveControlResult.Failed()
214+
if (error is InvalidPcAuthException) PcLiveControlResult.AuthFailed()
215+
else PcLiveControlResult.Failed()
213216
}
214217
}
215218

@@ -231,12 +234,44 @@ class SwitchifyPcClient(
231234
when (response) {
232235
is PcProtocolResponse.Ack -> if (response.id == requestId) return response
233236
is PcProtocolResponse.PairingComplete -> if (response.id == requestId) return response
237+
is PcProtocolResponse.PointerProfile -> if (response.id == requestId) return response
234238
is PcProtocolResponse.Error -> if (response.id == requestId || response.id == null) return response
235239
PcProtocolResponse.Invalid -> return response
236240
}
237241
}
238242
}
239243

244+
private suspend fun requestPointerProfile(
245+
webSocketSession: WebSocketSession,
246+
session: PcAuthenticatedSession,
247+
token: String
248+
): PcPointerMovementProfile? {
249+
return try {
250+
val requestId = nextRequestId()
251+
webSocketSession.send(
252+
Frame.Text(
253+
PcProtocol.pointerProfile(
254+
id = requestId,
255+
deviceId = session.deviceId,
256+
token = token,
257+
timestamp = System.currentTimeMillis()
258+
)
259+
)
260+
)
261+
when (val response = withTimeout(5_000) { readExpectedResponse(webSocketSession, requestId) }) {
262+
is PcProtocolResponse.PointerProfile -> response.profile
263+
is PcProtocolResponse.Error -> {
264+
if (response.code == "invalid_auth") throw InvalidPcAuthException()
265+
null
266+
}
267+
else -> null
268+
}
269+
} catch (error: Throwable) {
270+
if (error is CancellationException || error is InvalidPcAuthException) throw error
271+
null
272+
}
273+
}
274+
240275
private fun nextRequestId(): String = "android-${UUID.randomUUID()}"
241276

242277
private fun PcMouseCommand.toMessage(id: String, deviceId: String, token: String, timestamp: Long): String {
@@ -252,7 +287,8 @@ class SwitchifyPcClient(
252287
private inner class LiveMouseControlConnection(
253288
private val webSocketSession: WebSocketSession,
254289
private val authenticatedSession: PcAuthenticatedSession,
255-
private val token: String
290+
private val token: String,
291+
override val pointerProfile: PcPointerMovementProfile?
256292
) : PcMouseControlConnection {
257293
override suspend fun sendMouseCommand(command: PcMouseCommand): PcCommandResult {
258294
return runCatching {
@@ -282,4 +318,6 @@ class SwitchifyPcClient(
282318
webSocketSession.cancel()
283319
}
284320
}
321+
322+
private class InvalidPcAuthException : RuntimeException()
285323
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ data class PcMouseControlSpec(
2727
@Composable
2828
fun PcMouseCommandGrid(
2929
connected: Boolean,
30+
movementStep: Int,
3031
busyCommand: PcMouseCommand?,
3132
onCommandSelected: (PcMouseCommand) -> Unit,
3233
modifier: Modifier = Modifier
@@ -38,7 +39,7 @@ fun PcMouseCommandGrid(
3839
verticalArrangement = Arrangement.spacedBy(12.dp),
3940
horizontalArrangement = Arrangement.spacedBy(12.dp)
4041
) {
41-
items(pcMouseControlSpecs()) { control ->
42+
items(pcMouseControlSpecs(movementStep)) { control ->
4243
Button(
4344
onClick = { onCommandSelected(control.command) },
4445
enabled = connected && busyCommand == null,
@@ -55,8 +56,8 @@ fun PcMouseCommandGrid(
5556
}
5657
}
5758

58-
fun pcMouseControlSpecs(): List<PcMouseControlSpec> {
59-
val step = 80
59+
fun pcMouseControlSpecs(moveStep: Int): List<PcMouseControlSpec> {
60+
val step = moveStep.coerceAtLeast(1)
6061
val scrollStep = 5
6162
return listOf(
6263
PcMouseControlSpec(R.string.pc_mouse_up_left, PcMouseCommand.Move(-step, -step)),

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ private fun PcMouseControlScreen(
118118
}
119119
PcMouseCommandGrid(
120120
connected = uiState.connectedDisplayName != null,
121+
movementStep = uiState.movementStep,
121122
busyCommand = uiState.busyCommand,
122123
onCommandSelected = viewModel::send
123124
)

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.enaboapps.switchify.pc.PcDeviceIdentityRepository
1313
import com.enaboapps.switchify.pc.PcMouseCommand
1414
import com.enaboapps.switchify.pc.PcAuthenticatedSession
1515
import com.enaboapps.switchify.pc.PcPairingTokenStore
16+
import com.enaboapps.switchify.pc.PcPointerMovementProfile
1617
import com.enaboapps.switchify.pc.PcTokenStore
1718
import com.enaboapps.switchify.pc.SwitchifyPcClient
1819
import kotlinx.coroutines.CompletableDeferred
@@ -27,6 +28,7 @@ import kotlinx.coroutines.launch
2728

2829
data class PcMouseControlUiState(
2930
val connectedDisplayName: String? = null,
31+
val movementStep: Int = PcMouseControlViewModel.DEFAULT_MOVE_STEP,
3032
val isBusy: Boolean = false,
3133
val busyCommand: PcMouseCommand? = null,
3234
val message: String? = null
@@ -54,7 +56,10 @@ class PcMouseControlViewModel(
5456
viewModelScope.launch {
5557
PcConnectionStateHolder.connectionState.collect { state ->
5658
_uiState.update {
57-
it.copy(connectedDisplayName = (state as? PcConnectionState.Connected)?.displayName)
59+
it.copy(
60+
connectedDisplayName = (state as? PcConnectionState.Connected)?.displayName,
61+
movementStep = if (state is PcConnectionState.Connected) it.movementStep else DEFAULT_MOVE_STEP
62+
)
5863
}
5964
when (state) {
6065
is PcConnectionState.Connected -> ensureLiveConnection(state.session)
@@ -129,6 +134,9 @@ class PcMouseControlViewModel(
129134
when (val result = connector.openMouseControlSession(session)) {
130135
is PcLiveControlResult.Connected -> {
131136
liveConnection = result.connection
137+
_uiState.update {
138+
it.copy(movementStep = result.connection.pointerProfile?.smallMovementStep() ?: DEFAULT_MOVE_STEP)
139+
}
132140
result.connection
133141
}
134142
is PcLiveControlResult.AuthFailed -> {
@@ -137,6 +145,7 @@ class PcMouseControlViewModel(
137145
_uiState.update {
138146
it.copy(
139147
connectedDisplayName = null,
148+
movementStep = DEFAULT_MOVE_STEP,
140149
isBusy = false,
141150
busyCommand = null,
142151
message = CONNECT_FIRST_MESSAGE
@@ -164,7 +173,12 @@ class PcMouseControlViewModel(
164173
liveConnectionDeferred = null
165174
}
166175

176+
private fun PcPointerMovementProfile.smallMovementStep(): Int {
177+
return recommendedDeltas.small.coerceIn(1, maxDelta)
178+
}
179+
167180
companion object {
181+
const val DEFAULT_MOVE_STEP = 40
168182
const val CONNECT_FIRST_MESSAGE = "Connect to PC from Switchify first."
169183
const val COMMAND_FAILED_MESSAGE = "Could not send command to PC."
170184
}

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,63 @@ class PcProtocolTest {
6868
assertEquals("o0CHNFgcFjPSL1PaWzBP6riHHr9V8kxo-qTKAPUvT7A", proof)
6969
}
7070

71+
@Test
72+
fun buildsPointerProfileCommandWithAuthProof() {
73+
val json = JSONObject(
74+
PcProtocol.pointerProfile(
75+
id = "profile-1",
76+
deviceId = "device-1",
77+
token = "shared-token",
78+
timestamp = 1000L
79+
)
80+
)
81+
82+
assertEquals(1, json.getInt("version"))
83+
assertEquals("profile-1", json.getString("id"))
84+
assertEquals("device-1", json.getString("deviceId"))
85+
assertEquals(1000L, json.getLong("timestamp"))
86+
assertEquals("pointer.profile", json.getString("type"))
87+
assertEquals(0, json.getJSONObject("payload").length())
88+
assertEquals("6g3iFCFoeVuM016G2evT0XlnNMgzqd3lflUYPNfHuI8", json.getString("auth"))
89+
assertFalse(json.toString().contains("shared-token"))
90+
}
91+
92+
@Test
93+
fun parsesPointerProfileResponse() {
94+
val response = PcProtocol.parseResponse(validPointerProfileResponse()) as PcProtocolResponse.PointerProfile
95+
96+
assertEquals("profile-1", response.id)
97+
assertEquals("0:0:1280:720:1.5", response.profile.displayId)
98+
assertEquals(1.5, response.profile.scaleFactor, 0.0)
99+
assertEquals(1280, response.profile.bounds.width)
100+
assertEquals(500, response.profile.maxDelta)
101+
assertEquals(130, response.profile.recommendedDeltas.medium)
102+
}
103+
104+
@Test
105+
fun rejectsMalformedPointerProfileResponses() {
106+
assertEquals(
107+
PcProtocolResponse.Invalid,
108+
PcProtocol.parseResponse(validPointerProfileResponse(displayId = ""))
109+
)
110+
assertEquals(
111+
PcProtocolResponse.Invalid,
112+
PcProtocol.parseResponse(validPointerProfileResponse(scaleFactor = 0.0))
113+
)
114+
assertEquals(
115+
PcProtocolResponse.Invalid,
116+
PcProtocol.parseResponse(validPointerProfileResponse(width = 0))
117+
)
118+
assertEquals(
119+
PcProtocolResponse.Invalid,
120+
PcProtocol.parseResponse(
121+
"""
122+
{"version":1,"id":"profile-1","type":"pointer.profile","ok":true,"payload":{"displayId":"0:0:1280:720:1.5","scaleFactor":1.5,"bounds":{"x":0,"y":0,"width":1280,"height":720},"maxDelta":500},"error":null}
123+
""".trimIndent()
124+
)
125+
)
126+
}
127+
71128
@Test
72129
fun buildsMouseMoveCommandWithAuthProof() {
73130
val json = JSONObject(
@@ -108,4 +165,14 @@ class PcProtocolTest {
108165
assertEquals("mouse.scroll", scroll.getString("type"))
109166
assertEquals(5, scroll.getJSONObject("payload").getInt("dy"))
110167
}
168+
169+
private fun validPointerProfileResponse(
170+
displayId: String = "0:0:1280:720:1.5",
171+
scaleFactor: Double = 1.5,
172+
width: Int = 1280
173+
): String {
174+
return """
175+
{"version":1,"id":"profile-1","type":"pointer.profile","ok":true,"payload":{"displayId":"$displayId","scaleFactor":$scaleFactor,"bounds":{"x":0,"y":0,"width":$width,"height":720},"maxDelta":500,"recommendedDeltas":{"small":50,"medium":130,"large":252}},"error":null}
176+
""".trimIndent()
177+
}
111178
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.enaboapps.switchify.screens.pc
2+
3+
import com.enaboapps.switchify.pc.PcMouseCommand
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
7+
class PcMouseCommandGridTest {
8+
@Test
9+
fun movementCommandsUseProvidedStep() {
10+
val commands = pcMouseControlSpecs(130).map { it.command }
11+
12+
assertEquals(PcMouseCommand.Move(-130, -130), commands[0])
13+
assertEquals(PcMouseCommand.Move(0, -130), commands[1])
14+
assertEquals(PcMouseCommand.Move(130, -130), commands[2])
15+
assertEquals(PcMouseCommand.Move(-130, 0), commands[3])
16+
assertEquals(PcMouseCommand.Move(130, 0), commands[5])
17+
assertEquals(PcMouseCommand.Move(-130, 130), commands[6])
18+
assertEquals(PcMouseCommand.Move(0, 130), commands[7])
19+
assertEquals(PcMouseCommand.Move(130, 130), commands[8])
20+
}
21+
22+
@Test
23+
fun scrollCommandsKeepExistingStep() {
24+
val commands = pcMouseControlSpecs(130).map { it.command }
25+
26+
assertEquals(PcMouseCommand.Scroll(0, 5), commands[11])
27+
assertEquals(PcMouseCommand.Scroll(0, -5), commands[12])
28+
}
29+
}

0 commit comments

Comments
 (0)