Hide the recovery key while we are entering it (#5147)

* Hide the recovery key while we are entering it (#5134)

This is the Element X Android part of
https://github.com/element-hq/element-meta/issues/2888

* Move the textfield contents being visible to the state so we can preview and test it

* Always use the password visual transformation for the recovery key field

* Update screenshots

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2025-08-12 14:46:00 +02:00
committed by GitHub
parent d39332f8cc
commit 00e72aae44
35 changed files with 138 additions and 42 deletions

View File

@@ -9,6 +9,7 @@ package io.element.android.features.securebackup.impl.enter
sealed interface SecureBackupEnterRecoveryKeyEvents {
data class OnRecoveryKeyChange(val recoveryKey: String) : SecureBackupEnterRecoveryKeyEvents
data class ChangeRecoveryKeyFieldContentsVisibility(val visible: Boolean) : SecureBackupEnterRecoveryKeyEvents
data object Submit : SecureBackupEnterRecoveryKeyEvents
data object ClearDialog : SecureBackupEnterRecoveryKeyEvents
}

View File

@@ -33,6 +33,9 @@ class SecureBackupEnterRecoveryKeyPresenter @Inject constructor(
@Composable
override fun present(): SecureBackupEnterRecoveryKeyState {
val coroutineScope = rememberCoroutineScope()
var displayRecoveryKeyFieldContents by rememberSaveable {
mutableStateOf(false)
}
var recoveryKey by rememberSaveable {
mutableStateOf("")
}
@@ -59,6 +62,9 @@ class SecureBackupEnterRecoveryKeyPresenter @Inject constructor(
// No need to remove the spaces, the SDK will do it.
coroutineScope.submitRecoveryKey(recoveryKey, submitAction)
}
is SecureBackupEnterRecoveryKeyEvents.ChangeRecoveryKeyFieldContentsVisibility -> {
displayRecoveryKeyFieldContents = event.visible
}
}
}
@@ -66,6 +72,7 @@ class SecureBackupEnterRecoveryKeyPresenter @Inject constructor(
recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = recoveryKey,
displayTextFieldContents = displayRecoveryKeyFieldContents,
inProgress = submitAction.value.isLoading(),
),
isSubmitEnabled = recoveryKey.isNotEmpty() && submitAction.value.isUninitialized(),

View File

@@ -20,18 +20,21 @@ open class SecureBackupEnterRecoveryKeyStateProvider : PreviewParameterProvider<
aSecureBackupEnterRecoveryKeyState(),
aSecureBackupEnterRecoveryKeyState(submitAction = AsyncAction.Loading),
aSecureBackupEnterRecoveryKeyState(submitAction = AsyncAction.Failure(Exception("A Failure"))),
aSecureBackupEnterRecoveryKeyState(displayTextFieldContents = false),
)
}
fun aSecureBackupEnterRecoveryKeyState(
recoveryKey: String = aFormattedRecoveryKey(),
isSubmitEnabled: Boolean = recoveryKey.isNotEmpty(),
displayTextFieldContents: Boolean = true,
submitAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (SecureBackupEnterRecoveryKeyEvents) -> Unit = {},
) = SecureBackupEnterRecoveryKeyState(
recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = recoveryKey,
displayTextFieldContents = displayTextFieldContents,
inProgress = submitAction.isLoading(),
),
isSubmitEnabled = isSubmitEnabled,

View File

@@ -102,6 +102,9 @@ private fun Content(
onSubmit = {
state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.Submit)
},
toggleRecoveryKeyVisibility = {
state.eventSink(SecureBackupEnterRecoveryKeyEvents.ChangeRecoveryKeyFieldContentsVisibility(it))
}
)
}

View File

@@ -71,6 +71,7 @@ class SecureBackupSetupPresenter @AssistedInject constructor(
val recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
formattedRecoveryKey = setupState.recoveryKey(),
displayTextFieldContents = true,
inProgress = setupState is SetupState.Creating,
)

View File

@@ -43,6 +43,7 @@ private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState {
return RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = recoveryKey(),
displayTextFieldContents = true,
inProgress = this is SetupState.Creating,
)
}

