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:
committed by
GitHub
parent
d39332f8cc
commit
00e72aae44
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -102,6 +102,9 @@ private fun Content(
|
||||
onSubmit = {
|
||||
state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.Submit)
|
||||
},
|
||||
toggleRecoveryKeyVisibility = {
|
||||
state.eventSink(SecureBackupEnterRecoveryKeyEvents.ChangeRecoveryKeyFieldContentsVisibility(it))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState {
|
||||
return RecoveryKeyViewState(
|
||||
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
|
||||
formattedRecoveryKey = recoveryKey(),
|
||||
displayTextFieldContents = true,
|
||||
inProgress = this is SetupState.Creating,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ private fun Content(
|
||||
onClick = clickLambda,
|
||||
onChange = null,
|
||||
onSubmit = null,
|
||||
toggleRecoveryKeyVisibility = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user