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 a1c36d9afa
commit 31a952c389
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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,
)
)

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a1f1460739ae84a1231044e042e427d61cd7c94027035c1300aebf6695aec60a
size 30986
oid sha256:e3518f4ff808d3e61047834161935aa3603e3f8161288be14b87a1ea48c5e25f
size 31968

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d046c96d9948e4b3754de60cb5dc1017e21c4e2b509f9e736cc0ef1def375af7
size 41756
oid sha256:3bdd192ae952875c6c6882ec6b02ef4855e4862431c78cf29842e25700dd3d0e
size 42791

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:77c2e604b4c8281f284333a28608fb41f86adf56c3b9cf205d1eda0677c6c441
size 41973
oid sha256:23dce3fae5178c45a70171f762333a3e0f16e26b9b6c585ba7943a519de6e0dc
size 42771

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7e9ad9b6fe78b5028209f9f9683553fb6db2d2fc2e64f215c6abc02b19baecc
size 35633

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a0ca7c2dbd511607e61abc3165f8c2c8c70a6849e02415deb919ef90c9d16be
size 29969
oid sha256:e5e0e27543c3247402719dfe0bb0bcb2152b0d3aef7dc006cc55b36746c2fd67
size 30923

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f4b8abd3133c3754af7686266ab81ad7cae8f549609f3ddd53e1639e9671de3
size 40484
oid sha256:a081e06078ac0ef89e3c9f643766bc9c48474168227fbf011a92e31517344d8b
size 41374

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d77618c41e5c06b9f350ece10bfe02180516a6553dd69b642b77663144ceb238
size 40427
oid sha256:d2cb1156bd56a8602d17d6db4e7e7089e244e6afbaf129d9ea02ba625d8a944c
size 41173

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:69d2b8e9f30b06110a735161faf6e20b505ec9395c57349fb19144f07489a03c
size 34700

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:777216c815b4198de503101e3e52fe371c0e1916799ac209aef993739ca39004
size 25699
oid sha256:ac0bb1a0a9d500f89d20bed5190f7d4e442fec7cb8bd6710f21657ef0c298fe2
size 26672

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:47d063becbbeec9185ae3ea6dd9921d8a6620a084b8f153061fc05cf57040652
size 25038
oid sha256:910264e17c8737b5ee4efdca0853450524f21db7e7dd5a45424bfb87d221347b
size 25796

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:777216c815b4198de503101e3e52fe371c0e1916799ac209aef993739ca39004
size 25699
oid sha256:ac0bb1a0a9d500f89d20bed5190f7d4e442fec7cb8bd6710f21657ef0c298fe2
size 26672

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:46f7f3867698746018f5f6c12ca9aaccad4545a50f3b5db9193fe06a0e232601
size 20575
oid sha256:1fc08c8fa05f681d48ff886883aba32d71a74aa3db52e92964ad69f7fb367fd5
size 21717

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1347518095b2a8abdd67a0843b3f4d06bc685dd37ef113035864e3d74cc28740
size 18955

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:def874f1d40cd699de708cd3b4c2f55f7634de919587c2da25a2773ecdd39e4a
size 15493
oid sha256:5fd5183e7442a8e5945b8ffd8585204ede5b69b91ffb8944585678b4d88dcbeb
size 16528

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2191feda845f1e255fa4387f73cb06f334b6687d08580e8c00fa94d6046b16f4
size 15237
oid sha256:c2b2f513d4523c1e00f8508c5531f300a71b7a7f2396d1d4e40fc605e4075e6c
size 16235

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5200e8e2a0619bed9a9a6aad6a676a4292fe64d6d3ac11b55161887e44fd11f9
size 24702
oid sha256:3c908fe1b613b28a3d12d49f98f2363e361f2cab4b8c35c89482802525c5ef34
size 25773

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ba1b220dd3866ed16eefacba1a390c2aafdf66e06db765743493177699c8c392
size 24173
oid sha256:0fb8568e2cd07f9c856b1e3beac52a1e6979878fd8c6fdb6b57b4faecaa2c3e1
size 24961

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5200e8e2a0619bed9a9a6aad6a676a4292fe64d6d3ac11b55161887e44fd11f9
size 24702
oid sha256:3c908fe1b613b28a3d12d49f98f2363e361f2cab4b8c35c89482802525c5ef34
size 25773

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9dc5974fea6f46ad50b224a1aabbcde69fd87121578343df5d13eb9a5ec0cfd
size 19488
oid sha256:a3465fcc599b8fea97ad6347abd9525b983df5d2f4a28c0ce4a3b9743ef85c65
size 20600

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ba81a7ac256c647460e5661ec36f78a4abb592fa98f9a5e472590d7234154dd7
size 18101

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:696cead69a01bf5fce8b382a2b20b6823cdf66d47a30608cbbda4a3062bc6a05
size 14743
oid sha256:963c535c88475dd6f659ce4f22494314f29401b70b2a407a689bdccfb3b062c1
size 15723

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bea33b82255867ef88c363d502872ced6cc063d877a4ea3da389b4fc75cfafa3
size 14737
oid sha256:985b5897ba1ae81ff8de6ac90daec70584f1586f0c5ff762ec154a9fd50e2cae
size 15664