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 { android {
namespace = "io.element.android.features.lockscreen.impl" namespace = "io.element.android.features.lockscreen.impl"
testOptions {
unitTests.isIncludeAndroidResources = true
}
} }
setupAnvil() setupAnvil()
@ -30,6 +34,8 @@ dependencies {
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api) implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
@ -42,6 +48,9 @@ dependencies {
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) 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.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.test)
@ -50,4 +59,5 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Dp
import androidx.compose.ui.unit.coerceIn import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp 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.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.components.Icon 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -60,7 +68,22 @@ fun PinKeypad(
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally) val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically) val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
Column( 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, verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment, horizontalAlignment = horizontalAlignment,
) { ) {
@ -183,7 +206,7 @@ private fun PinKeypadBackButton(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Backspace, 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 { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { 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
}
}