Skip to content

Commit 915699a

Browse files
authored
Allow using a hardware keyboard to unlock the app using a pin code (#4530)
* Allow using a hardware keyboard to unlock the app using a pin code * Add UI tests to `PinKeypad` * Also take into account the numpad keys. Extract this to an extension property in `ui-utils`. Made `ui-utils` also a compose-compatible library (vs `android-utils`, which doesn't have compose dependencies).
1 parent b3c0332 commit 915699a

File tree

5 files changed

+201
-3
lines changed

5 files changed

+201
-3
lines changed

features/lockscreen/impl/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ plugins {
1414

1515
android {
1616
namespace = "io.element.android.features.lockscreen.impl"
17+
18+
testOptions {
19+
unitTests.isIncludeAndroidResources = true
20+
}
1721
}
1822

1923
setupAnvil()
@@ -30,6 +34,8 @@ dependencies {
3034
implementation(projects.libraries.featureflag.api)
3135
implementation(projects.libraries.cryptography.api)
3236
implementation(projects.libraries.preferences.api)
37+
implementation(projects.libraries.testtags)
38+
implementation(projects.libraries.uiUtils)
3339
implementation(projects.features.logout.api)
3440
implementation(projects.libraries.uiStrings)
3541
implementation(projects.libraries.sessionStorage.api)
@@ -42,6 +48,9 @@ dependencies {
4248
testImplementation(libs.molecule.runtime)
4349
testImplementation(libs.test.truth)
4450
testImplementation(libs.test.turbine)
51+
testImplementation(libs.test.robolectric)
52+
testImplementation(libs.androidx.compose.ui.test.junit)
53+
testImplementation(libs.androidx.test.ext.junit)
4554
testImplementation(projects.libraries.matrix.test)
4655
testImplementation(projects.tests.testutils)
4756
testImplementation(projects.libraries.cryptography.test)
@@ -50,4 +59,5 @@ dependencies {
5059
testImplementation(projects.libraries.sessionStorage.test)
5160
testImplementation(projects.services.appnavstate.test)
5261
testImplementation(projects.features.logout.test)
62+
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
5363
}

features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ import androidx.compose.runtime.Composable
2727
import androidx.compose.ui.Alignment
2828
import androidx.compose.ui.Modifier
2929
import androidx.compose.ui.draw.clip
30+
import androidx.compose.ui.input.key.Key
31+
import androidx.compose.ui.input.key.KeyEventType
32+
import androidx.compose.ui.input.key.key
33+
import androidx.compose.ui.input.key.onKeyEvent
34+
import androidx.compose.ui.input.key.type
35+
import androidx.compose.ui.res.stringResource
3036
import androidx.compose.ui.unit.Dp
3137
import androidx.compose.ui.unit.coerceIn
3238
import androidx.compose.ui.unit.dp
@@ -37,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
3743
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
3844
import io.element.android.libraries.designsystem.text.toSp
3945
import io.element.android.libraries.designsystem.theme.components.Icon
46+
import io.element.android.libraries.ui.strings.CommonStrings
47+
import io.element.android.libraries.ui.utils.time.digit
4048
import kotlinx.collections.immutable.ImmutableList
4149
import kotlinx.collections.immutable.persistentListOf
4250

@@ -60,7 +68,22 @@ fun PinKeypad(
6068
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
6169
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
6270
Column(
63-
modifier = modifier,
71+
modifier = modifier.onKeyEvent { event ->
72+
if (event.type == KeyEventType.KeyUp) {
73+
val digitChar = event.digit
74+
if (digitChar != null) {
75+
onClick(PinKeypadModel.Number(digitChar))
76+
true
77+
} else if (event.key == Key.Backspace) {
78+
onClick(PinKeypadModel.Back)
79+
true
80+
} else {
81+
false
82+
}
83+
} else {
84+
false
85+
}
86+
},
6487
verticalArrangement = verticalArrangement,
6588
horizontalAlignment = horizontalAlignment,
6689
) {
@@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
183206
) {
184207
Icon(
185208
imageVector = Icons.AutoMirrored.Filled.Backspace,
186-
contentDescription = null,
209+
contentDescription = stringResource(CommonStrings.a11y_delete),
187210
)
188211
}
189212
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.lockscreen.impl.unlock.keypad
9+
10+
import android.view.KeyEvent
11+
import androidx.activity.ComponentActivity
12+
import androidx.compose.ui.input.key.Key
13+
import androidx.compose.ui.test.ExperimentalTestApi
14+
import androidx.compose.ui.test.hasContentDescription
15+
import androidx.compose.ui.test.hasText
16+
import androidx.compose.ui.test.isRoot
17+
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
18+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
19+
import androidx.compose.ui.test.onNodeWithText
20+
import androidx.compose.ui.test.performClick
21+
import androidx.compose.ui.test.performKeyInput
22+
import androidx.compose.ui.test.pressKey
23+
import androidx.compose.ui.test.requestFocus
24+
import androidx.compose.ui.unit.dp
25+
import io.element.android.libraries.ui.strings.CommonStrings
26+
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
27+
import io.element.android.tests.testutils.EventsRecorder
28+
import org.junit.Rule
29+
import org.junit.Test
30+
import org.junit.rules.TestRule
31+
import org.junit.runner.RunWith
32+
import org.robolectric.RobolectricTestRunner
33+
34+
@RunWith(RobolectricTestRunner::class)
35+
class PinKeypadTest {
36+
@get:Rule
37+
val rule = createAndroidComposeRule<ComponentActivity>()
38+
39+
@Test
40+
fun `clicking on a number emits the expected event`() {
41+
val eventsRecorder = EventsRecorder<PinKeypadModel>()
42+
rule.setPinKeyPad(onClick = eventsRecorder)
43+
rule.onNode(hasText("1")).performClick()
44+
eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
45+
}
46+
47+
@Test
48+
fun `clicking on the delete previous character button emits the expected event`() {
49+
val eventsRecorder = EventsRecorder<PinKeypadModel>()
50+
rule.setPinKeyPad(onClick = eventsRecorder)
51+
rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick()
52+
eventsRecorder.assertSingle(PinKeypadModel.Back)
53+
}
54+
55+
@OptIn(ExperimentalTestApi::class)
56+
@Test
57+
fun `typing using the hardware keyboard emits the expected events`() {
58+
val eventsRecorder = EventsRecorder<PinKeypadModel>()
59+
rule.setPinKeyPad(onClick = eventsRecorder)
60+
rule.onNodeWithText("1").requestFocus()
61+
rule.onAllNodes(isRoot())[0].performKeyInput {
62+
val keys = listOf(
63+
Key.A,
64+
Key.NumPad1,
65+
Key.NumPad2,
66+
Key.NumPad3,
67+
Key.NumPad4,
68+
Key.NumPad5,
69+
Key.NumPad6,
70+
Key.NumPad7,
71+
Key.NumPad8,
72+
Key.NumPad9,
73+
Key.NumPad0,
74+
Key(KeyEvent.KEYCODE_1),
75+
Key(KeyEvent.KEYCODE_2),
76+
Key(KeyEvent.KEYCODE_3),
77+
Key(KeyEvent.KEYCODE_4),
78+
Key(KeyEvent.KEYCODE_5),
79+
Key(KeyEvent.KEYCODE_6),
80+
Key(KeyEvent.KEYCODE_7),
81+
Key(KeyEvent.KEYCODE_8),
82+
Key(KeyEvent.KEYCODE_9),
83+
Key(KeyEvent.KEYCODE_0),
84+
Key.Backspace,
85+
)
86+
for (key in keys) {
87+
pressKey(key)
88+
}
89+
}
90+
eventsRecorder.assertList(
91+
listOf(
92+
// Note that the first key is not a number, but a letter so it's ignored as input
93+
// Then we have the numpad keys
94+
PinKeypadModel.Number('1'),
95+
PinKeypadModel.Number('2'),
96+
PinKeypadModel.Number('3'),
97+
PinKeypadModel.Number('4'),
98+
PinKeypadModel.Number('5'),
99+
PinKeypadModel.Number('6'),
100+
PinKeypadModel.Number('7'),
101+
PinKeypadModel.Number('8'),
102+
PinKeypadModel.Number('9'),
103+
PinKeypadModel.Number('0'),
104+
// And the normal keys from the number row in the keyboard
105+
PinKeypadModel.Number('1'),
106+
PinKeypadModel.Number('2'),
107+
PinKeypadModel.Number('3'),
108+
PinKeypadModel.Number('4'),
109+
PinKeypadModel.Number('5'),
110+
PinKeypadModel.Number('6'),
111+
PinKeypadModel.Number('7'),
112+
PinKeypadModel.Number('8'),
113+
PinKeypadModel.Number('9'),
114+
PinKeypadModel.Number('0'),
115+
PinKeypadModel.Back,
116+
)
117+
)
118+
}
119+
120+
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinKeyPad(
121+
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
122+
) {
123+
setContent {
124+
PinKeypad(
125+
onClick = onClick,
126+
maxWidth = 1000.dp,
127+
maxHeight = 1000.dp,
128+
)
129+
}
130+
}
131+
}

libraries/ui-utils/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
plugins {
9-
id("io.element.android-library")
9+
id("io.element.android-compose-library")
1010
}
1111

1212
android {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.libraries.ui.utils.time
9+
10+
import androidx.compose.ui.input.key.Key
11+
import androidx.compose.ui.input.key.KeyEvent
12+
import androidx.compose.ui.input.key.key
13+
14+
/**
15+
* Extension property to get the digit character from a KeyEvent.
16+
* This handles both regular digit keys and numpad keys.
17+
*/
18+
val KeyEvent.digit: Char? get() {
19+
val char = nativeKeyEvent.unicodeChar.toChar()
20+
return when {
21+
Character.isDigit(char) -> char
22+
key == Key.NumPad0 -> '0'
23+
key == Key.NumPad1 -> '1'
24+
key == Key.NumPad2 -> '2'
25+
key == Key.NumPad3 -> '3'
26+
key == Key.NumPad4 -> '4'
27+
key == Key.NumPad5 -> '5'
28+
key == Key.NumPad6 -> '6'
29+
key == Key.NumPad7 -> '7'
30+
key == Key.NumPad8 -> '8'
31+
key == Key.NumPad9 -> '9'
32+
else -> null
33+
}
34+
}

0 commit comments

Comments
 (0)