Improve account cred copy UX.

This commit is contained in:
Greyson Parrelli
2026-09-09 16:37:43 -04:00
committed by Cody Henthorne
parent 823c540a6e
commit 445884199a
10 changed files with 126 additions and 71 deletions
@@ -50,10 +50,10 @@ class SignalLoginViewDetailsViewModel(
SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> {
_actions.send(SignalLoginViewDetailsAction.LaunchSaveAsPdf)
}
is SignalLoginViewDetailsScreenEvents.AccountIdLongClicked -> {
is SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked -> {
_actions.send(SignalLoginViewDetailsAction.CopyTextToClipboard(event.aci))
}
is SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked -> {
is SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked -> {
_actions.send(SignalLoginViewDetailsAction.CopyTextToClipboard(event.aep))
}
}
@@ -100,24 +100,24 @@ class SignalLoginViewDetailsViewModelTest {
}
@Test
fun `AccountIdLongClicked copies the account key to the clipboard`() = runTest(testDispatcher) {
fun `CopyAccountIdClicked copies the account key to the clipboard`() = runTest(testDispatcher) {
val viewModel = SignalLoginViewDetailsViewModel(repository)
val actions = mutableListOf<SignalLoginViewDetailsAction>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.AccountIdLongClicked("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
assertThat(actions).containsExactly(SignalLoginViewDetailsAction.CopyTextToClipboard("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
}
@Test
fun `RecoveryKeyLongClicked copies the recovery key to the clipboard`() = runTest(testDispatcher) {
fun `CopyRecoveryKeyClicked copies the recovery key to the clipboard`() = runTest(testDispatcher) {
val recoveryKey = AccountEntropyPool.generate().displayValue
val viewModel = SignalLoginViewDetailsViewModel(repository)
val actions = mutableListOf<SignalLoginViewDetailsAction>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked(recoveryKey))
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(recoveryKey))
assertThat(actions).containsExactly(SignalLoginViewDetailsAction.CopyTextToClipboard(recoveryKey))
}
@@ -72,11 +72,11 @@ class SignalLoginViewDetailsViewModel(
_actions.trySend(SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf)
}
is SignalLoginViewDetailsScreenEvents.AccountIdLongClicked -> {
is SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked -> {
_actions.trySend(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(event.aci))
}
is SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked -> {
is SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked -> {
_actions.trySend(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(event.aep))
}
}
@@ -7,10 +7,9 @@ package org.signal.registration.screens.signallogindetails
import android.app.Application
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
@@ -44,21 +43,21 @@ class SignalLoginViewDetailsScreenTest {
private val events = mutableListOf<SignalLoginViewDetailsScreenEvents>()
@Test
fun `when the account key is long clicked, AccountIdLongClicked is emitted`() {
fun `when the account key copy button is clicked, CopyAccountIdClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_BLOCK).performScrollTo().performTouchInput { longClick() }
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_COPY_BUTTON).performScrollTo().performClick()
assertThat(events).contains(SignalLoginViewDetailsScreenEvents.AccountIdLongClicked(ACCOUNT_KEY))
assertThat(events).contains(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked(ACCOUNT_KEY))
}
@Test
fun `when the recovery key is long clicked, RecoveryKeyLongClicked is emitted`() {
fun `when the recovery key copy button is clicked, CopyRecoveryKeyClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RECOVERY_KEY_BLOCK).performScrollTo().performTouchInput { longClick() }
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RECOVERY_KEY_COPY_BUTTON).performScrollTo().performClick()
assertThat(events).contains(SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked(RECOVERY_KEY))
assertThat(events).contains(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(RECOVERY_KEY))
}
private fun setContent() {
@@ -92,22 +92,22 @@ class SignalLoginViewDetailsViewModelTest {
}
@Test
fun `AccountIdLongClicked copies the account key to the clipboard`() = runTest(testDispatcher) {
fun `CopyAccountIdClicked copies the account key to the clipboard`() = runTest(testDispatcher) {
val actions = mutableListOf<SignalLoginViewDetailsScreenActions>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.AccountIdLongClicked("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.CopyTextToClipboard("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
}
@Test
fun `RecoveryKeyLongClicked copies the recovery key to the clipboard`() = runTest(testDispatcher) {
fun `CopyRecoveryKeyClicked copies the recovery key to the clipboard`() = runTest(testDispatcher) {
val recoveryKey = AccountEntropyPool.generate().displayValue
val actions = mutableListOf<SignalLoginViewDetailsScreenActions>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked(recoveryKey))
viewModel.onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(recoveryKey))
assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(recoveryKey))
}
@@ -16,4 +16,6 @@ object SignalLoginTestTags {
const val VIEW_DETAILS_SAVE_AS_PDF_BUTTON = "signal_login_view_details_save_as_pdf_button"
const val VIEW_DETAILS_ACCOUNT_KEY_BLOCK = "signal_login_view_details_account_key_block"
const val VIEW_DETAILS_RECOVERY_KEY_BLOCK = "signal_login_view_details_recovery_key_block"
const val VIEW_DETAILS_ACCOUNT_KEY_COPY_BUTTON = "signal_login_view_details_account_key_copy_button"
const val VIEW_DETAILS_RECOVERY_KEY_COPY_BUTTON = "signal_login_view_details_recovery_key_copy_button"
}
@@ -7,9 +7,7 @@ package org.signal.signallogin.viewdetails
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -24,6 +22,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -67,6 +67,13 @@ private const val GROUPS_PER_ROW = 4
/** The least amount of space allowed between recovery key groups before falling back to natural text wrapping. */
private val MIN_GROUP_SPACING = 12.dp
private val KEY_BLOCK_TEXT_PADDING_HORIZONTAL = 28.dp
private val KEY_BLOCK_TEXT_PADDING_VERTICAL = 20.dp
/** Insets that center the copy button's icon on the first line of key text and put it 16dp from the block's end edge, accounting for the button's own 12dp of internal padding. */
private val COPY_BUTTON_PADDING_TOP = 10.dp
private val COPY_BUTTON_PADDING_END = 4.dp
/**
* Shows the user the full keys that make up their Signal Login and offers ways to save them.
*/
@@ -102,9 +109,9 @@ fun SignalLoginViewDetailsScreen(
Spacer(modifier = Modifier.height(16.dp))
Texts.SectionHeader(text = stringResource(R.string.SignalLoginViewDetailsScreen__account_key))
Texts.SectionHeader(text = stringResource(R.string.SignalLoginViewDetailsScreen__account_id))
KeyBlock(
AccountIdBlock(
text = state.accountKey,
onEvent = onEvent,
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_BLOCK)
@@ -143,15 +150,27 @@ private fun MiniCard(modifier: Modifier = Modifier) {
* A full credential rendered in the special monospace font on a rounded surface.
*/
@Composable
private fun KeyBlock(
private fun AccountIdBlock(
text: String,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.keyBlockSurface(onLongClick = { onEvent(SignalLoginViewDetailsScreenEvents.AccountIdLongClicked(text)) })) {
Row(
verticalAlignment = Alignment.Top,
modifier = modifier.keyBlockSurface()
) {
Text(
text = text,
style = keyTextStyle()
style = keyTextStyle(),
modifier = Modifier
.weight(1f)
.padding(start = KEY_BLOCK_TEXT_PADDING_HORIZONTAL, top = KEY_BLOCK_TEXT_PADDING_VERTICAL, bottom = KEY_BLOCK_TEXT_PADDING_VERTICAL)
)
CopyButton(
contentDescription = stringResource(R.string.SignalLoginViewDetailsScreen__copy_account_id),
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked(text)) },
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_COPY_BUTTON)
)
}
}
@@ -167,57 +186,88 @@ private fun RecoveryKeyBlock(
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier = modifier.keyBlockSurface(onLongClick = { onEvent(SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked(groups.joinToString(separator = ""))) })) {
val style = keyTextStyle()
val textMeasurer = rememberTextMeasurer()
val maxWidth = constraints.maxWidth
Row(
verticalAlignment = Alignment.Top,
modifier = modifier.keyBlockSurface()
) {
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.padding(start = KEY_BLOCK_TEXT_PADDING_HORIZONTAL, top = KEY_BLOCK_TEXT_PADDING_VERTICAL, bottom = KEY_BLOCK_TEXT_PADDING_VERTICAL)
) {
val style = keyTextStyle()
val textMeasurer = rememberTextMeasurer()
val maxWidth = constraints.maxWidth
val groupWidth = remember(groups, style) {
groups.maxOfOrNull { group -> textMeasurer.measure(text = group, style = style).size.width } ?: 0
}
val groupWidth = remember(groups, style) {
groups.maxOfOrNull { group -> textMeasurer.measure(text = group, style = style).size.width } ?: 0
}
val minSpacing = with(LocalDensity.current) { MIN_GROUP_SPACING.roundToPx() }
val fitsFourPerRow = groupWidth * GROUPS_PER_ROW + minSpacing * (GROUPS_PER_ROW - 1) <= maxWidth
val minSpacing = with(LocalDensity.current) { MIN_GROUP_SPACING.roundToPx() }
val fitsFourPerRow = groupWidth * GROUPS_PER_ROW + minSpacing * (GROUPS_PER_ROW - 1) <= maxWidth
if (fitsFourPerRow) {
val spacing = with(LocalDensity.current) { ((maxWidth - groupWidth * GROUPS_PER_ROW) / (GROUPS_PER_ROW - 1)).toDp() }
if (fitsFourPerRow) {
val spacing = with(LocalDensity.current) { ((maxWidth - groupWidth * GROUPS_PER_ROW) / (GROUPS_PER_ROW - 1)).toDp() }
Column {
groups.chunked(GROUPS_PER_ROW).forEach { row ->
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
modifier = Modifier.fillMaxWidth()
) {
row.forEach { group ->
Text(
text = group,
style = style
)
Column {
groups.chunked(GROUPS_PER_ROW).forEach { row ->
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
modifier = Modifier.fillMaxWidth()
) {
row.forEach { group ->
Text(
text = group,
style = style
)
}
}
}
}
} else {
Text(
text = groups.joinToString(separator = " "),
style = style
)
}
} else {
Text(
text = groups.joinToString(separator = " "),
style = style
)
}
CopyButton(
contentDescription = stringResource(R.string.SignalLoginViewDetailsScreen__copy_recovery_key),
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(groups.joinToString(separator = ""))) },
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_RECOVERY_KEY_COPY_BUTTON)
)
}
}
/**
* The button in the top-end corner of a key block that copies the key to the clipboard.
*/
@Composable
private fun CopyButton(
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onClick,
modifier = modifier.padding(top = COPY_BUTTON_PADDING_TOP, end = COPY_BUTTON_PADDING_END)
) {
Icon(
painter = SignalIcons.Copy.painter,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun Modifier.keyBlockSurface(onLongClick: () -> Unit): Modifier {
private fun Modifier.keyBlockSurface(): Modifier {
return this
.horizontalGutters()
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(SignalTheme.colors.colorSurface2)
.combinedClickable(
onLongClick = onLongClick,
onClick = {}
)
.padding(horizontal = 28.dp, vertical = 20.dp)
}
@Composable
@@ -17,17 +17,17 @@ sealed class SignalLoginViewDetailsScreenEvents {
/** The user chose to save the credentials as a PDF. */
data object SaveAsPdfClicked : SignalLoginViewDetailsScreenEvents()
/** User long clicked the account ID field. */
data class AccountIdLongClicked(val aci: String) : SignalLoginViewDetailsScreenEvents() {
/** The user tapped the copy button on the account ID field. */
data class CopyAccountIdClicked(val aci: String) : SignalLoginViewDetailsScreenEvents() {
override fun toString(): String {
return "AccountIdLongClicked(aci=${aci.censor()})"
return "CopyAccountIdClicked(aci=${aci.censor()})"
}
}
/** User long clicked the recovery key field. */
data class RecoveryKeyLongClicked(val aep: String) : SignalLoginViewDetailsScreenEvents() {
/** The user tapped the copy button on the recovery key field. */
data class CopyRecoveryKeyClicked(val aep: String) : SignalLoginViewDetailsScreenEvents() {
override fun toString(): String {
return "RecoveryKeyLongClicked(aep=${aep.censor()})"
return "CopyRecoveryKeyClicked(aep=${aep.censor()})"
}
}
}
@@ -63,14 +63,14 @@ class SignalLoginViewDetailsViewModel(
Log.i(TAG, "Save as PDF clicked, but the flow isn't implemented yet.")
}
is SignalLoginViewDetailsScreenEvents.AccountIdLongClicked -> {
is SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked -> {
// TODO [phonenumberless] Copy the account key to the clipboard.
Log.i(TAG, "Account key long clicked, but the copy flow isn't implemented yet.")
Log.i(TAG, "Account key copy clicked, but the copy flow isn't implemented yet.")
}
is SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked -> {
is SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked -> {
// TODO [phonenumberless] Copy the recovery key to the clipboard.
Log.i(TAG, "Recovery key long clicked, but the copy flow isn't implemented yet.")
Log.i(TAG, "Recovery key copy clicked, but the copy flow isn't implemented yet.")
}
}
}
@@ -14,9 +14,13 @@
<!-- Content description for the back arrow in the top app bar. -->
<string name="SignalLoginViewDetailsScreen__navigate_back">Navigate back</string>
<!-- Section header above the account key. -->
<string name="SignalLoginViewDetailsScreen__account_key">Account key</string>
<string name="SignalLoginViewDetailsScreen__account_id">Account ID</string>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">Recovery key</string>
<!-- Content description for the button that copies the account key to the clipboard. -->
<string name="SignalLoginViewDetailsScreen__copy_account_id">Copy account ID</string>
<!-- Content description for the button that copies the recovery key to the clipboard. -->
<string name="SignalLoginViewDetailsScreen__copy_recovery_key">Copy recovery key</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
<string name="SignalLoginViewDetailsScreen__save_to_password_manager">Save to password manager</string>
<!-- Action button that saves the Signal Login as a PDF. -->