Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified app/screenshots/onboarding/08-sundial.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 71 additions & 3 deletions app/src/main/java/hrln/interactive/sidekit/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,48 @@ internal fun demoRows(module: KeypadModule): List<List<DemoKey>> {
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."))
)
}
}

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Pair<Int, KeyEvent>>()
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<Int>(), 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<Int>(), holds.map { it.first })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -71,13 +72,15 @@ 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()
compose.onNodeWithText("Back").performClick()
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()
Expand All @@ -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)
Expand All @@ -110,19 +125,24 @@ 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())
assertEquals(KeypadModule.entries.toSet(), orderedModules(module).toSet())
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" })
Expand Down
6 changes: 6 additions & 0 deletions docs/keypad-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading