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 f8fb9d0c05
commit 9390964b01
5 changed files with 201 additions and 3 deletions

View File

@@ -14,6 +14,10 @@ plugins {
android {
namespace = "io.element.android.features.lockscreen.impl"
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
setupAnvil()
@@ -30,6 +34,8 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
@@ -42,6 +48,9 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test)
@@ -50,4 +59,5 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

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,
)
}
}
}

View File

@@ -6,7 +6,7 @@
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}
android {

View File

@@ -0,0 +1,34 @@
/*
* 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.libraries.ui.utils.time
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.key
/**
* Extension property to get the digit character from a KeyEvent.
* This handles both regular digit keys and numpad keys.
*/
val KeyEvent.digit: Char? get() {
val char = nativeKeyEvent.unicodeChar.toChar()
return when {
Character.isDigit(char) -> char
key == Key.NumPad0 -> '0'
key == Key.NumPad1 -> '1'
key == Key.NumPad2 -> '2'
key == Key.NumPad3 -> '3'
key == Key.NumPad4 -> '4'
key == Key.NumPad5 -> '5'
key == Key.NumPad6 -> '6'
key == Key.NumPad7 -> '7'
key == Key.NumPad8 -> '8'
key == Key.NumPad9 -> '9'
else -> null
}
}