View File

@@ -138,6 +138,7 @@ private fun Content(
onClick = clickLambda,
onChange = null,
onSubmit = null,
toggleRecoveryKeyVisibility = {},
)
}

View File

@@ -8,6 +8,7 @@
package io.element.android.features.securebackup.impl.setup.views
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,7 +21,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
@@ -32,6 +35,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -57,6 +61,7 @@ internal fun RecoveryKeyView(
onClick: (() -> Unit)?,
onChange: ((String) -> Unit)?,
onSubmit: (() -> Unit)?,
toggleRecoveryKeyVisibility: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@@ -67,7 +72,7 @@ internal fun RecoveryKeyView(
text = stringResource(id = CommonStrings.common_recovery_key),
style = ElementTheme.typography.fontBodyMdRegular,
)
RecoveryKeyContent(state, onClick, onChange, onSubmit)
RecoveryKeyContent(state, onClick, onChange, onSubmit, toggleRecoveryKeyVisibility)
RecoveryKeyFooter(state)
}
}
@@ -78,11 +83,17 @@ private fun RecoveryKeyContent(
onClick: (() -> Unit)?,
onChange: ((String) -> Unit)?,
onSubmit: (() -> Unit)?,
toggleRecoveryKeyVisibility: (Boolean) -> Unit,
) {
when (state.recoveryKeyUserStory) {
RecoveryKeyUserStory.Setup,
RecoveryKeyUserStory.Change -> RecoveryKeyStaticContent(state, onClick)
RecoveryKeyUserStory.Enter -> RecoveryKeyFormContent(state, onChange, onSubmit)
RecoveryKeyUserStory.Enter -> RecoveryKeyFormContent(
state = state,
toggleRecoveryKeyVisibility = toggleRecoveryKeyVisibility,
onChange = onChange,
onSubmit = onSubmit,
)
}
}
@@ -171,15 +182,24 @@ private fun RecoveryKeyWithCopy(
@Composable
private fun RecoveryKeyFormContent(
state: RecoveryKeyViewState,
toggleRecoveryKeyVisibility: (Boolean) -> Unit,
onChange: ((String) -> Unit)?,
onSubmit: (() -> Unit)?,
) {
onChange ?: error("onChange should not be null")
onSubmit ?: error("onSubmit should not be null")
if (state.inProgress) {
// Ensure recovery key is hidden when user submits the form
toggleRecoveryKeyVisibility(false)
}
val keyHasSpace = state.formattedRecoveryKey.orEmpty().contains(" ")
val recoveryKeyVisualTransformation = remember(keyHasSpace) {
// Do not apply a visual transformation if the key has spaces, to let user enter passphrase
if (keyHasSpace) VisualTransformation.None else RecoveryKeyVisualTransformation()
val recoveryKeyVisualTransformation = remember(keyHasSpace, state.displayTextFieldContents) {
if (state.displayTextFieldContents) {
// Do not apply a visual transformation if the key has spaces, to let user enter passphrase
if (keyHasSpace) VisualTransformation.None else RecoveryKeyVisualTransformation()
} else {
PasswordVisualTransformation()
}
}
TextField(
modifier = Modifier
@@ -201,6 +221,18 @@ private fun RecoveryKeyFormContent(
onDone = { onSubmit() }
),
placeholder = stringResource(id = R.string.screen_recovery_key_confirm_key_placeholder),
trailingIcon = {
val image =
if (state.displayTextFieldContents) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
val description =
if (state.displayTextFieldContents) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
Box(Modifier.clickable { toggleRecoveryKeyVisibility(!state.displayTextFieldContents) }) {
Icon(
imageVector = image,
contentDescription = description,
)
}
},
)
}
@@ -249,5 +281,6 @@ internal fun RecoveryKeyViewPreview(
onClick = {},
onChange = {},
onSubmit = {},
toggleRecoveryKeyVisibility = {},
)
}

View File

@@ -10,6 +10,7 @@ package io.element.android.features.securebackup.impl.setup.views
data class RecoveryKeyViewState(
val recoveryKeyUserStory: RecoveryKeyUserStory,
val formattedRecoveryKey: String?,
val displayTextFieldContents: Boolean,
val inProgress: Boolean,
)

View File

@@ -22,6 +22,11 @@ open class RecoveryKeyViewStateProvider : PreviewParameterProvider<RecoveryKeyVi
} + sequenceOf(
aRecoveryKeyViewState(recoveryKeyUserStory = RecoveryKeyUserStory.Enter, formattedRecoveryKey = aFormattedRecoveryKey().replace(" ", "")),
aRecoveryKeyViewState(recoveryKeyUserStory = RecoveryKeyUserStory.Enter, formattedRecoveryKey = "This is a passphrase with spaces"),
aRecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = aFormattedRecoveryKey().replace(" ", ""),
displayTextFieldContents = false
),
)
}
@@ -29,9 +34,11 @@ fun aRecoveryKeyViewState(
recoveryKeyUserStory: RecoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey: String? = null,
inProgress: Boolean = false,
displayTextFieldContents: Boolean = true,
) = RecoveryKeyViewState(
recoveryKeyUserStory = recoveryKeyUserStory,
formattedRecoveryKey = formattedRecoveryKey,
displayTextFieldContents = displayTextFieldContents,
inProgress = inProgress,
)

