Skip to content

Commit 08e2faf

Browse files
Add PC mouse repeat controls (#2551)
* Add PC mouse repeat controls - Repeat PC mouse movement and scroll commands until the next switch press - Add PC settings for enabling repeat and configuring repeat interval - Stop repeat on command failure, PC reconnect/disconnect, screen changes, and service cleanup - Cover repeat timing, repeatable command scope, ViewModel behavior, and task stopping 🤖 Auto-generated * Move PC mouse repeat settings link - Move Mouse Repeat entry from PC connection into main settings - Keep PC connection focused on setup and pairing 🤖 Auto-generated --------- Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 35c5a62 commit 08e2faf

19 files changed

Lines changed: 647 additions & 28 deletions

app/src/main/java/com/enaboapps/switchify/backend/preferences/PreferenceManager.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ class PreferenceManager(context: Context) {
2727
const val PREFERENCE_KEY_MOVE_REPEAT_DELAY = "move_repeat_delay"
2828
const val PREFERENCE_KEY_PC_MOUSE_MOVEMENT_SIZE = "pc_mouse_movement_size"
2929
const val PREFERENCE_KEY_PC_CONTROL_SURFACE = "pc_control_surface"
30+
const val PREFERENCE_KEY_PC_MOUSE_REPEAT = "pc_mouse_repeat"
31+
const val PREFERENCE_KEY_PC_MOUSE_REPEAT_INTERVAL = "pc_mouse_repeat_interval"
3032
const val PREFERENCE_KEY_AUTOMATICALLY_START_SCAN_AFTER_SELECTION =
3133
"automatically_start_scan_after_selection"
3234
const val PREFERENCE_KEY_PAUSE_ON_FIRST_ITEM = "pause_on_first_item"

app/src/main/java/com/enaboapps/switchify/nav/NavGraph.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.enaboapps.switchify.screens.account.AuthScreen
1313
import com.enaboapps.switchify.screens.onboarding.OnboardingScreen
1414
import com.enaboapps.switchify.screens.paywall.AppPaywallScreen
1515
import com.enaboapps.switchify.screens.pc.PcConnectionScreen
16+
import com.enaboapps.switchify.screens.pc.PcSettingsScreen
1617

1718
import com.enaboapps.switchify.screens.settings.CameraSettingsScreen
1819
import com.enaboapps.switchify.screens.settings.HeadControlSettingsScreen
@@ -75,6 +76,9 @@ fun NavGraph(navController: NavHostController) {
7576
composable(NavigationRoute.PcConnection.name) {
7677
PcConnectionScreen(navController)
7778
}
79+
composable(NavigationRoute.PcSettings.name) {
80+
PcSettingsScreen(navController)
81+
}
7882
composable(NavigationRoute.SwitchStability.name) {
7983
SwitchStabilityScreen(navController)
8084
}

app/src/main/java/com/enaboapps/switchify/nav/NavigationRoute.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ sealed class NavigationRoute(val name: String) {
99
data object Account : NavigationRoute("Account")
1010
data object Settings : NavigationRoute("Settings")
1111
data object PcConnection : NavigationRoute("PcConnection")
12+
data object PcSettings : NavigationRoute("PcSettings")
1213
data object SwitchStability : NavigationRoute("SwitchStability")
1314
data object AdvancedGestureSettings : NavigationRoute("AdvancedGestureSettings")
1415
data object ScrollingSettings : NavigationRoute("ScrollingSettings")
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package com.enaboapps.switchify.pc
2+
3+
import android.content.Context
4+
import com.enaboapps.switchify.R
5+
import com.enaboapps.switchify.backend.preferences.PreferenceManager
6+
import com.enaboapps.switchify.service.window.MessageSeverity
7+
import com.enaboapps.switchify.service.window.ServiceMessageHUD
8+
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.Job
10+
import kotlinx.coroutines.delay
11+
import kotlinx.coroutines.isActive
12+
import kotlinx.coroutines.launch
13+
14+
class PcMouseRepeatManager private constructor() {
15+
private var context: Context? = null
16+
private var repeatJob: Job? = null
17+
private var repeatedCommand: PcControlCommand? = null
18+
private var enabledProviderForTesting: (() -> Boolean)? = null
19+
private var intervalProviderForTesting: (() -> Long)? = null
20+
private var suppressHudForTesting = false
21+
private var messageRecorderForTesting: ((Int) -> Unit)? = null
22+
23+
companion object {
24+
const val DEFAULT_REPEAT_INTERVAL = 250L
25+
const val MIN_REPEAT_INTERVAL = 100L
26+
const val MAX_REPEAT_INTERVAL = 2000L
27+
const val REPEAT_INTERVAL_STEP = 50L
28+
29+
val instance: PcMouseRepeatManager by lazy { PcMouseRepeatManager() }
30+
31+
fun isRepeatable(command: PcControlCommand): Boolean {
32+
return command is PcControlCommand.Move || command is PcControlCommand.Scroll
33+
}
34+
}
35+
36+
fun init(context: Context) {
37+
this.context = context.applicationContext
38+
}
39+
40+
fun start(
41+
command: PcControlCommand,
42+
scope: CoroutineScope,
43+
sendCommand: suspend (PcControlCommand) -> PcCommandResult
44+
): Boolean {
45+
if (!isRepeatable(command) || !isEnabled()) return false
46+
47+
stop(showMessage = false)
48+
repeatedCommand = command
49+
repeatJob = scope.launch {
50+
when (sendCommand(command)) {
51+
PcCommandResult.Ack -> showMessage(R.string.pc_mouse_repeat_started, MessageSeverity.Success)
52+
is PcCommandResult.AuthFailed,
53+
is PcCommandResult.Failed -> {
54+
clearRepeatState()
55+
showMessage(R.string.pc_mouse_repeat_stopped, MessageSeverity.Info)
56+
return@launch
57+
}
58+
}
59+
60+
while (isActive) {
61+
delay(getRepeatInterval())
62+
if (!isActive) return@launch
63+
when (sendCommand(command)) {
64+
PcCommandResult.Ack -> Unit
65+
is PcCommandResult.AuthFailed,
66+
is PcCommandResult.Failed -> {
67+
clearRepeatState()
68+
showMessage(R.string.pc_mouse_repeat_stopped, MessageSeverity.Info)
69+
return@launch
70+
}
71+
}
72+
}
73+
}
74+
return true
75+
}
76+
77+
fun stop(showMessage: Boolean = true): Boolean {
78+
if (!isRepeating()) {
79+
clearRepeatState()
80+
return false
81+
}
82+
repeatJob?.cancel()
83+
clearRepeatState()
84+
if (showMessage) {
85+
showMessage(R.string.pc_mouse_repeat_stopped, MessageSeverity.Info)
86+
}
87+
return true
88+
}
89+
90+
fun stopForSwitchPress(): Boolean = stop()
91+
92+
fun isRepeating(): Boolean {
93+
return repeatJob?.isActive == true && repeatedCommand != null
94+
}
95+
96+
fun clearServiceState(showMessage: Boolean = false) {
97+
stop(showMessage)
98+
}
99+
100+
private fun isEnabled(): Boolean {
101+
enabledProviderForTesting?.let { return it() }
102+
return context?.let {
103+
PreferenceManager(it).getBooleanValue(
104+
PreferenceManager.PREFERENCE_KEY_PC_MOUSE_REPEAT,
105+
true
106+
)
107+
} ?: true
108+
}
109+
110+
private fun getRepeatInterval(): Long {
111+
val interval = intervalProviderForTesting?.invoke()
112+
?: context?.let {
113+
PreferenceManager(it).getLongValue(
114+
PreferenceManager.PREFERENCE_KEY_PC_MOUSE_REPEAT_INTERVAL,
115+
DEFAULT_REPEAT_INTERVAL
116+
)
117+
}
118+
?: DEFAULT_REPEAT_INTERVAL
119+
return interval.coerceIn(MIN_REPEAT_INTERVAL, MAX_REPEAT_INTERVAL)
120+
}
121+
122+
private fun clearRepeatState() {
123+
repeatJob = null
124+
repeatedCommand = null
125+
}
126+
127+
private fun showMessage(messageResId: Int, severity: MessageSeverity) {
128+
messageRecorderForTesting?.invoke(messageResId)
129+
if (suppressHudForTesting) return
130+
ServiceMessageHUD.instance.showMessage(
131+
messageResId,
132+
ServiceMessageHUD.MessageType.DISAPPEARING,
133+
severity = severity
134+
)
135+
}
136+
137+
internal fun setEnabledProviderForTesting(provider: (() -> Boolean)?) {
138+
enabledProviderForTesting = provider
139+
}
140+
141+
internal fun setIntervalProviderForTesting(provider: (() -> Long)?) {
142+
intervalProviderForTesting = provider
143+
}
144+
145+
internal fun setSuppressHudForTesting(suppress: Boolean) {
146+
suppressHudForTesting = suppress
147+
}
148+
149+
internal fun setMessageRecorderForTesting(recorder: ((Int) -> Unit)?) {
150+
messageRecorderForTesting = recorder
151+
}
152+
153+
internal fun resetForTesting() {
154+
repeatJob?.cancel()
155+
repeatJob = null
156+
repeatedCommand = null
157+
context = null
158+
enabledProviderForTesting = null
159+
intervalProviderForTesting = null
160+
suppressHudForTesting = false
161+
messageRecorderForTesting = null
162+
}
163+
}

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

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ data class PcMouseControlSpec(
5858
val command: PcControlCommand,
5959
val icon: ImageVector? = null,
6060
val iconRotationDegrees: Float = 0f,
61-
val tone: PcCommandTone = PcCommandTone.Neutral
61+
val tone: PcCommandTone = PcCommandTone.Neutral,
62+
val repeatable: Boolean = false
6263
)
6364

6465
enum class PcCommandTone {
@@ -73,7 +74,8 @@ data class PcCompactCommandCell(
7374
val onClick: () -> Unit,
7475
val icon: ImageVector? = null,
7576
val iconRotationDegrees: Float = 0f,
76-
val tone: PcCommandTone = PcCommandTone.Neutral
77+
val tone: PcCommandTone = PcCommandTone.Neutral,
78+
val repeatable: Boolean = false
7779
)
7880

7981
/**
@@ -144,7 +146,7 @@ fun PcMovementSizeSection(
144146
fun PcControlCommandGrid(
145147
enabled: Boolean,
146148
movementStep: Int,
147-
onCommandSelected: (PcControlCommand) -> Unit,
149+
onCommandSelected: (PcControlCommand, Boolean) -> Unit,
148150
modifier: Modifier = Modifier
149151
) {
150152
PcControlCommandSections(
@@ -159,7 +161,7 @@ fun PcControlCommandGrid(
159161
fun PcControlCommandSections(
160162
enabled: Boolean,
161163
movementStep: Int,
162-
onCommandSelected: (PcControlCommand) -> Unit,
164+
onCommandSelected: (PcControlCommand, Boolean) -> Unit,
163165
modifier: Modifier = Modifier
164166
) {
165167
PcCompactCommandGrid(
@@ -170,10 +172,11 @@ fun PcControlCommandSections(
170172
PcCompactCommandCell(
171173
labelResId = it.labelResId,
172174
enabled = enabled,
173-
onClick = { onCommandSelected(it.command) },
175+
onClick = { onCommandSelected(it.command, it.repeatable) },
174176
icon = it.icon,
175177
iconRotationDegrees = it.iconRotationDegrees,
176-
tone = it.tone
178+
tone = it.tone,
179+
repeatable = it.repeatable
177180
)
178181
}
179182
},
@@ -397,29 +400,53 @@ fun pcMovementControlSpecs(moveStep: Int): List<PcMouseControlSpec> {
397400
R.string.pc_mouse_up_left,
398401
PcControlCommand.Move(-step, -step),
399402
Icons.Default.KeyboardArrowUp,
400-
-45f
403+
-45f,
404+
repeatable = true
405+
),
406+
PcMouseControlSpec(
407+
R.string.pc_mouse_up,
408+
PcControlCommand.Move(0, -step),
409+
Icons.Default.KeyboardArrowUp,
410+
repeatable = true
401411
),
402-
PcMouseControlSpec(R.string.pc_mouse_up, PcControlCommand.Move(0, -step), Icons.Default.KeyboardArrowUp),
403412
PcMouseControlSpec(
404413
R.string.pc_mouse_up_right,
405414
PcControlCommand.Move(step, -step),
406415
Icons.Default.KeyboardArrowUp,
407-
45f
416+
45f,
417+
repeatable = true
418+
),
419+
PcMouseControlSpec(
420+
R.string.pc_mouse_left,
421+
PcControlCommand.Move(-step, 0),
422+
Icons.AutoMirrored.Filled.ArrowBack,
423+
repeatable = true
424+
),
425+
PcMouseControlSpec(
426+
R.string.pc_mouse_right,
427+
PcControlCommand.Move(step, 0),
428+
Icons.AutoMirrored.Filled.ArrowForward,
429+
repeatable = true
408430
),
409-
PcMouseControlSpec(R.string.pc_mouse_left, PcControlCommand.Move(-step, 0), Icons.AutoMirrored.Filled.ArrowBack),
410-
PcMouseControlSpec(R.string.pc_mouse_right, PcControlCommand.Move(step, 0), Icons.AutoMirrored.Filled.ArrowForward),
411431
PcMouseControlSpec(
412432
R.string.pc_mouse_down_left,
413433
PcControlCommand.Move(-step, step),
414434
Icons.Default.KeyboardArrowDown,
415-
45f
435+
45f,
436+
repeatable = true
437+
),
438+
PcMouseControlSpec(
439+
R.string.pc_mouse_down,
440+
PcControlCommand.Move(0, step),
441+
Icons.Default.KeyboardArrowDown,
442+
repeatable = true
416443
),
417-
PcMouseControlSpec(R.string.pc_mouse_down, PcControlCommand.Move(0, step), Icons.Default.KeyboardArrowDown),
418444
PcMouseControlSpec(
419445
R.string.pc_mouse_down_right,
420446
PcControlCommand.Move(step, step),
421447
Icons.Default.KeyboardArrowDown,
422-
-45f
448+
-45f,
449+
repeatable = true
423450
)
424451
)
425452
}
@@ -441,11 +468,17 @@ fun pcClickControlSpecs(): List<PcMouseControlSpec> {
441468
fun pcScrollControlSpecs(): List<PcMouseControlSpec> {
442469
val scrollStep = 5
443470
return listOf(
444-
PcMouseControlSpec(R.string.pc_mouse_scroll_up, PcControlCommand.Scroll(0, scrollStep), Icons.Default.KeyboardArrowUp),
471+
PcMouseControlSpec(
472+
R.string.pc_mouse_scroll_up,
473+
PcControlCommand.Scroll(0, scrollStep),
474+
Icons.Default.KeyboardArrowUp,
475+
repeatable = true
476+
),
445477
PcMouseControlSpec(
446478
R.string.pc_mouse_scroll_down,
447479
PcControlCommand.Scroll(0, -scrollStep),
448-
Icons.Default.KeyboardArrowDown
480+
Icons.Default.KeyboardArrowDown,
481+
repeatable = true
449482
)
450483
)
451484
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ private fun PcMouseControlScreen(
136136
PcControlCommandGrid(
137137
enabled = surfaceEnabled,
138138
movementStep = uiState.movementStep,
139-
onCommandSelected = viewModel::send
139+
onCommandSelected = viewModel::sendMouseCommand
140140
)
141141
PcMovementSizeSection(
142142
selectedSize = uiState.selectedMovementSize,

0 commit comments

Comments
 (0)