Make text copy-pastable in signal login details.

This commit is contained in:
Greyson Parrelli
2026-09-09 16:37:40 -04:00
committed by Cody Henthorne
parent 1995400a8f
commit d079b46aa0
13 changed files with 211 additions and 5 deletions
@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.components.settings.app.account.signallogin
import org.signal.core.util.censor
/**
* One-shot side effects that need an Activity or the nav graph, and therefore have to be carried out by
* [SignalLoginViewDetailsFragment] rather than the screen itself.
@@ -21,4 +23,11 @@ sealed interface SignalLoginViewDetailsAction {
/** Launch the system document picker so the user can choose where to save the login PDF. */
data object LaunchSaveAsPdf : SignalLoginViewDetailsAction
/** Copy the specified text to the clipboard */
data class CopyTextToClipboard(val text: String) : SignalLoginViewDetailsAction {
override fun toString(): String {
return "CopyTextToClipboard(text=${text.censor()})"
}
}
}
@@ -17,6 +17,7 @@ import kotlinx.coroutines.launch
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.util.Result
import org.signal.core.util.Util
import org.signal.passwordmanager.SignalCredentialManager
import org.signal.signallogin.pdf.SignalLoginPdfRenderer
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen
@@ -26,6 +27,10 @@ import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen
*/
class SignalLoginViewDetailsFragment : ComposeFragment() {
companion object {
private const val CLIPBOARD_TIMEOUT_SECONDS = 60
}
private val viewModel: SignalLoginViewDetailsViewModel by viewModels()
private val savePdfLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri: Uri? ->
@@ -65,6 +70,7 @@ class SignalLoginViewDetailsFragment : ComposeFragment() {
}
}
SignalLoginViewDetailsAction.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(requireContext()))
is SignalLoginViewDetailsAction.CopyTextToClipboard -> Util.copyToClipboard(requireContext(), action.text, CLIPBOARD_TIMEOUT_SECONDS)
}
}
}
@@ -50,6 +50,12 @@ class SignalLoginViewDetailsViewModel(
SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> {
_actions.send(SignalLoginViewDetailsAction.LaunchSaveAsPdf)
}
is SignalLoginViewDetailsScreenEvents.AccountIdLongClicked -> {
_actions.send(SignalLoginViewDetailsAction.CopyTextToClipboard(event.aci))
}
is SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked -> {
_actions.send(SignalLoginViewDetailsAction.CopyTextToClipboard(event.aep))
}
}
}
}
@@ -98,4 +98,27 @@ class SignalLoginViewDetailsViewModelTest {
assertThat(actions).containsExactly(SignalLoginViewDetailsAction.LaunchSaveAsPdf)
}
@Test
fun `AccountIdLongClicked 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"))
assertThat(actions).containsExactly(SignalLoginViewDetailsAction.CopyTextToClipboard("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
}
@Test
fun `RecoveryKeyLongClicked 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))
assertThat(actions).containsExactly(SignalLoginViewDetailsAction.CopyTextToClipboard(recoveryKey))
}
}
@@ -49,6 +49,7 @@ import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.core.util.LinkActions
import org.signal.core.util.LinkActions.OpenUrlError
import org.signal.core.util.Result
import org.signal.core.util.Util
import org.signal.core.util.censor
import org.signal.core.util.serialization.AccountEntropyPoolSerializer
import org.signal.network.api.RegistrationApiV2.SessionMetadata
@@ -387,6 +388,7 @@ private const val LOCAL_BACKUP_RESTORE_RESULT = "local_backup_restore_result"
private const val PHONE_NUMBER_DISCOVERABILITY_RESULT = "phone_number_discoverability_result"
private const val TWO_FACTOR_CODE_RESULT = "two_factor_code_result"
private const val PIN_LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059792"
private const val CLIPBOARD_TIMEOUT_SECONDS = 60
// TODO [phonenumberless] Point at the real support article once it exists.
private const val SIGNAL_LOGIN_LEARN_MORE_URL = "https://support.signal.org/"
@@ -767,6 +769,8 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
}
SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf -> savePdfLauncher.launch(SignalLoginPdfRenderer.suggestedFileName(context))
is SignalLoginViewDetailsScreenActions.CopyTextToClipboard -> Util.copyToClipboard(context, action.text, CLIPBOARD_TIMEOUT_SECONDS)
}
}
@@ -5,10 +5,19 @@
package org.signal.registration.screens.signallogindetails
import org.signal.core.util.censor
sealed interface SignalLoginViewDetailsScreenActions {
/** Launch the system credential manager UI so the user can store the login in their password manager. */
data object LaunchSaveToPasswordManager : SignalLoginViewDetailsScreenActions
/** Launch the system document picker so the user can choose where to save the login PDF. */
data object LaunchSaveAsPdf : SignalLoginViewDetailsScreenActions
/** Copy the specified text to the clipboard. */
data class CopyTextToClipboard(val text: String) : SignalLoginViewDetailsScreenActions {
override fun toString(): String {
return "CopyTextToClipboard(text=${text.censor()})"
}
}
}
@@ -73,6 +73,14 @@ class SignalLoginViewDetailsViewModel(
is SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> {
_actions.trySend(SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf)
}
is SignalLoginViewDetailsScreenEvents.AccountIdLongClicked -> {
_actions.trySend(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(event.aci))
}
is SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked -> {
_actions.trySend(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(event.aep))
}
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.signallogin.SignalLoginTestTags
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreen
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsScreenEvents
import org.signal.signallogin.viewdetails.SignalLoginViewDetailsState
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class SignalLoginViewDetailsScreenTest {
companion object {
private const val ACCOUNT_KEY = "A6B28482-2E32-83D0-7F23-91360A4C2B91"
private const val RECOVERY_KEY = "UY38JH2778HJJHJ8LK19GA61S672JSJ089R023S6A57809BAP92J2YH5T326VV7T"
}
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<SignalLoginViewDetailsScreenEvents>()
@Test
fun `when the account key is long clicked, AccountIdLongClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_BLOCK).performScrollTo().performTouchInput { longClick() }
assertThat(events).contains(SignalLoginViewDetailsScreenEvents.AccountIdLongClicked(ACCOUNT_KEY))
}
@Test
fun `when the recovery key is long clicked, RecoveryKeyLongClicked is emitted`() {
setContent()
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RECOVERY_KEY_BLOCK).performScrollTo().performTouchInput { longClick() }
assertThat(events).contains(SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked(RECOVERY_KEY))
}
private fun setContent() {
composeTestRule.setContent {
SignalTheme {
SignalLoginViewDetailsScreen(
state = SignalLoginViewDetailsState(
accountKey = ACCOUNT_KEY,
recoveryKey = RECOVERY_KEY
),
onEvent = { events += it }
)
}
}
}
}
@@ -90,4 +90,25 @@ class SignalLoginViewDetailsViewModelTest {
assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.LaunchSaveAsPdf)
}
@Test
fun `AccountIdLongClicked 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"))
assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.CopyTextToClipboard("A6B28482-2E32-83D0-7F23-91360A4C2B91"))
}
@Test
fun `RecoveryKeyLongClicked 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))
assertThat(actions).containsExactly(SignalLoginViewDetailsScreenActions.CopyTextToClipboard(recoveryKey))
}
}
@@ -14,4 +14,6 @@ object SignalLoginTestTags {
const val VIEW_DETAILS_SCREEN = "signal_login_view_details_screen"
const val VIEW_DETAILS_SAVE_TO_PASSWORD_MANAGER_BUTTON = "signal_login_view_details_save_to_password_manager_button"
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"
}
@@ -7,6 +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
@@ -103,11 +104,19 @@ fun SignalLoginViewDetailsScreen(
Texts.SectionHeader(text = stringResource(R.string.SignalLoginViewDetailsScreen__account_key))
KeyBlock(text = state.accountKey)
KeyBlock(
text = state.accountKey,
onEvent = onEvent,
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_ACCOUNT_KEY_BLOCK)
)
Texts.SectionHeader(text = stringResource(R.string.SignalLoginViewDetailsScreen__recovery_key))
RecoveryKeyBlock(groups = state.recoveryKeyGroups)
RecoveryKeyBlock(
groups = state.recoveryKeyGroups,
onEvent = onEvent,
modifier = Modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_RECOVERY_KEY_BLOCK)
)
}
Footer(onEvent = onEvent)
@@ -136,9 +145,10 @@ private fun MiniCard(modifier: Modifier = Modifier) {
@Composable
private fun KeyBlock(
text: String,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.keyBlockSurface()) {
Box(modifier = modifier.keyBlockSurface(onLongClick = { onEvent(SignalLoginViewDetailsScreenEvents.AccountIdLongClicked(text)) })) {
Text(
text = text,
style = keyTextStyle()
@@ -154,9 +164,10 @@ private fun KeyBlock(
@Composable
private fun RecoveryKeyBlock(
groups: List<String>,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier = modifier.keyBlockSurface()) {
BoxWithConstraints(modifier = modifier.keyBlockSurface(onLongClick = { onEvent(SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked(groups.joinToString(separator = ""))) })) {
val style = keyTextStyle()
val textMeasurer = rememberTextMeasurer()
val maxWidth = constraints.maxWidth
@@ -196,12 +207,16 @@ private fun RecoveryKeyBlock(
}
@Composable
private fun Modifier.keyBlockSurface(): Modifier {
private fun Modifier.keyBlockSurface(onLongClick: () -> Unit): Modifier {
return this
.horizontalGutters()
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(SignalTheme.colors.colorSurface2)
.combinedClickable(
onLongClick = onLongClick,
onClick = {}
)
.padding(horizontal = 28.dp, vertical = 20.dp)
}
@@ -5,6 +5,8 @@
package org.signal.signallogin.viewdetails
import org.signal.core.util.censor
sealed class SignalLoginViewDetailsScreenEvents {
/** The user tapped the back arrow. */
data object BackClicked : SignalLoginViewDetailsScreenEvents()
@@ -14,4 +16,18 @@ 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() {
override fun toString(): String {
return "AccountIdLongClicked(aci=${aci.censor()})"
}
}
/** User long clicked the recovery key field. */
data class RecoveryKeyLongClicked(val aep: String) : SignalLoginViewDetailsScreenEvents() {
override fun toString(): String {
return "RecoveryKeyLongClicked(aep=${aep.censor()})"
}
}
}
@@ -64,6 +64,16 @@ class SignalLoginViewDetailsViewModel(
// TODO [phonenumberless] Render the credentials to a PDF and hand it to the user.
Log.i(TAG, "Save as PDF clicked, but the flow isn't implemented yet.")
}
is SignalLoginViewDetailsScreenEvents.AccountIdLongClicked -> {
// TODO [phonenumberless] Copy the account key to the clipboard.
Log.i(TAG, "Account key long clicked, but the copy flow isn't implemented yet.")
}
is SignalLoginViewDetailsScreenEvents.RecoveryKeyLongClicked -> {
// TODO [phonenumberless] Copy the recovery key to the clipboard.
Log.i(TAG, "Recovery key long clicked, but the copy flow isn't implemented yet.")
}
}
}