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:
parent
f8fb9d0c05
commit
9390964b01
5 changed files with 201 additions and 3 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-compose-library")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue