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:
committed by
GitHub
parent
f8fb9d0c05
commit
9390964b01
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user