Skip to content

Commit 4343649

Browse files
Keyboard: combine state + nodes flows, drop stale node batches (#2255)
Wires the deferred pieces from #2249 and #2250 together: - KeyboardNodesState.isStaleAgainst(currentBounds) compares the captured IME bounds against a live target. - KeyboardNodesPolicy.shouldDropAsStale(nodes, state) lifts the full drop-or-forward rule into one place (hidden / uninitialized / stale). - StartupOrchestrator combines KeyboardManager.keyboardState with NodeExaminer.keyboardNodesState and only forwards a batch to the scanner when the policy says it is current. Closes the race where the keyboard scanner could receive a node batch captured against a previous IME's bounds — e.g. swapping IMEs while item-scanning, or the initial empty default emission before NodeExaminer has run. Unit tests cover the hidden and uninitialized drop paths. The bounds-mismatch path needs android.graphics.Rect, which throws "Stub!" in pure-JVM tests, and is left to on-device verification. Fixes #2251 Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 2d30e62 commit 4343649

4 files changed

Lines changed: 79 additions & 4 deletions

File tree

app/src/main/java/com/enaboapps/switchify/service/core/StartupOrchestrator.kt

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package com.enaboapps.switchify.service.core
22

33
import android.accessibilityservice.AccessibilityService
4+
import com.enaboapps.switchify.service.keyboard.KeyboardManager
5+
import com.enaboapps.switchify.service.keyboard.KeyboardNodesPolicy
46
import com.enaboapps.switchify.service.techniques.nodes.NodeExaminer
57
import kotlinx.coroutines.CoroutineScope
68
import kotlinx.coroutines.delay
9+
import kotlinx.coroutines.flow.combine
10+
import kotlinx.coroutines.flow.filter
11+
import kotlinx.coroutines.flow.map
712
import kotlinx.coroutines.launch
813

914
class StartupOrchestrator(
@@ -14,6 +19,8 @@ class StartupOrchestrator(
1419
private const val STARTUP_EXAM_DELAY_MS = 100L
1520
}
1621

22+
private val keyboardNodesPolicy = KeyboardNodesPolicy()
23+
1724
suspend fun executeStartupTasks(processAccessibilityEvent: suspend () -> Unit) {
1825
// Initial accessibility tree examination
1926
processAccessibilityEvent()
@@ -27,9 +34,19 @@ class StartupOrchestrator(
2734
}
2835
}
2936
serviceScope.launch {
30-
NodeExaminer.keyboardNodesState.collect { state ->
31-
scanningManager.updateKeyboardNodes(state.nodes)
32-
}
37+
// Combine keyboard state with the node batches so the scanner
38+
// only sees nodes whose captured bounds match the current IME.
39+
// Drops the initial empty batch and any leftover from a
40+
// previous keyboard layout (e.g. when swapping IMEs).
41+
combine(
42+
KeyboardManager.keyboardState,
43+
NodeExaminer.keyboardNodesState
44+
) { state, nodes -> state to nodes }
45+
.filter { (state, nodes) ->
46+
!keyboardNodesPolicy.shouldDropAsStale(nodes, state)
47+
}
48+
.map { (_, nodes) -> nodes.nodes }
49+
.collect { scanningManager.updateKeyboardNodes(it) }
3350
}
3451
}
3552
}

app/src/main/java/com/enaboapps/switchify/service/keyboard/KeyboardNodesPolicy.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.enaboapps.switchify.service.keyboard
22

33
import com.enaboapps.switchify.service.techniques.AccessTechnique
4+
import com.enaboapps.switchify.service.techniques.nodes.KeyboardNodesState
45

56
/**
67
* Encapsulates routing and lifetime decisions for the keyboard node scanner.
@@ -36,4 +37,21 @@ class KeyboardNodesPolicy {
3637
fun shouldKeepKeyboardScannerAlive(state: KeyboardState): Boolean {
3738
return state.isVisible
3839
}
40+
41+
/**
42+
* Whether a [KeyboardNodesState] batch should be dropped instead of
43+
* forwarded to the keyboard scanner.
44+
*
45+
* Drops when:
46+
* - The keyboard is hidden — nodes are not relevant.
47+
* - The batch carries no bounds — it is the default uninitialized state
48+
* from before NodeExaminer has produced its first emission.
49+
* - The captured bounds differ from the current IME bounds — the batch
50+
* was extracted from a previous keyboard layout.
51+
*/
52+
fun shouldDropAsStale(nodes: KeyboardNodesState, state: KeyboardState): Boolean {
53+
return !state.isVisible ||
54+
nodes.keyboardBounds == null ||
55+
nodes.isStaleAgainst(state.keyboardBounds)
56+
}
3957
}

app/src/main/java/com/enaboapps/switchify/service/techniques/nodes/KeyboardNodesState.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,18 @@ import android.graphics.Rect
1616
data class KeyboardNodesState(
1717
val nodes: List<Node> = emptyList(),
1818
val keyboardBounds: Rect? = null
19-
)
19+
) {
20+
/**
21+
* True when this batch's captured bounds differ from [currentBounds],
22+
* meaning the nodes belong to a previous keyboard layout.
23+
*
24+
* Returns false when either side is null — callers needing to reject
25+
* uninitialized batches (no bounds captured yet) should check that
26+
* separately or go through `KeyboardNodesPolicy.shouldDropAsStale`.
27+
*/
28+
fun isStaleAgainst(currentBounds: Rect?): Boolean {
29+
return keyboardBounds != null &&
30+
currentBounds != null &&
31+
keyboardBounds != currentBounds
32+
}
33+
}

app/src/test/java/com/enaboapps/switchify/service/keyboard/KeyboardNodesPolicyTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.enaboapps.switchify.service.keyboard
22

33
import com.enaboapps.switchify.service.techniques.AccessTechnique
4+
import com.enaboapps.switchify.service.techniques.nodes.KeyboardNodesState
45
import org.junit.Assert.assertFalse
56
import org.junit.Assert.assertTrue
67
import org.junit.Test
@@ -74,4 +75,29 @@ class KeyboardNodesPolicyTest {
7475
)
7576
)
7677
}
78+
79+
@Test
80+
fun dropsBatchWhenKeyboardHidden() {
81+
// Hidden keyboard with stale (or any) captured nodes — drop regardless.
82+
val state = KeyboardState(isVisible = false)
83+
val nodes = KeyboardNodesState() // uses default null bounds
84+
85+
assertTrue(policy.shouldDropAsStale(nodes, state))
86+
}
87+
88+
@Test
89+
fun dropsUninitializedBatchWithNoCapturedBounds() {
90+
// Visible keyboard but NodeExaminer has not yet produced a batch —
91+
// the default KeyboardNodesState (bounds == null) must not leak
92+
// through as an empty node update.
93+
val state = KeyboardState(isVisible = true)
94+
val nodes = KeyboardNodesState() // default: no nodes, no bounds
95+
96+
assertTrue(policy.shouldDropAsStale(nodes, state))
97+
}
98+
99+
// Note: the bounds-mismatch and bounds-match cases of shouldDropAsStale
100+
// depend on android.graphics.Rect, which throws "Stub!" in pure-JVM unit
101+
// tests. Those paths are exercised on device by swapping IMEs while
102+
// item-scanning (see the PR test plan).
77103
}

0 commit comments

Comments
 (0)