diff --git a/app/screenshots/onboarding/08-sundial.png b/app/screenshots/onboarding/08-sundial.png index ff7e7eeb..f9d17ede 100644 Binary files a/app/screenshots/onboarding/08-sundial.png and b/app/screenshots/onboarding/08-sundial.png differ diff --git a/app/src/main/java/hrln/interactive/sidekit/MainActivity.kt b/app/src/main/java/hrln/interactive/sidekit/MainActivity.kt index b4bda5c8..28b3d659 100644 --- a/app/src/main/java/hrln/interactive/sidekit/MainActivity.kt +++ b/app/src/main/java/hrln/interactive/sidekit/MainActivity.kt @@ -20,7 +20,10 @@ import hrln.interactive.sidekit.apps.sidemessage.IncomingLinks import hrln.interactive.sidekit.apps.sideport.SideportLinks import hrln.interactive.sidekit.design.action.ChromeState import hrln.interactive.sidekit.design.canvas.SidekitCanvas +import hrln.interactive.sidekit.design.input.HoldToAct import hrln.interactive.sidekit.design.input.Keypad +import hrln.interactive.sidekit.design.input.KeypadModule +import hrln.interactive.sidekit.design.input.NavControls import hrln.interactive.sidekit.design.input.SystemKeys import hrln.interactive.sidekit.design.theme.SidekitColors import hrln.interactive.sidekit.design.theme.SidekitSpacing @@ -38,6 +41,9 @@ class MainActivity : ComponentActivity() { /** The shared title + action bar; held here so hardware keys (Options) can reach it. */ private val chrome = ChromeState() + /** The Sundial has no Back/Options key: hold Tab / Enter, and hold Previous/Next for left/right. */ + private val sundialHold = HoldToAct(onHold = { action, source -> performSundialHold(action, source) }) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Opened from an sms: link or a message notification: go to that conversation. @@ -76,16 +82,78 @@ class MainActivity : ComponentActivity() { hrln.interactive.sidekit.apps.settings.SidekitPreferences(this).applyInputDefault(keypad) } + override fun onPause() { + sundialHold.reset() + super.onPause() + } + // Overriding dispatchKeyEvent is the only way to see a keypad event before Compose or the // system consumes it; lint flags the override because ComponentActivity marks it @RestrictTo. @SuppressLint("RestrictedApi") - override fun dispatchKeyEvent(raw: KeyEvent): Boolean { + override fun dispatchKeyEvent(event: KeyEvent): Boolean { // Detect the module from the raw key before navigation rewrites it, so a first QWERTY press // on a letter that is also a navigator (T/D/B/J) still reveals the module. - keypad.observe(raw) + keypad.observe(event) // Keypad mapping first, plus each module's default navigation, so Options, Back, focus and // typing all see the mapped key. - val event = keypad.translateForUi(raw) ?: return true + val translated = keypad.translateForUi(event) ?: return true + when (val hold = sundialHold.handle(translated, ::sundialHoldAction)) { + HoldToAct.Result.Pass -> Unit + + HoldToAct.Result.Consume -> return true + + is HoldToAct.Result.Deliver -> { + hold.events.forEach(::dispatchTranslated) + return true + } + } + return dispatchTranslated(translated) + } + + /** + * Performs a Sundial hold after the timer fires or the key is released late. + * + * Back comes from holding a hardware key, not from a system back gesture, so the + * predictive-back lint does not apply; the dispatcher still routes through registered + * callbacks. + */ + @SuppressLint("GestureBackNavigation") + private fun performSundialHold(action: Int, source: KeyEvent) { + when (action) { + KeyEvent.KEYCODE_BACK -> onBackPressedDispatcher.onBackPressed() + + KeyEvent.KEYCODE_ENDCALL -> { + keypad.finishTyping() + chrome.toggleOptions() + } + + else -> dispatchNav(action, source) + } + } + + /** The Sundial keys that get a hold action, and only while its layout is auto-detected. */ + private fun sundialHoldAction(code: Int): Int? { + if (keypad.module != KeypadModule.Sundial || keypad.mapper.customModule != null) return null + return NavControls.sundialHoldFor[code] + } + + /** Sends a held Sundial key's navigation action as a normal press/release. */ + private fun dispatchNav(code: Int, from: KeyEvent) { + val down = KeyEvent( + from.downTime, from.eventTime, KeyEvent.ACTION_DOWN, code, 0, + from.metaState, from.deviceId, from.scanCode, from.flags, from.source + ) + val up = KeyEvent( + from.downTime, from.eventTime + 1, KeyEvent.ACTION_UP, code, 0, + from.metaState, from.deviceId, from.scanCode, from.flags, from.source + ) + dispatchTranslated(down) + dispatchTranslated(up) + } + + /** Routes an already-translated event; also used for the tap released by [sundialHold]. */ + @SuppressLint("RestrictedApi") + private fun dispatchTranslated(event: KeyEvent): Boolean { when { // Keypad top-right: the universal Options key. Acts on release; the press is consumed // so the system's end-call handling never sees it. diff --git a/app/src/main/java/hrln/interactive/sidekit/onboarding/KeypadDemo.kt b/app/src/main/java/hrln/interactive/sidekit/onboarding/KeypadDemo.kt index d89ad3a6..3162800b 100644 --- a/app/src/main/java/hrln/interactive/sidekit/onboarding/KeypadDemo.kt +++ b/app/src/main/java/hrln/interactive/sidekit/onboarding/KeypadDemo.kt @@ -17,29 +17,48 @@ internal fun demoRows(module: KeypadModule): List> { val arrows = listOf(key("← ↑ ↓ →", "Move focus between controls. In text, left and right move the cursor or suggestion selection."), key("OK", "Activate the focused control or confirm a suggestion.")) return when (module) { KeypadModule.T9 -> listOf(navigation, arrows) + listOf( - listOf("1", "2 abc", "3 def"), listOf("4 ghi", "5 jkl", "6 mno"), listOf("7 pqrs", "8 tuv", "9 wxyz"), listOf("*", "0", "#"), - ).map { row -> row.map { label -> key(label, when (label) { - "*" -> "Tap * repeatedly to cycle punctuation and symbols." - "#" -> "Tap # to change letter case. Hold # to switch letters / numbers." - "0" -> "Tap 0 for a space in letter mode. Hold for the digit 0." - else -> "Tap repeatedly to cycle this key’s characters. Hold to type its digit." - }) } } + listOf(listOf(key("Delete", "Delete the previous character."))) + listOf("1", "2 abc", "3 def"), + listOf("4 ghi", "5 jkl", "6 mno"), + listOf("7 pqrs", "8 tuv", "9 wxyz"), + listOf("*", "0", "#") + ).map { row -> + row.map { label -> + key( + label, + when (label) { + "*" -> "Tap * repeatedly to cycle punctuation and symbols." + "#" -> "Tap # to change letter case. Hold # to switch letters / numbers." + "0" -> "Tap 0 for a space in letter mode. Hold for the digit 0." + else -> "Tap repeatedly to cycle this key’s characters. Hold to type its digit." + } + ) + } + } + listOf(listOf(key("Delete", "Delete the previous character."))) + KeypadModule.Qwerty -> listOf(navigation) + listOf( - listOf("QW", "ER", "TY", "UI", "OP"), listOf("AS", "DF", "GH", "JK", "L"), listOf("ZX", "CV", "BN", "M", "Delete"), + listOf("QW", "ER", "TY", "UI", "OP"), + listOf("AS", "DF", "GH", "JK", "L"), + listOf("ZX", "CV", "BN", "M", "Delete") ).map { row -> row.map { key(it, if (it == "Delete") "Delete the previous character." else "Tap repeatedly to choose a letter from $it. Hold for the digit or symbol printed above the key.") } } + listOf( listOf(key("↕", "Cycle the suggested words."), key("SYM", "Tap repeatedly to cycle symbols."), key("aA", "Tap to change letter case. Hold to switch letters / numbers.")), - listOf(key("Space", "Insert a space. Hold to type 0."), key("Enter", "Confirm a suggestion or insert a new line.")), + listOf(key("Space", "Insert a space. Hold to type 0."), key("Enter", "Confirm a suggestion or insert a new line.")) + ) + + // The Sundial is a ring of eight keys around the centre key. Lay it out exactly as it sits + // on the module: top-left is left, top-right is right, bottom-left is Tab, bottom-right is + // Enter, and the media keys are the middle row. + KeypadModule.Sundial -> listOf( + listOf(key("←", "Move focus left."), key("↑", "Move focus up."), key("→", "Move focus right.")), + listOf(key("Previous", "Previous media track; hold for left."), key("Play / pause", "Play or pause media where supported."), key("Next", "Next media track; hold for right.")), + listOf(key("Tab", "Move to the next focusable control; hold for Back."), key("↓", "Move focus down."), key("Enter", "Activate the focused control; hold for Options.")) ) - KeypadModule.Sundial -> listOf(navigation, - listOf(key("↑", "Move focus up.")), - listOf(key("Previous", "Previous media track where supported."), key("Play / pause", "Play or pause media where supported."), key("Next", "Next media track where supported.")), - listOf(key("↓", "Move focus down.")), - listOf(key("← / →", "Move focus left / right."), key("Tab", "Move to the next focusable control."), key("Enter", "Activate the focused control."))) + KeypadModule.GamePad -> listOf( listOf(key("D-pad", "Move focus using the directional pad.")), listOf(key("Y", "Game action: its meaning depends on the app or game.")), listOf(key("X", "Game action: its meaning depends on the app or game."), key("A", "Confirm where the app supports controller input."), key("B", "Back where the app supports controller input.")), - listOf(key("Shoulder", "Game action: its meaning depends on the app or game."))) + listOf(key("Shoulder", "Game action: its meaning depends on the app or game.")) + ) } } @@ -59,6 +78,13 @@ internal fun KeypadDemo(module: KeypadModule) { } } } + if (module == KeypadModule.Sundial) { + SidekitText( + "No Back or Options key: hold Tab for Back and Enter for Options. The corner arrows move left/right; hold Previous/Next for the same, and a tap is still media.", + style = SidekitTheme.typography.label.copy(fontSize = 16.sp, lineHeight = 18.sp), + maxLines = Int.MAX_VALUE + ) + } if (!module.typesOnKeypad) SidekitText("Text fields use the on-screen keyboard with this module.", style = caption, maxLines = Int.MAX_VALUE) } } diff --git a/app/src/test/java/hrln/interactive/sidekit/design/input/HoldToActTest.kt b/app/src/test/java/hrln/interactive/sidekit/design/input/HoldToActTest.kt new file mode 100644 index 00000000..80abc831 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/design/input/HoldToActTest.kt @@ -0,0 +1,88 @@ +package hrln.interactive.sidekit.design.input + +import android.view.KeyEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** A tap is delivered on release; a hold fires while the key is still down and never also taps. */ +@RunWith(AndroidJUnit4::class) +class HoldToActTest { + private class FakeScheduler : HoldToAct.Scheduler { + var scheduledDelay = -1L + private var action: (() -> Unit)? = null + override fun schedule(delayMs: Long, action: () -> Unit): HoldToAct.Cancellable { + scheduledDelay = delayMs + this.action = action + return HoldToAct.Cancellable { this.action = null } + } + fun fire() { + action?.invoke() + } + } + + private val holds = mutableListOf>() + private val scheduler = FakeScheduler() + + private fun hold() = HoldToAct( + holdMs = 600, + onHold = { action, source -> holds += action to source }, + scheduler = scheduler + ) + + private fun event(action: Int, code: Int, time: Long, repeat: Int = 0) = KeyEvent(0L, time, action, code, repeat, 0, 1, 0) + + private fun action(code: Int): Int? = when (code) { + KeyEvent.KEYCODE_TAB -> KeyEvent.KEYCODE_BACK + KeyEvent.KEYCODE_ENTER -> KeyEvent.KEYCODE_ENDCALL + else -> null + } + + @Test fun shortTapIsDeliveredOnRelease() { + val hold = hold() + assertEquals(HoldToAct.Result.Consume, hold.handle(event(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB, 0), ::action)) + assertEquals(600L, scheduler.scheduledDelay) + val result = hold.handle(event(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_TAB, 100), ::action) + assertTrue(result is HoldToAct.Result.Deliver) + result as HoldToAct.Result.Deliver + assertEquals(listOf(KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_TAB), result.events.map { it.keyCode }) + assertEquals(listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP), result.events.map { it.action }) + } + + @Test fun holdFiresWhileHeldAndSwallowsTheRelease() { + val hold = hold() + assertEquals(HoldToAct.Result.Consume, hold.handle(event(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB, 0), ::action)) + scheduler.fire() + assertEquals(listOf(KeyEvent.KEYCODE_BACK), holds.map { it.first }) + assertEquals(HoldToAct.Result.Consume, hold.handle(event(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_TAB, 500), ::action)) + } + + @Test fun holdFiresOnReleaseWhenTheTimerDidNotRun() { + val hold = hold() + assertEquals(HoldToAct.Result.Consume, hold.handle(event(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER, 0), ::action)) + assertEquals( + HoldToAct.Result.Consume, + hold.handle(event(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER, 700), ::action) + ) + assertEquals(listOf(KeyEvent.KEYCODE_ENDCALL), holds.map { it.first }) + } + + @Test fun otherKeysPassAndDropAPendingTap() { + val hold = hold() + assertEquals(HoldToAct.Result.Pass, hold.handle(event(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP, 0), ::action)) + assertEquals(HoldToAct.Result.Consume, hold.handle(event(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB, 100), ::action)) + assertEquals(HoldToAct.Result.Pass, hold.handle(event(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP, 150), ::action)) + assertEquals(HoldToAct.Result.Consume, hold.handle(event(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_TAB, 200), ::action)) + assertEquals(emptyList(), holds.map { it.first }) + } + + @Test fun resetCancelsAPendingHold() { + val hold = hold() + assertEquals(HoldToAct.Result.Consume, hold.handle(event(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB, 0), ::action)) + hold.reset() + scheduler.fire() + assertEquals(emptyList(), holds.map { it.first }) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/onboarding/OnboardingTest.kt b/app/src/test/java/hrln/interactive/sidekit/onboarding/OnboardingTest.kt index 8a097717..cd93e2cb 100644 --- a/app/src/test/java/hrln/interactive/sidekit/onboarding/OnboardingTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/onboarding/OnboardingTest.kt @@ -43,6 +43,7 @@ class OnboardingTest { compose.mainClock.advanceTimeBy(1000) compose.onRoot().captureRoboImage("screenshots/onboarding/$name.png") } + @Test fun `setup saves choices and only completes on Finish`() { launch() shot("01-home") @@ -71,6 +72,7 @@ class OnboardingTest { assertTrue(prefs.setupComplete) assertEquals(0, prefs.setupStep) } + @Test fun `resumes at saved step and can go back`() { launch(2) compose.onNodeWithText("Your home screen").assertIsDisplayed() @@ -78,6 +80,7 @@ class OnboardingTest { assertEquals(1, prefs.setupStep) compose.onNodeWithText("Make it yours").assertIsDisplayed() } + @Test fun `current keypad is first and other demos do not change it`() { launch(6, KeypadModule.Qwerty) compose.onNodeWithText("1 / 4 · Mini QWERTY").assertIsDisplayed() @@ -100,6 +103,18 @@ class OnboardingTest { compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText("Mini QWERTY is your current selection.", substring = true)) compose.onNodeWithText("Mini QWERTY is your current selection.", substring = true).assertExists() } + + @Test fun `Sundial demo follows the physical ring`() { + assertEquals( + listOf( + listOf("←", "↑", "→"), + listOf("Previous", "Play / pause", "Next"), + listOf("Tab", "↓", "Enter") + ), + demoRows(KeypadModule.Sundial).map { row -> row.map { it.label } } + ) + } + @Test fun `access status reflects grants and revocations`() { val camera = setupAccess().first { it.title == "Camera" } val app = org.robolectric.Shadows.shadowOf(context as android.app.Application) @@ -110,6 +125,7 @@ class OnboardingTest { app.denyPermissions(android.Manifest.permission.CAMERA) assertFalse(camera.enabled(context)) } + @Test fun `each module appears once with the current one first`() { KeypadModule.entries.forEach { module -> assertEquals(module, orderedModules(module).first()) @@ -117,12 +133,16 @@ class OnboardingTest { assertEquals(4, orderedModules(module).size) } } - @Test @Config(sdk = [31]) fun `Android 12 uses storage access without newer permission prompts`() { + + @Test + @Config(sdk = [31]) + fun `Android 12 uses storage access without newer permission prompts`() { val permissions = setupAccess().flatMap { it.permissions } assertTrue(android.Manifest.permission.READ_EXTERNAL_STORAGE in permissions) assertFalse(android.Manifest.permission.POST_NOTIFICATIONS in permissions) assertFalse(android.Manifest.permission.READ_MEDIA_IMAGES in permissions) } + @Test fun `Android 15 uses granular media access and full screen alarm settings`() { val entries = setupAccess() assertTrue(entries.any { it.title == "Full-screen alarms" }) diff --git a/docs/keypad-mapping.md b/docs/keypad-mapping.md index 8606890a..90eb8147 100644 --- a/docs/keypad-mapping.md +++ b/docs/keypad-mapping.md @@ -22,6 +22,12 @@ While Sidekit waits for a key, it captures every press, including the release, s The key tester shows the key code and scan code for each press, the input device that sent it, and the control it becomes. Use it to set up a new keypad or to report how a device's keys behave. +Sidekit only treats the keypad modules' own input devices as keypads. A headset button or an external keyboard is ignored for module detection, even when it sends the same key code. + +## Sundial holds + +The Sundial has no Back or Options key. Hold **Tab** for Back and hold **Enter** for Options. Hold the left/right edges (**Previous** / **Next**) for left/right; a tap on them still sends the media command. Keypad holds follow Android's long-press timeout (`ViewConfiguration.getLongPressTimeout()`, 400 ms by default), so a device or accessibility tool that changes the system value also changes them. The Back-to-dialer hold stays at 600 ms. + ## Scope - Mappings are saved for each input device, by device name. A key from another device isn't remapped, even if it sends the same code. Examples are an external keyboard and a headset button. Device names, unlike device ids, stay the same across restarts and reconnections. diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/HoldToAct.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/HoldToAct.kt new file mode 100644 index 00000000..8bdfcc17 --- /dev/null +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/HoldToAct.kt @@ -0,0 +1,135 @@ +package hrln.interactive.sidekit.design.input + +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent + +/** + * Gives a key a hold action without changing what a tap does. + * + * The press is held back until it is released or [holdMs] passes. A timer fires the hold while the + * key is still down; a tap is delivered as a normal press/release pair, so a hold never also fires + * the key's tap action. Used for the Sundial, whose ring has no Back or Options key: hold Tab for + * Back, Enter for Options, and Previous/Next for left/right while a tap still sends the media + * command. + */ +class HoldToAct( + private val holdMs: Long = KeypadTiming.LONG_PRESS_MS, + private val onHold: (action: Int, source: KeyEvent) -> Unit = { _, _ -> }, + private val scheduler: Scheduler = MainLooperScheduler() +) { + sealed interface Result { + /** Not one of ours: deliver the event normally. */ + data object Pass : Result + + /** Swallow the event. */ + data object Consume : Result + + /** Deliver these synthetic events through the normal key path. */ + data class Deliver(val events: List) : Result + } + + fun interface Cancellable { + fun cancel() + } + + fun interface Scheduler { + fun schedule(delayMs: Long, action: () -> Unit): Cancellable + } + + private var pending: KeyEvent? = null + private var fired = false + private var timer: Cancellable? = null + + /** Forgets a press that never got its release (the window lost focus). */ + fun reset() = clear() + + /** + * [holdAction] returns the action key code for a held key, or null to leave that key alone. + * Only one key can be pending; pressing another key drops the pending tap. + */ + fun handle(event: KeyEvent, holdAction: (Int) -> Int?): Result { + val action = holdAction(event.keyCode) + if (action == null) { + clear() + return Result.Pass + } + if (event.isCanceled) { + clear() + return Result.Consume + } + when (event.action) { + KeyEvent.ACTION_DOWN -> { + if (event.repeatCount == 0) { + clear() + pending = event + timer = scheduler.schedule(holdMs) { fire(action, event) } + return Result.Consume + } + // The keypad doesn't send repeats, but if one arrives after the threshold and the + // timer hasn't run yet, fire now. + val down = pending + if (!fired && down != null && event.eventTime - down.downTime >= holdMs) { + timer?.cancel() + timer = null + fire(action, down) + } + return Result.Consume + } + + KeyEvent.ACTION_UP -> { + val down = pending + timer?.cancel() + timer = null + if (fired) { + pending = null + fired = false + return Result.Consume + } + if (down != null && down.keyCode == event.keyCode) { + // Releasing after the threshold without a timer still counts as a hold. + if (event.eventTime - down.downTime >= holdMs) { + fire(action, down) + pending = null + fired = false + return Result.Consume + } + pending = null + val up = KeyEvent( + down.downTime, event.eventTime, KeyEvent.ACTION_UP, down.keyCode, + event.repeatCount, event.metaState, event.deviceId, event.scanCode, + event.flags, event.source + ) + return Result.Deliver(listOf(down, up)) + } + pending = null + return Result.Consume + } + + else -> return Result.Pass + } + } + + private fun fire(action: Int, source: KeyEvent) { + if (pending !== source || fired) return + fired = true + timer = null + onHold(action, source) + } + + private fun clear() { + timer?.cancel() + timer = null + pending = null + fired = false + } + + private class MainLooperScheduler : Scheduler { + private val handler = Handler(Looper.getMainLooper()) + override fun schedule(delayMs: Long, action: () -> Unit): Cancellable { + val runnable = Runnable { action() } + handler.postDelayed(runnable, delayMs) + return Cancellable { handler.removeCallbacks(runnable) } + } + } +} diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeyMapping.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeyMapping.kt index cce743d8..b6ac7ad3 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeyMapping.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeyMapping.kt @@ -54,13 +54,14 @@ class KeypadControl(val keyCode: Int, val label: String, val hint: String) object KeypadControls { fun of(module: KeypadModule): List = when (module) { KeypadModule.T9 -> system + dpad + listOf( - control(KeyEvent.KEYCODE_ENTER, "OK", "Select, or take a suggestion"), + control(KeyEvent.KEYCODE_ENTER, "OK", "Select, or take a suggestion") ) + (1..9).map { digit(it) } + listOf( control(KeyEvent.KEYCODE_STAR, "*", "Punctuation and symbols"), digit(0), control(KeyEvent.KEYCODE_POUND, "#", "Case; hold for numbers"), - control(KeyEvent.KEYCODE_DEL, "Delete", "Delete the previous character"), + control(KeyEvent.KEYCODE_DEL, "Delete", "Delete the previous character") ) + KeypadModule.Qwerty -> system + QwertyLayout.letters.map { (code, letters) -> control(code, letters.uppercase(), "Hold for ${QwertyLayout.printed[code]}") } + listOf( @@ -69,25 +70,27 @@ object KeypadControls { control(KeyEvent.KEYCODE_ENTER, "Enter", "New line, or take a suggestion"), control(QwertyLayout.SUGGEST, "↕", "Next suggestion"), control(QwertyLayout.SYM, "SYM", "Symbols"), - control(QwertyLayout.CASE, "aA", "Case; hold for numbers"), + control(QwertyLayout.CASE, "aA", "Case; hold for numbers") ) + KeypadModule.Sundial -> system + listOf( control(KeyEvent.KEYCODE_DPAD_UP, "Top", "Move up"), - control(KeyEvent.KEYCODE_MEDIA_NEXT, "Right edge", "Next track"), + control(KeyEvent.KEYCODE_MEDIA_NEXT, "Right edge", "Next track; hold for right"), control(KeyEvent.KEYCODE_DPAD_DOWN, "Bottom", "Move down"), - control(KeyEvent.KEYCODE_MEDIA_PREVIOUS, "Left edge", "Previous track"), + control(KeyEvent.KEYCODE_MEDIA_PREVIOUS, "Left edge", "Previous track; hold for left"), control(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, "Centre", "Play or pause"), control(KeyEvent.KEYCODE_DPAD_LEFT, "Corner ←", "Move left"), control(KeyEvent.KEYCODE_DPAD_RIGHT, "Corner →", "Move right"), - control(KeyEvent.KEYCODE_TAB, "Tab", "Next control"), - control(KeyEvent.KEYCODE_ENTER, "Enter", "Select"), + control(KeyEvent.KEYCODE_TAB, "Tab", "Next control; hold for Back"), + control(KeyEvent.KEYCODE_ENTER, "Enter", "Select; hold for Options") ) + KeypadModule.GamePad -> system + dpad + listOf( control(KeyEvent.KEYCODE_BUTTON_A, "A", "Confirm"), control(KeyEvent.KEYCODE_BUTTON_B, "B", "Back in games"), control(KeyEvent.KEYCODE_BUTTON_X, "X", "Game action"), control(KeyEvent.KEYCODE_BUTTON_Y, "Y", "Game action"), - control(KeyEvent.KEYCODE_BUTTON_L1, "Shoulder", "Game action"), + control(KeyEvent.KEYCODE_BUTTON_L1, "Shoulder", "Game action") ) } @@ -96,20 +99,30 @@ object KeypadControls { private fun control(code: Int, label: String, hint: String) = KeypadControl(code, label, hint) private fun digit(n: Int): KeypadControl { val letters = listOf("", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz")[n] - return control(KeyEvent.KEYCODE_0 + n, if (letters.isEmpty()) "$n" else "$n $letters", if (n == 0) "Space; hold for 0" else if (n == 1) "Punctuation; hold for 1" else "Letters; hold for $n") + return control( + KeyEvent.KEYCODE_0 + n, + if (letters.isEmpty()) "$n" else "$n $letters", + if (n == 0) { + "Space; hold for 0" + } else if (n == 1) { + "Punctuation; hold for 1" + } else { + "Letters; hold for $n" + } + ) } private val system = listOf( // Back is the plain Back key here: the Sidephone's top-left key (CALL) only becomes Back // through the keypad accessibility service, which keeps working whatever is mapped. control(KeyEvent.KEYCODE_BACK, "Back", "Go back a screen"), - control(KeyEvent.KEYCODE_ENDCALL, "Options", "Show the screen's extra actions"), + control(KeyEvent.KEYCODE_ENDCALL, "Options", "Show the screen's extra actions") ) private val dpad = listOf( control(KeyEvent.KEYCODE_DPAD_UP, "Up", "Move up"), control(KeyEvent.KEYCODE_DPAD_DOWN, "Down", "Move down"), control(KeyEvent.KEYCODE_DPAD_LEFT, "Left", "Move left; cursor"), - control(KeyEvent.KEYCODE_DPAD_RIGHT, "Right", "Move right; cursor"), + control(KeyEvent.KEYCODE_DPAD_RIGHT, "Right", "Move right; cursor") ) } @@ -129,7 +142,7 @@ object KeypadControls { @Stable class KeyMapper internal constructor( private val prefs: SharedPreferences?, - private val deviceName: (KeyEvent) -> String = ::defaultDeviceName, + private val deviceName: (KeyEvent) -> String = ::defaultDeviceName ) { /** The module the custom mapping is for, or null for automatic detection. */ var customModule: KeypadModule? by mutableStateOf(null) @@ -174,8 +187,7 @@ class KeyMapper internal constructor( } /** The control [key] is bound to in [module], if any. */ - fun controlFor(module: KeypadModule, key: PhysicalKey): Int? = - bindings(module).entries.firstOrNull { it.value == key }?.key + fun controlFor(module: KeypadModule, key: PhysicalKey): Int? = bindings(module).entries.firstOrNull { it.value == key }?.key fun reset(module: KeypadModule, controlCode: Int) = save(module, bindings(module) - controlCode) @@ -229,6 +241,7 @@ class KeyMapper internal constructor( val taken = capture?.invoke(key, event) == true (if (taken) SWALLOWED else mappedCode(key)).also { held[key] = it } } + // A release finishes the press the way it started, even if the mapping changed meanwhile. else -> held.remove(key) ?: if (capture != null) SWALLOWED else mappedCode(key) } @@ -273,8 +286,7 @@ class KeyMapper internal constructor( return control to PhysicalKey(parts[3], code, scan) } - private fun defaultDeviceName(event: KeyEvent): String = - event.device?.name ?: if (event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD) "Virtual" else "" + private fun defaultDeviceName(event: KeyEvent): String = event.device?.name ?: if (event.deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD) "Virtual" else "" /** A mapper that saves to [prefs] (the keypad's own preferences). */ fun create(prefs: SharedPreferences?): KeyMapper = KeyMapper(prefs) @@ -285,10 +297,14 @@ class KeyMapper internal constructor( } private class Translation( - val event: KeyEvent, val action: Int, val keyCode: Int, val downTime: Long, val eventTime: Long, val repeat: Int, - val out: KeyEvent?, + val event: KeyEvent, + val action: Int, + val keyCode: Int, + val downTime: Long, + val eventTime: Long, + val repeat: Int, + val out: KeyEvent? ) { fun matches(e: KeyEvent) = e === event && e.action == action && e.keyCode == keyCode && e.downTime == downTime && e.eventTime == eventTime && e.repeatCount == repeat } - diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/Keypad.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/Keypad.kt index b215d1c1..8db04fbb 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/Keypad.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/Keypad.kt @@ -3,32 +3,34 @@ package hrln.interactive.sidekit.design.input import android.content.Context import android.content.SharedPreferences import android.view.KeyEvent -import android.view.ViewConfiguration import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.focus.FocusRequester +import java.io.File +import java.io.IOException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException /** The Sidephone's swappable keypad modules. */ enum class KeypadModule(val label: String, val typesOnKeypad: Boolean) { /** Phone keypad with D-pad, OK and call keys. */ T9("T9", true), + /** Two letters per key, 20 keys. */ Qwerty("Mini QWERTY", true), - /** Click wheel with a centre button. */ + + /** Ring of eight keys with a centre button. */ Sundial("Sundial", false), + /** D-pad, A / B / X / Y and a shoulder key. */ - GamePad("Game controller", false), + GamePad("Game controller", false) } /** @@ -51,9 +53,9 @@ class Keypad internal constructor( private val scope: CoroutineScope, private val loadWords: (suspend () -> T9Words)?, initialModule: KeypadModule, - val longPressMs: Long = DEFAULT_LONG_PRESS_MS, + val longPressMs: Long = KeypadTiming.LONG_PRESS_MS, /** Custom key mapping (off unless the user turns it on in Settings). */ - val mapper: KeyMapper = KeyMapper.forTesting(), + val mapper: KeyMapper = KeyMapper.forTesting() ) { private var detected: KeypadModule by mutableStateOf(initialModule) @@ -168,8 +170,12 @@ class Keypad internal constructor( session?.finish() } - internal fun registerFallback(focus: FocusRequester, activate: () -> Unit = {}) { fallbacks.add(TypingTarget(focus, activate)) } - internal fun unregisterFallback(focus: FocusRequester) { fallbacks.removeAll { it.focus === focus } } + internal fun registerFallback(focus: FocusRequester, activate: () -> Unit = {}) { + fallbacks.add(TypingTarget(focus, activate)) + } + internal fun unregisterFallback(focus: FocusRequester) { + fallbacks.removeAll { it.focus === focus } + } private fun loadResources() { if (wordsJob != null || loadWords == null) return @@ -219,7 +225,6 @@ class Keypad internal constructor( private const val PREF_T9 = "t9" private const val PREF_MODULE = "module" private const val SAVE_DELAY_MS = 2_000L - private const val DEFAULT_LONG_PRESS_MS = 450L fun isTypingKey(code: Int) = code in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 || code == KeyEvent.KEYCODE_STAR || code == KeyEvent.KEYCODE_POUND || code == KeyEvent.KEYCODE_DEL || @@ -238,8 +243,8 @@ class Keypad internal constructor( ?.let { name -> KeypadModule.entries.firstOrNull { it.name == name } } // Before modules were told apart, T9 was the only one that typed. ?: if (prefs.getBoolean(PREF_T9, true)) KeypadModule.T9 else KeypadModule.Sundial, - longPressMs = ViewConfiguration.getLongPressTimeout().toLong(), - mapper = KeyMapper.create(prefs), + longPressMs = KeypadTiming.LONG_PRESS_MS, + mapper = KeyMapper.create(prefs) ) } @@ -248,12 +253,11 @@ class Keypad internal constructor( scope: CoroutineScope, words: T9Words, module: KeypadModule = KeypadModule.T9, - mapper: KeyMapper = KeyMapper.forTesting(), + mapper: KeyMapper = KeyMapper.forTesting() ) = Keypad(null, null, scope, null, module, mapper = mapper).also { it.words = words } } } -internal fun KeyEvent.withCode(code: Int) = - KeyEvent(downTime, eventTime, action, code, repeatCount, metaState, deviceId, scanCode, flags, source) +internal fun KeyEvent.withCode(code: Int) = KeyEvent(downTime, eventTime, action, code, repeatCount, metaState, deviceId, scanCode, flags, source) val LocalKeypad = staticCompositionLocalOf { null } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeypadDetector.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeypadDetector.kt index 04162253..2ae26ea5 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeypadDetector.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeypadDetector.kt @@ -1,39 +1,60 @@ package hrln.interactive.sidekit.design.input +import android.view.KeyCharacterMap import android.view.KeyEvent /** * Tells the attached keypad module from the keys it sends. * - * Every module is the same I2C input device (`gxa535_keyboard`, vendor / product 0, no readable - * id in sysfs), so the module is recognised by keys only it has. Measured with `getevent`: + * Each swappable module appears as its own input device: `gxa535_media`, `gxa535_qwerty`, + * `gxa535_t9` and `gxa535_gamepad`. Older firmware exposed everything as `gxa535_keyboard`. + * `gxa535_phone` is the phone's own key device, not a module, so it is not trusted here. The + * module is recognised by keys only it has. Measured with `getevent`: * * - T9: digits, `*`, `#` (plus D-pad, OK = `KEY_ENTER`, left soft key = Home, right = Delete). * - Mini QWERTY: letters, one per key (`KEY_Q` for QW...), Space, Ctrl / Alt / Shift. - * - Sundial: centre = Play/Pause, edges = Up / Next / Down / Previous, corners = Left / Right / - * Tab / Enter. Turning the wheel presses the edges in sequence. + * - Sundial: centre = Play/Pause, ring = Up / Next / Down / Previous, corners = Left / Right / + * Tab / Enter. The ring reports eight separate keys; it has no rotation axis. * - Game controller: gamepad buttons. * * Keys shared between modules (D-pad, Enter, call keys, Delete) say nothing. */ internal object KeypadDetector { - const val DEVICE_NAME = "gxa535_keyboard" + /** + * The input devices that carry a swappable module. An allowlist, not a `gxa535_*` prefix: + * `gxa535_phone` shares the prefix but sends the phone's own keys, and a future device should + * be added deliberately rather than trusted by name alone. + */ + private val MODULE_DEVICES = setOf( + "gxa535_media", + "gxa535_qwerty", + "gxa535_t9", + "gxa535_gamepad", + // Older firmware exposed every module under this one name. + "gxa535_keyboard" + ) + + /** True for a keypad module, the virtual keyboard, or an event with no readable device. */ + internal fun isKeypadDevice(name: String?, deviceId: Int): Boolean = name == null || name in MODULE_DEVICES || deviceId == KeyCharacterMap.VIRTUAL_KEYBOARD fun moduleFor(event: KeyEvent): KeypadModule? { // Headset buttons send Play/Pause too: only trust the keypad (or injected / remapped keys, // which come from the virtual device). - val name = event.device?.name - if (name != null && name != DEVICE_NAME && event.deviceId != android.view.KeyCharacterMap.VIRTUAL_KEYBOARD) return null + if (!isKeypadDevice(event.device?.name, event.deviceId)) return null val code = event.keyCode return when { code in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z || code == KeyEvent.KEYCODE_SPACE || code == KeyEvent.KEYCODE_SHIFT_LEFT || code == KeyEvent.KEYCODE_ALT_LEFT || code == KeyEvent.KEYCODE_CTRL_LEFT -> KeypadModule.Qwerty + code == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || code == KeyEvent.KEYCODE_MEDIA_NEXT || code == KeyEvent.KEYCODE_MEDIA_PREVIOUS || code == KeyEvent.KEYCODE_TAB -> KeypadModule.Sundial + KeyEvent.isGamepadButton(code) -> KeypadModule.GamePad + code in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 || code == KeyEvent.KEYCODE_STAR || code == KeyEvent.KEYCODE_POUND -> KeypadModule.T9 + else -> null } } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeypadTiming.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeypadTiming.kt new file mode 100644 index 00000000..e9800a13 --- /dev/null +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/KeypadTiming.kt @@ -0,0 +1,14 @@ +package hrln.interactive.sidekit.design.input + +import android.view.ViewConfiguration + +/** + * The system long-press threshold, shared by T9 and the Sundial holds. + * + * [ViewConfiguration.getLongPressTimeout] follows `Settings.Secure.LONG_PRESS_TIMEOUT` (400 ms by + * default), so a device or accessibility tool that changes the system value also changes these + * gestures. The Back-to-dialer hold keeps its own, longer threshold in [SystemKeys]. + */ +object KeypadTiming { + val LONG_PRESS_MS: Long get() = ViewConfiguration.getLongPressTimeout().toLong() +} diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/NavControls.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/NavControls.kt index dfde98f0..fb45994e 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/NavControls.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/NavControls.kt @@ -1,3 +1,7 @@ +// The key constants read as navigation keys in code; ktlint's property-naming rule would force +// SCREAMING_SNAKE_CASE for these compile-time constants. +@file:Suppress("ktlint:standard:property-naming") + package hrln.interactive.sidekit.design.input import android.view.KeyEvent @@ -21,6 +25,7 @@ object NavControls { const val Down = KeyEvent.KEYCODE_DPAD_DOWN const val Left = KeyEvent.KEYCODE_DPAD_LEFT const val Right = KeyEvent.KEYCODE_DPAD_RIGHT + /** Select / open the focused control. [KeyEvent.KEYCODE_ENTER] is what T9 calls OK and ListItems act on. */ const val Confirm = KeyEvent.KEYCODE_ENTER const val Back = KeyEvent.KEYCODE_BACK @@ -49,17 +54,29 @@ object NavControls { KeypadModule.Sundial to emptyMap(), KeypadModule.GamePad to mapOf( KeyEvent.KEYCODE_BUTTON_A to Confirm, - KeyEvent.KEYCODE_BUTTON_B to Back, + KeyEvent.KEYCODE_BUTTON_B to Back ), KeypadModule.Qwerty to mapOf( // Measured on-device: T / D / B / J around the tablet's centre form a directional cross. KeyEvent.KEYCODE_T to Up, KeyEvent.KEYCODE_D to Left, KeyEvent.KEYCODE_B to Down, - KeyEvent.KEYCODE_J to Right, - ), + KeyEvent.KEYCODE_J to Right + ) + ) + + /** + * Hold actions for the Sundial's ring. A tap keeps the printed role (media, Tab, Enter); a + * hold turns it into the navigation action the module has no dedicated key for. Tab/Enter have + * no media role to preserve; Previous/Next keep theirs on a tap. + */ + val sundialHoldFor: Map = mapOf( + KeyEvent.KEYCODE_TAB to Back, + KeyEvent.KEYCODE_ENTER to Options, + KeyEvent.KEYCODE_MEDIA_PREVIOUS to Left, + KeyEvent.KEYCODE_MEDIA_NEXT to Right ) /** The canonical navigation key [code] becomes in [module]'s UI, or null to keep it as sent. */ internal fun applyDefault(module: KeypadModule, code: Int): Int? = defaultFor[module]?.get(code) -} \ No newline at end of file +} diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/SystemKeys.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/SystemKeys.kt index 293ec414..149be4b7 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/SystemKeys.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/SystemKeys.kt @@ -16,9 +16,12 @@ import android.view.KeyEvent object SystemKeys { fun isBack(event: KeyEvent): Boolean = event.keyCode == KeyEvent.KEYCODE_CALL - fun isOptions(event: KeyEvent): Boolean = - event.keyCode == KeyEvent.KEYCODE_ENDCALL || event.keyCode == KeyEvent.KEYCODE_MENU + fun isOptions(event: KeyEvent): Boolean = event.keyCode == KeyEvent.KEYCODE_ENDCALL || event.keyCode == KeyEvent.KEYCODE_MENU - /** Hold the Back key this long to open the dialer instead. */ + /** + * Hold the Back key this long to open the dialer instead. Kept separate from + * [KeypadTiming.LONG_PRESS_MS]: opening the dialer is a deliberate, destructive-feeling + * gesture, so it stays longer than an ordinary keypad hold. + */ const val LONG_PRESS_MS = 600L } diff --git a/sidekit/src/test/java/hrln/interactive/sidekit/design/input/KeypadDetectorTest.kt b/sidekit/src/test/java/hrln/interactive/sidekit/design/input/KeypadDetectorTest.kt new file mode 100644 index 00000000..18bd8beb --- /dev/null +++ b/sidekit/src/test/java/hrln/interactive/sidekit/design/input/KeypadDetectorTest.kt @@ -0,0 +1,42 @@ +package hrln.interactive.sidekit.design.input + +import android.view.KeyCharacterMap +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The keypad is told from other input devices by the module device's name. */ +class KeypadDetectorTest { + + @Test fun acceptsEveryModuleName() { + for (name in listOf( + "gxa535_media", + "gxa535_qwerty", + "gxa535_t9", + "gxa535_gamepad", + "gxa535_keyboard" + )) { + assertTrue(name, KeypadDetector.isKeypadDevice(name, deviceId = 3)) + } + } + + @Test fun rejectsThePhonesOwnKeys() { + // Same gxa535_ prefix, but this device carries the phone's keys, not a swappable module. + assertFalse(KeypadDetector.isKeypadDevice("gxa535_phone", deviceId = 3)) + } + + @Test fun rejectsUnknownModuleNames() { + // A new module must be added to the allowlist deliberately. + assertFalse(KeypadDetector.isKeypadDevice("gxa535_future", deviceId = 3)) + } + + @Test fun rejectsOtherDevices() { + assertFalse(KeypadDetector.isKeypadDevice("mt63xx-accdet Headset", deviceId = 4)) + assertFalse(KeypadDetector.isKeypadDevice("External keyboard", deviceId = 7)) + } + + @Test fun allowsVirtualAndUnnamedEvents() { + assertTrue(KeypadDetector.isKeypadDevice("Injected", KeyCharacterMap.VIRTUAL_KEYBOARD)) + assertTrue(KeypadDetector.isKeypadDevice(null, deviceId = 3)) + } +} diff --git a/sidekit/src/test/java/hrln/interactive/sidekit/design/input/NavControlsTest.kt b/sidekit/src/test/java/hrln/interactive/sidekit/design/input/NavControlsTest.kt index 90689aff..a9b3df53 100644 --- a/sidekit/src/test/java/hrln/interactive/sidekit/design/input/NavControlsTest.kt +++ b/sidekit/src/test/java/hrln/interactive/sidekit/design/input/NavControlsTest.kt @@ -15,9 +15,15 @@ class NavControlsTest { @Test fun t9AndSundialAlreadySendCanonicalKeys() { // T9's D-pad / OK are already what focus and select act on: nothing to rewrite. - for (code in listOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_ENDCALL)) { + for (code in listOf( + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_ENDCALL + )) { assertNull(mapped(KeypadModule.T9, code)) assertNull(mapped(KeypadModule.Sundial, code)) } @@ -51,12 +57,28 @@ class NavControlsTest { @Test fun qwertyModifierKeysAndConfirmStayTyping() { // The modifier keys and Enter keep their jobs: only the letter cross navigates. - for (code in listOf(QwertyLayout.SUGGEST, QwertyLayout.SYM, QwertyLayout.CASE, - KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_A)) { + for (code in listOf( + QwertyLayout.SUGGEST, + QwertyLayout.SYM, + QwertyLayout.CASE, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_A + )) { assertNull(mapped(KeypadModule.Qwerty, code)) } } + @Test fun sundialHoldTargetsAreCanonicalNavKeys() { + NavControls.sundialHoldFor.forEach { (_, target) -> + assertTrue("unexpected Sundial hold target $target", target in NavControls.all) + } + assertEquals(NavControls.Back, NavControls.sundialHoldFor[KeyEvent.KEYCODE_TAB]) + assertEquals(NavControls.Options, NavControls.sundialHoldFor[KeyEvent.KEYCODE_ENTER]) + assertEquals(NavControls.Left, NavControls.sundialHoldFor[KeyEvent.KEYCODE_MEDIA_PREVIOUS]) + assertEquals(NavControls.Right, NavControls.sundialHoldFor[KeyEvent.KEYCODE_MEDIA_NEXT]) + } + @Test fun everyDefaultTargetIsACanonicalNavKey() { NavControls.defaultFor.forEach { (module, profile) -> profile.forEach { (_, target) -> @@ -70,4 +92,4 @@ class NavControlsTest { assertTrue("unexpected canonical target $it", isDirection(it) || it == NavControls.Confirm || it == NavControls.Back) } } -} \ No newline at end of file +}