mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Make text copy-pastable in signal login details.
This commit is contained in:
committed by
Cody Henthorne
parent
1995400a8f
commit
d079b46aa0
+9
@@ -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()})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -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()})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+77
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
|
||||
+20
-5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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()})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user