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).
This commit is contained in:
Jorge Martin Espinosa 2025-04-07 11:55:35 +02:00 committed by GitHub
parent b3c0332eac
commit 915699a265
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 201 additions and 3 deletions

View file

@ -27,6 +27,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
@ -37,6 +43,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.digit
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -60,7 +68,22 @@ fun PinKeypad(
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
Column(
modifier = modifier,
modifier = modifier.onKeyEvent { event ->
if (event.type == KeyEventType.KeyUp) {
val digitChar = event.digit
if (digitChar != null) {
onClick(PinKeypadModel.Number(digitChar))
true
} else if (event.key == Key.Backspace) {
onClick(PinKeypadModel.Back)
true
} else {
false
}
} else {
false
}
},
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
) {
@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Backspace,
contentDescription = null,
contentDescription = stringResource(CommonStrings.a11y_delete),
)
}
}

View file

@ -0,0 +1,131 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.lockscreen.impl.unlock.keypad
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isRoot
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.unit.dp
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PinKeypadTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on a number emits the expected event`() {
val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder)
rule.onNode(hasText("1")).performClick()
eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
}
@Test
fun `clicking on the delete previous character button emits the expected event`() {
val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder)
rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick()
eventsRecorder.assertSingle(PinKeypadModel.Back)
}
@OptIn(ExperimentalTestApi::class)
@Test
fun `typing using the hardware keyboard emits the expected events`() {
val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder)
rule.onNodeWithText("1").requestFocus()
rule.onAllNodes(isRoot())[0].performKeyInput {
val keys = listOf(
Key.A,
Key.NumPad1,
Key.NumPad2,
Key.NumPad3,
Key.NumPad4,
Key.NumPad5,
Key.NumPad6,
Key.NumPad7,
Key.NumPad8,
Key.NumPad9,
Key.NumPad0,
Key(KeyEvent.KEYCODE_1),
Key(KeyEvent.KEYCODE_2),
Key(KeyEvent.KEYCODE_3),
Key(KeyEvent.KEYCODE_4),
Key(KeyEvent.KEYCODE_5),
Key(KeyEvent.KEYCODE_6),
Key(KeyEvent.KEYCODE_7),
Key(KeyEvent.KEYCODE_8),
Key(KeyEvent.KEYCODE_9),
Key(KeyEvent.KEYCODE_0),
Key.Backspace,
)
for (key in keys) {
pressKey(key)
}
}
eventsRecorder.assertList(
listOf(
// Note that the first key is not a number, but a letter so it's ignored as input
// Then we have the numpad keys
PinKeypadModel.Number('1'),
PinKeypadModel.Number('2'),
PinKeypadModel.Number('3'),
PinKeypadModel.Number('4'),
PinKeypadModel.Number('5'),
PinKeypadModel.Number('6'),
PinKeypadModel.Number('7'),
PinKeypadModel.Number('8'),
PinKeypadModel.Number('9'),
PinKeypadModel.Number('0'),
// And the normal keys from the number row in the keyboard
PinKeypadModel.Number('1'),
PinKeypadModel.Number('2'),
PinKeypadModel.Number('3'),
PinKeypadModel.Number('4'),
PinKeypadModel.Number('5'),
PinKeypadModel.Number('6'),
PinKeypadModel.Number('7'),
PinKeypadModel.Number('8'),
PinKeypadModel.Number('9'),
PinKeypadModel.Number('0'),
PinKeypadModel.Back,
)
)
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinKeyPad(
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
PinKeypad(
onClick = onClick,
maxWidth = 1000.dp,
maxHeight = 1000.dp,
)
}
}
}