View File

@@ -40,6 +40,7 @@ class SecureBackupEnterRecoveryKeyPresenterTest {
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = "",
displayTextFieldContents = false,
inProgress = false,
)
)
@@ -61,6 +62,7 @@ class SecureBackupEnterRecoveryKeyPresenterTest {
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = "1234",
displayTextFieldContents = false,
inProgress = false,
)
)

View File

@@ -10,11 +10,12 @@ package io.element.android.features.securebackup.impl.enter
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings
@@ -81,6 +82,23 @@ class SecureBackupEnterRecoveryKeyViewTest {
)
}
@Test
@Config(qualifiers = "h1024dp")
fun `toggling the visibility of the textfield changes it`() {
val recorder = EventsRecorder<SecureBackupEnterRecoveryKeyEvents>()
val keyValue = aFormattedRecoveryKey()
rule.setSecureBackupEnterRecoveryKeyView(aSecureBackupEnterRecoveryKeyState(isSubmitEnabled = true, eventSink = recorder))
// Initially, the text field should be visible
rule.onNodeWithText(keyValue).assertExists()
rule.onNodeWithContentDescription(rule.activity.getString(CommonStrings.a11y_hide_password)).performClick()
rule.waitForIdle()
recorder.assertSingle(SecureBackupEnterRecoveryKeyEvents.ChangeRecoveryKeyFieldContentsVisibility(false))
}
@Test
fun `validating from keyboard emits the expected event`() {
val recorder = EventsRecorder<SecureBackupEnterRecoveryKeyEvents>()

View File

@@ -40,6 +40,7 @@ class SecureBackupSetupPresenterTest {
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = null,
displayTextFieldContents = true,
inProgress = false,
)
)
@@ -63,6 +64,7 @@ class SecureBackupSetupPresenterTest {
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = null,
displayTextFieldContents = true,
inProgress = true,
)
)
@@ -73,6 +75,7 @@ class SecureBackupSetupPresenterTest {
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = A_RECOVERY_KEY,
displayTextFieldContents = true,
inProgress = false,
)
)
@@ -103,6 +106,7 @@ class SecureBackupSetupPresenterTest {
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Change,
formattedRecoveryKey = null,
displayTextFieldContents = true,
inProgress = false,
)
)
@@ -155,6 +159,7 @@ class SecureBackupSetupPresenterTest {
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Change,
formattedRecoveryKey = null,
displayTextFieldContents = true,
inProgress = true,
)
)
@@ -164,6 +169,7 @@ class SecureBackupSetupPresenterTest {
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Change,
formattedRecoveryKey = FakeEncryptionService.FAKE_RECOVERY_KEY,
displayTextFieldContents = true,
inProgress = false,
)
)