Skip to content

Commit 9cb29f8

Browse files
Add PC mouse repeat reconnect grace (#2582)
- Pause active PC mouse repeat for a five-second reconnect grace instead of stopping immediately - Resume the same repeated move or scroll command when the control session reconnects - Keep explicit disconnects, auth failures, and grace expiry as immediate stop paths - Cover repeat manager and PC mouse control reconnect scenarios in unit tests 🤖 Auto-generated Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 6ebc64b commit 9cb29f8

4 files changed

Lines changed: 319 additions & 10 deletions

File tree

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

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ class PcMouseRepeatManager internal constructor(
1515
private var showHudMessage: (Int, MessageSeverity) -> Unit = defaultHudMessageHandler()
1616
) {
1717
private var repeatJob: Job? = null
18+
private var reconnectGraceJob: Job? = null
1819
private var repeatedCommand: PcControlCommand? = null
1920
private var repeatArmed = false
21+
private var pausedForReconnect = false
2022

2123
companion object {
24+
internal const val RECONNECT_GRACE_MS = 5_000L
2225
val instance: PcMouseRepeatManager by lazy { PcMouseRepeatManager() }
2326

2427
fun isRepeatable(command: PcControlCommand): Boolean {
@@ -47,6 +50,7 @@ class PcMouseRepeatManager internal constructor(
4750
fun startAfterInitialSend(
4851
command: PcControlCommand,
4952
scope: CoroutineScope,
53+
shouldPauseForReconnect: () -> Boolean = { false },
5054
sendRepeatedCommand: suspend (PcControlCommand) -> PcCommandResult
5155
): Boolean {
5256
if (!isRepeatable(command)) return false
@@ -57,6 +61,61 @@ class PcMouseRepeatManager internal constructor(
5761
if (!repeatArmed || repeatedCommand != command) return false
5862

5963
repeatJob?.cancel()
64+
reconnectGraceJob?.cancel()
65+
reconnectGraceJob = null
66+
pausedForReconnect = false
67+
startRepeatJob(command, scope, sendRepeatedCommand, shouldPauseForReconnect)
68+
return true
69+
}
70+
71+
fun pauseForReconnect(
72+
scope: CoroutineScope,
73+
graceMs: Long = RECONNECT_GRACE_MS
74+
): Boolean {
75+
if (!isRepeating()) return false
76+
if (pausedForReconnect) return true
77+
78+
repeatJob?.cancel()
79+
repeatJob = null
80+
pausedForReconnect = true
81+
reconnectGraceJob?.cancel()
82+
reconnectGraceJob = scope.launch {
83+
delay(graceMs)
84+
if (pausedForReconnect) {
85+
stop(showMessage = true)
86+
}
87+
}
88+
return true
89+
}
90+
91+
fun resumeAfterReconnect(
92+
scope: CoroutineScope,
93+
sendRepeatedCommand: suspend (PcControlCommand) -> PcCommandResult
94+
): Boolean {
95+
val command = repeatedCommand ?: return false
96+
if (!pausedForReconnect) return false
97+
if (!canRepeat(command)) {
98+
stop(showMessage = false)
99+
return false
100+
}
101+
102+
reconnectGraceJob?.cancel()
103+
reconnectGraceJob = null
104+
pausedForReconnect = false
105+
startRepeatJob(command, scope, sendRepeatedCommand) { false }
106+
return true
107+
}
108+
109+
fun isPausedForReconnect(): Boolean {
110+
return pausedForReconnect
111+
}
112+
113+
private fun startRepeatJob(
114+
command: PcControlCommand,
115+
scope: CoroutineScope,
116+
sendRepeatedCommand: suspend (PcControlCommand) -> PcCommandResult,
117+
shouldPauseForReconnect: () -> Boolean
118+
) {
60119
repeatedCommand = command
61120
repeatArmed = true
62121
repeatJob = scope.launch {
@@ -67,16 +126,18 @@ class PcMouseRepeatManager internal constructor(
67126
stop()
68127
return@launch
69128
}
70-
if (!sendAndContinue(command, sendRepeatedCommand)) return@launch
129+
if (!sendAndContinue(command, scope, sendRepeatedCommand, shouldPauseForReconnect)) {
130+
return@launch
131+
}
71132
}
72133
}
73-
return true
74134
}
75135

76136
fun cancelPendingStart(showMessage: Boolean = false): Boolean {
77137
if (!isRepeating()) return false
78138

79139
repeatJob?.cancel()
140+
reconnectGraceJob?.cancel()
80141
clearRepeatState()
81142
if (showMessage) {
82143
showMessage(R.string.pc_mouse_repeat_stopped, MessageSeverity.Info)
@@ -90,6 +151,7 @@ class PcMouseRepeatManager internal constructor(
90151
return false
91152
}
92153
repeatJob?.cancel()
154+
reconnectGraceJob?.cancel()
93155
clearRepeatState()
94156
if (showMessage) {
95157
showMessage(R.string.pc_mouse_repeat_stopped, MessageSeverity.Info)
@@ -109,16 +171,26 @@ class PcMouseRepeatManager internal constructor(
109171

110172
private suspend fun sendAndContinue(
111173
command: PcControlCommand,
112-
sendCommand: suspend (PcControlCommand) -> PcCommandResult
174+
scope: CoroutineScope,
175+
sendCommand: suspend (PcControlCommand) -> PcCommandResult,
176+
shouldPauseForReconnect: () -> Boolean
113177
): Boolean {
114178
return when (sendCommand(command)) {
115179
PcCommandResult.Ack -> true
116-
is PcCommandResult.AuthFailed,
117-
is PcCommandResult.Failed -> {
180+
is PcCommandResult.AuthFailed -> {
118181
clearRepeatState()
119182
showMessage(R.string.pc_mouse_repeat_stopped, MessageSeverity.Info)
120183
false
121184
}
185+
is PcCommandResult.Failed -> {
186+
if (shouldPauseForReconnect()) {
187+
pauseForReconnect(scope)
188+
} else {
189+
clearRepeatState()
190+
showMessage(R.string.pc_mouse_repeat_stopped, MessageSeverity.Info)
191+
}
192+
false
193+
}
122194
}
123195
}
124196

@@ -131,8 +203,10 @@ class PcMouseRepeatManager internal constructor(
131203

132204
private fun clearRepeatState() {
133205
repeatJob = null
206+
reconnectGraceJob = null
134207
repeatedCommand = null
135208
repeatArmed = false
209+
pausedForReconnect = false
136210
}
137211

138212
private fun showMessage(messageResId: Int, severity: MessageSeverity) {
@@ -141,9 +215,12 @@ class PcMouseRepeatManager internal constructor(
141215

142216
internal fun resetForTesting() {
143217
repeatJob?.cancel()
218+
reconnectGraceJob?.cancel()
144219
repeatJob = null
220+
reconnectGraceJob = null
145221
repeatedCommand = null
146222
repeatArmed = false
223+
pausedForReconnect = false
147224
settings = null
148225
showHudMessage = defaultHudMessageHandler()
149226
}

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,13 @@ class PcMouseControlViewModel(
157157
mouseRepeatManager.startAfterInitialSend(
158158
command = command,
159159
scope = viewModelScope,
160-
sendRepeatedCommand = ::sendRepeatCommand
160+
sendRepeatedCommand = ::sendRepeatCommand,
161+
shouldPauseForReconnect = {
162+
val controller = serviceControllerProvider()
163+
controller?.state?.value is PcServiceConnectionState.Reconnecting ||
164+
(controller?.state?.value is PcServiceConnectionState.Connected && !controller.hasLiveControlSession()) ||
165+
PcConnectionStateHolder.connectionState.value is PcConnectionState.Reconnecting
166+
}
161167
)
162168
}
163169
is PcCommandResult.AuthFailed,
@@ -738,9 +744,13 @@ class PcMouseControlViewModel(
738744
)
739745
)
740746
}
747+
mouseRepeatManager.resumeAfterReconnect(
748+
scope = viewModelScope,
749+
sendRepeatedCommand = ::sendRepeatCommand
750+
)
741751
}
742752
is PcServiceConnectionState.Reconnecting -> {
743-
mouseRepeatManager.clearServiceState()
753+
mouseRepeatManager.pauseForReconnect(viewModelScope)
744754
_uiState.update {
745755
it.copy(
746756
connectedDisplayName = state.displayName,
@@ -810,7 +820,7 @@ class PcMouseControlViewModel(
810820
private fun applySharedConnectionState(state: PcConnectionState) {
811821
when (state) {
812822
is PcConnectionState.Reconnecting -> {
813-
mouseRepeatManager.clearServiceState()
823+
mouseRepeatManager.pauseForReconnect(viewModelScope)
814824
_uiState.update {
815825
it.copy(
816826
connectedDisplayName = state.displayName,

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

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,155 @@ class PcMouseRepeatManagerTest {
177177
assertFalse(repeatManager.isRepeating())
178178
}
179179

180+
@Test
181+
fun pauseForReconnectKeepsRepeatArmedWithoutSending() = runTest {
182+
val commands = mutableListOf<PcControlCommand>()
183+
val repeatManager = repeatManager(intervalMs = 100L)
184+
val command = PcControlCommand.Move(5, 0)
185+
186+
assertTrue(repeatManager.armForInitialSend(command))
187+
assertTrue(
188+
repeatManager.startAfterInitialSend(command, this) {
189+
commands += it
190+
PcCommandResult.Ack
191+
}
192+
)
193+
assertTrue(repeatManager.pauseForReconnect(this))
194+
195+
advanceTimeBy(PcMouseRepeatManager.RECONNECT_GRACE_MS - 1)
196+
runCurrent()
197+
198+
assertTrue(repeatManager.isRepeating())
199+
assertTrue(repeatManager.isPausedForReconnect())
200+
assertEquals(emptyList<PcControlCommand>(), commands)
201+
repeatManager.stop(showMessage = false)
202+
}
203+
204+
@Test
205+
fun resumeAfterReconnectRestartsRepeatAfterInterval() = runTest {
206+
val commands = mutableListOf<PcControlCommand>()
207+
val repeatManager = repeatManager(intervalMs = 100L)
208+
val command = PcControlCommand.Scroll(0, 5)
209+
210+
assertTrue(repeatManager.armForInitialSend(command))
211+
assertTrue(
212+
repeatManager.startAfterInitialSend(command, this) {
213+
commands += it
214+
PcCommandResult.Ack
215+
}
216+
)
217+
assertTrue(repeatManager.pauseForReconnect(this))
218+
219+
assertTrue(
220+
repeatManager.resumeAfterReconnect(this) {
221+
commands += it
222+
PcCommandResult.Ack
223+
}
224+
)
225+
advanceTimeBy(99)
226+
runCurrent()
227+
assertEquals(emptyList<PcControlCommand>(), commands)
228+
229+
advanceTimeBy(1)
230+
runCurrent()
231+
232+
assertFalse(repeatManager.isPausedForReconnect())
233+
assertEquals(listOf(command), commands)
234+
repeatManager.stop(showMessage = false)
235+
}
236+
237+
@Test
238+
fun pauseForReconnectStopsAfterGraceExpires() = runTest {
239+
val messages = mutableListOf<Int>()
240+
val repeatManager = repeatManager(intervalMs = 100L, messages = messages)
241+
val command = PcControlCommand.Move(5, 0)
242+
243+
assertTrue(repeatManager.armForInitialSend(command))
244+
assertTrue(
245+
repeatManager.startAfterInitialSend(command, this) {
246+
PcCommandResult.Ack
247+
}
248+
)
249+
assertTrue(repeatManager.pauseForReconnect(this))
250+
251+
advanceTimeBy(PcMouseRepeatManager.RECONNECT_GRACE_MS)
252+
runCurrent()
253+
254+
assertFalse(repeatManager.isRepeating())
255+
assertFalse(repeatManager.isPausedForReconnect())
256+
assertEquals(
257+
listOf(
258+
R.string.pc_mouse_repeat_started,
259+
R.string.pc_mouse_repeat_stopped
260+
),
261+
messages
262+
)
263+
}
264+
265+
@Test
266+
fun stopForSwitchPressStopsPausedReconnectRepeat() = runTest {
267+
val repeatManager = repeatManager(intervalMs = 100L)
268+
val command = PcControlCommand.Move(5, 0)
269+
270+
assertTrue(repeatManager.armForInitialSend(command))
271+
assertTrue(
272+
repeatManager.startAfterInitialSend(command, this) {
273+
PcCommandResult.Ack
274+
}
275+
)
276+
assertTrue(repeatManager.pauseForReconnect(this))
277+
278+
assertTrue(repeatManager.stopForSwitchPress())
279+
280+
assertFalse(repeatManager.isRepeating())
281+
assertFalse(repeatManager.isPausedForReconnect())
282+
}
283+
284+
@Test
285+
fun failedRepeatCommandPausesWhenReconnectPredicateIsTrue() = runTest {
286+
val repeatManager = repeatManager(intervalMs = 100L)
287+
val command = PcControlCommand.Move(5, 0)
288+
289+
assertTrue(repeatManager.armForInitialSend(command))
290+
assertTrue(
291+
repeatManager.startAfterInitialSend(
292+
command = command,
293+
scope = this,
294+
sendRepeatedCommand = { PcCommandResult.Failed() },
295+
shouldPauseForReconnect = { true }
296+
)
297+
)
298+
299+
advanceTimeBy(100)
300+
runCurrent()
301+
302+
assertTrue(repeatManager.isRepeating())
303+
assertTrue(repeatManager.isPausedForReconnect())
304+
repeatManager.stop(showMessage = false)
305+
}
306+
307+
@Test
308+
fun failedRepeatCommandStopsWhenReconnectPredicateIsFalse() = runTest {
309+
val repeatManager = repeatManager(intervalMs = 100L)
310+
val command = PcControlCommand.Move(5, 0)
311+
312+
assertTrue(repeatManager.armForInitialSend(command))
313+
assertTrue(
314+
repeatManager.startAfterInitialSend(
315+
command = command,
316+
scope = this,
317+
sendRepeatedCommand = { PcCommandResult.Failed() },
318+
shouldPauseForReconnect = { false }
319+
)
320+
)
321+
322+
advanceTimeBy(100)
323+
runCurrent()
324+
325+
assertFalse(repeatManager.isRepeating())
326+
assertFalse(repeatManager.isPausedForReconnect())
327+
}
328+
180329
@Test
181330
fun authFailureStopsRepeat() = runTest {
182331
val repeatManager = repeatManager(intervalMs = 100L)

0 commit comments

Comments
 (0)