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:
parent
a1c36d9afa
commit
31a952c389
35 changed files with 138 additions and 42 deletions
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue