From 91441f6fca8e55da8cf122ccbc99a3ca330820bb Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 20 Aug 2026 14:31:24 -0400 Subject: [PATCH] Add UI for signal login view details screen. --- feature/registration/build.gradle.kts | 1 + .../signallogininfo/SignalLoginInfoScreen.kt | 157 ++-------- .../signallogininfo/SignalLoginInfoState.kt | 26 +- .../org/signal/registration/test/TestTags.kt | 1 - .../src/main/res/values/strings.xml | 6 - lib/signal-login/build.gradle.kts | 25 ++ lib/signal-login/src/main/AndroidManifest.xml | 2 + .../main/assets/fonts/MonoSpecial-Regular.otf | Bin 0 -> 7168 bytes .../signal/signallogin/SignalLoginTestTags.kt | 17 ++ .../signallogin/card/SignalLoginCard.kt | 195 +++++++++++++ .../signal/signallogin/fonts/MonoTypeface.kt | 24 ++ .../SignalLoginViewDetailsScreen.kt | 269 ++++++++++++++++++ .../SignalLoginViewDetailsScreenEvents.kt | 17 ++ .../SignalLoginViewDetailsState.kt | 26 ++ .../SignalLoginViewDetailsViewModel.kt | 79 +++++ .../res/drawable/image_signal_login_card.xml | 0 .../src/main/res/values/strings.xml | 24 ++ settings.gradle.kts | 1 + 18 files changed, 709 insertions(+), 161 deletions(-) create mode 100644 lib/signal-login/build.gradle.kts create mode 100644 lib/signal-login/src/main/AndroidManifest.xml create mode 100644 lib/signal-login/src/main/assets/fonts/MonoSpecial-Regular.otf create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/SignalLoginTestTags.kt create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/card/SignalLoginCard.kt create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/fonts/MonoTypeface.kt create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreenEvents.kt create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt create mode 100644 lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsViewModel.kt rename {feature/registration => lib/signal-login}/src/main/res/drawable/image_signal_login_card.xml (100%) create mode 100644 lib/signal-login/src/main/res/values/strings.xml diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index a03073c80d..f44caf5288 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(project(":core:models-jvm")) implementation(project(":core:serialization")) implementation(project(":lib:device-transfer")) + implementation(project(":lib:signal-login")) implementation(libs.libsignal.android) // Compose BOM diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt index 41937af1c7..fd4799fcb3 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt @@ -6,26 +6,18 @@ package org.signal.registration.screens.signallogininfo import androidx.compose.foundation.Image -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 import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -36,43 +28,28 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import org.signal.core.models.AccountEntropyPool +import org.signal.core.models.ServiceId import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.theme.SignalTheme import org.signal.registration.R -import org.signal.registration.fonts.MonoTypeface import org.signal.registration.screens.OnePaneRegistrationScaffold import org.signal.registration.screens.RegistrationScaffold import org.signal.registration.screens.TwoPaneRegistrationScaffold import org.signal.registration.screens.attachDebugLogHelper import org.signal.registration.screens.shared.BackTopAppBar import org.signal.registration.test.TestTags - -/** Aspect ratio of the credential card artwork, so it scales with the available width. */ -private const val CARD_ASPECT_RATIO = 363f / 220f - -private val CARD_MAX_WIDTH = 363.dp - -/** Number of masking dots shown in front of the revealed suffix of each credential. */ -private const val MASK_DOT_COUNT = 4 - -// Vertical space within the card is split by weight, using the gaps from the design (in its 220dp-tall coordinates) so -// that everything scales together with the artwork. -private const val WORDMARK_WEIGHT = 80f -private const val PILL_GAP_WEIGHT = 28f -private const val BOTTOM_WEIGHT = 24f +import org.signal.signallogin.card.SignalLoginCard +import java.util.UUID /** * Presents the Signal Login the user just purchased and prompts them to save it, either into the system password @@ -89,8 +66,8 @@ fun SignalLoginInfoScreen( title = stringResource(R.string.SignalLoginInfoScreen__your_signal_login), message = stringResource( R.string.SignalLoginInfoScreen__account_s_recovery_s, - state.accountIdentifier, - state.recoveryKey + state.accountDisplay, + state.recoveryDisplay ), dismiss = stringResource(android.R.string.ok), onDismiss = { onEvent(SignalLoginInfoScreenEvents.CredentialDetailsDismissed) } @@ -232,120 +209,18 @@ private fun ColumnScope.Header(twoPane: Boolean = false) { ) } -/** - * The Signal-branded card showing the purchased credentials, masked down to their final few characters. - */ @Composable private fun CredentialCard( state: SignalLoginInfoState, onEvent: (SignalLoginInfoScreenEvents) -> Unit, modifier: Modifier = Modifier ) { - Box( - modifier = modifier - .widthIn(max = CARD_MAX_WIDTH) - .fillMaxWidth() - .aspectRatio(CARD_ASPECT_RATIO) - .testTag(TestTags.SIGNAL_LOGIN_INFO_CREDENTIAL_CARD) - ) { - Image( - painter = painterResource(R.drawable.image_signal_login_card), - contentDescription = null, - contentScale = ContentScale.FillBounds, - modifier = Modifier.fillMaxSize() - ) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp) - ) { - // The card artwork scales with the box, so the space it reserves for the baked-in "Signal" wordmark is - // apportioned by weight rather than a fixed height. - Spacer(modifier = Modifier.weight(WORDMARK_WEIGHT)) - - Row(modifier = Modifier.fillMaxWidth()) { - MaskedCredential( - label = stringResource(R.string.SignalLoginInfoScreen__account), - visibleSuffix = state.accountSuffix, - modifier = Modifier.weight(1f) - ) - - MaskedCredential( - label = stringResource(R.string.SignalLoginInfoScreen__recovery), - visibleSuffix = state.recoverySuffix, - modifier = Modifier.weight(1f) - ) - } - - Spacer(modifier = Modifier.weight(PILL_GAP_WEIGHT)) - - ViewDetailsButton( - onClick = { onEvent(SignalLoginInfoScreenEvents.ViewDetailsClicked) }, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - - Spacer(modifier = Modifier.weight(BOTTOM_WEIGHT)) - } - } -} - -@Composable -private fun MaskedCredential( - label: String, - visibleSuffix: String, - modifier: Modifier = Modifier -) { - Column(modifier = modifier) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.6f) - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - repeat(MASK_DOT_COUNT) { - Box( - modifier = Modifier - .padding(end = 8.dp) - .size(7.dp) - .clip(CircleShape) - .background(Color.White) - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - Text( - text = visibleSuffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = MonoTypeface.fontFamily(), - fontSize = 15.sp, - letterSpacing = 2.sp - ), - color = Color.White - ) - } - } -} - -@Composable -private fun ViewDetailsButton( - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .clip(RoundedCornerShape(18.dp)) - .background(Color.White.copy(alpha = 0.2f)) - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 8.dp) - .testTag(TestTags.SIGNAL_LOGIN_INFO_VIEW_DETAILS_BUTTON) - ) { - Text( - text = stringResource(R.string.SignalLoginInfoScreen__view_details), - style = MaterialTheme.typography.labelLarge, - color = Color.White.copy(alpha = 0.96f) + if (state.aci != null && state.recoveryKey != null) { + SignalLoginCard( + aci = state.aci, + aep = state.recoveryKey, + onViewDetailsClicked = { onEvent(SignalLoginInfoScreenEvents.ViewDetailsClicked) }, + modifier = modifier.testTag(TestTags.SIGNAL_LOGIN_INFO_CREDENTIAL_CARD) ) } } @@ -414,8 +289,8 @@ private fun SignalLoginInfoScreenPreview() { Previews.Preview { SignalLoginInfoScreen( state = SignalLoginInfoState( - accountIdentifier = "H7KQ2B91", - recoveryKey = "3TXPVV7T", + aci = ServiceId.ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91")), + recoveryKey = AccountEntropyPool("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"), isPasswordManagerAvailable = true ), onEvent = {} @@ -429,8 +304,8 @@ private fun SignalLoginInfoScreenNoPasswordManagerPreview() { Previews.Preview { SignalLoginInfoScreen( state = SignalLoginInfoState( - accountIdentifier = "H7KQ2B91", - recoveryKey = "3TXPVV7T" + aci = ServiceId.ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91")), + recoveryKey = AccountEntropyPool("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t") ), onEvent = {} ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt index 8606ed8a4c..22c21251e6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt @@ -5,32 +5,32 @@ package org.signal.registration.screens.signallogininfo +import org.signal.core.models.AccountEntropyPool +import org.signal.core.models.ServiceId import org.signal.core.util.censor /** * State for the screen that hands the user their newly-purchased Signal Login and asks them to save it. * - * The account and recovery values are shown masked, with only [VISIBLE_SUFFIX_LENGTH] trailing characters revealed, - * until the user opts into seeing the full credentials. + * The credentials are shown masked on the card, with only a few trailing characters revealed, until the user opts + * into seeing the full values. */ data class SignalLoginInfoState( - val accountIdentifier: String = "", - val recoveryKey: String = "", + val aci: ServiceId.ACI? = null, + val recoveryKey: AccountEntropyPool? = null, val isPasswordManagerAvailable: Boolean = false, val showSpinner: Boolean = false, val dialogs: Dialogs = Dialogs() ) { - companion object { - const val VISIBLE_SUFFIX_LENGTH = 4 - } + /** Full account identifier, as it should be displayed to the user. */ + val accountDisplay: String + get() = aci?.toString()?.uppercase().orEmpty() - val accountSuffix: String - get() = accountIdentifier.takeLast(VISIBLE_SUFFIX_LENGTH) + /** Full recovery key, as it should be displayed to the user. */ + val recoveryDisplay: String + get() = recoveryKey?.displayValue.orEmpty() - val recoverySuffix: String - get() = recoveryKey.takeLast(VISIBLE_SUFFIX_LENGTH) - - override fun toString(): String = "SignalLoginInfoState(accountIdentifier=${accountIdentifier.censor()}, recoveryKey=${recoveryKey.censor()}, " + + override fun toString(): String = "SignalLoginInfoState(aci=${aci?.logString()}, recoveryKey=${recoveryKey?.value?.censor()}, " + "isPasswordManagerAvailable=$isPasswordManagerAvailable, showSpinner=$showSpinner, dialogs=$dialogs)" data class Dialogs( diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index f30e7dcb2c..7017b13b67 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -59,7 +59,6 @@ object TestTags { // Signal Login Info Screen const val SIGNAL_LOGIN_INFO_SCREEN = "signal_login_info_screen" const val SIGNAL_LOGIN_INFO_CREDENTIAL_CARD = "signal_login_info_credential_card" - const val SIGNAL_LOGIN_INFO_VIEW_DETAILS_BUTTON = "signal_login_info_view_details_button" const val SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON = "signal_login_info_save_to_password_manager_button" const val SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON = "signal_login_info_save_manually_button" diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 6752a21731..e0dae13582 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -643,12 +643,6 @@ Your Signal Login Thanks for your purchase. Use your Signal Login to register and restore your account. If you forget your keys, you will not be able to recover your account. - - Account - - Recovery - - View details Account: %1$s\n\nRecovery: %2$s diff --git a/lib/signal-login/build.gradle.kts b/lib/signal-login/build.gradle.kts new file mode 100644 index 0000000000..68b826ea78 --- /dev/null +++ b/lib/signal-login/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("signal-library") + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "org.signal.signallogin" + + buildFeatures { + compose = true + } +} + +dependencies { + lintChecks(project(":lintchecks")) + + api(project(":core:ui")) + implementation(project(":core:util-jvm")) + implementation(project(":core:models-jvm")) + implementation(libs.libsignal.android) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.lifecycle.viewmodel.ktx) +} diff --git a/lib/signal-login/src/main/AndroidManifest.xml b/lib/signal-login/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b2d3ea1235 --- /dev/null +++ b/lib/signal-login/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/lib/signal-login/src/main/assets/fonts/MonoSpecial-Regular.otf b/lib/signal-login/src/main/assets/fonts/MonoSpecial-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..314b20f5a80e257d2ef578ea14cc6afb539725ba GIT binary patch literal 7168 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 D=kkos literal 0 HcmV?d00001 diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/SignalLoginTestTags.kt b/lib/signal-login/src/main/java/org/signal/signallogin/SignalLoginTestTags.kt new file mode 100644 index 0000000000..7f25fe4bf8 --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/SignalLoginTestTags.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.signallogin + +/** + * Test tags for the composables in this module, so UI tests can find them. + */ +object SignalLoginTestTags { + const val CARD_VIEW_DETAILS_BUTTON = "signal_login_card_view_details_button" + + 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" +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/card/SignalLoginCard.kt b/lib/signal-login/src/main/java/org/signal/signallogin/card/SignalLoginCard.kt new file mode 100644 index 0000000000..84693bc463 --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/card/SignalLoginCard.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.signallogin.card + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.models.AccountEntropyPool +import org.signal.core.models.ServiceId +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.signallogin.R +import org.signal.signallogin.SignalLoginTestTags +import org.signal.signallogin.fonts.MonoTypeface +import java.util.UUID + +/** Aspect ratio of the credential card artwork, so it scales with the available width. */ +private const val CARD_ASPECT_RATIO = 363f / 220f + +private val CARD_MAX_WIDTH = 363.dp + +/** Number of masking dots shown in front of the revealed suffix of each credential. */ +private const val MASK_DOT_COUNT = 4 + +/** Number of trailing characters of each credential that are left visible. */ +private const val VISIBLE_SUFFIX_LENGTH = 4 + +// Vertical space within the card is split by weight, using the gaps from the design (in its 220dp-tall coordinates) so +// that everything scales together with the artwork. +private const val WORDMARK_WEIGHT = 80f +private const val PILL_GAP_WEIGHT = 28f +private const val BOTTOM_WEIGHT = 24f + +/** + * The Signal-branded card showing a Signal Login, masked down to the final few characters of each credential. + * + * @param aci The account key. Only the last few characters are shown. + * @param aep The recovery key. Only the last few characters are shown. + * @param onViewDetailsClicked Invoked when the user taps the "View details" pill on the card. + */ +@Composable +fun SignalLoginCard( + aci: ServiceId.ACI, + aep: AccountEntropyPool, + onViewDetailsClicked: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .widthIn(max = CARD_MAX_WIDTH) + .fillMaxWidth() + .aspectRatio(CARD_ASPECT_RATIO) + ) { + Image( + painter = painterResource(R.drawable.image_signal_login_card), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier.fillMaxSize() + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + ) { + // The card artwork scales with the box, so the space it reserves for the baked-in "Signal" wordmark is + // apportioned by weight rather than a fixed height. + Spacer(modifier = Modifier.weight(WORDMARK_WEIGHT)) + + Row(modifier = Modifier.fillMaxWidth()) { + MaskedCredential( + label = stringResource(R.string.SignalLoginCard__account), + visibleSuffix = aci.toString().takeLast(VISIBLE_SUFFIX_LENGTH).uppercase(), + modifier = Modifier.weight(1f) + ) + + MaskedCredential( + label = stringResource(R.string.SignalLoginCard__recovery), + visibleSuffix = aep.displayValue.takeLast(VISIBLE_SUFFIX_LENGTH), + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.weight(PILL_GAP_WEIGHT)) + + ViewDetailsButton( + onClick = onViewDetailsClicked, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.weight(BOTTOM_WEIGHT)) + } + } +} + +@Composable +private fun MaskedCredential( + label: String, + visibleSuffix: String, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = Color.White.copy(alpha = 0.6f) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + repeat(MASK_DOT_COUNT) { + Box( + modifier = Modifier + .padding(end = 8.dp) + .size(7.dp) + .clip(CircleShape) + .background(Color.White) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = visibleSuffix, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = MonoTypeface.fontFamily(), + fontSize = 15.sp, + letterSpacing = 2.sp + ), + color = Color.White + ) + } + } +} + +@Composable +private fun ViewDetailsButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(18.dp)) + .background(Color.White.copy(alpha = 0.2f)) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 8.dp) + .testTag(SignalLoginTestTags.CARD_VIEW_DETAILS_BUTTON) + ) { + Text( + text = stringResource(R.string.SignalLoginCard__view_details), + style = MaterialTheme.typography.labelLarge, + color = Color.White.copy(alpha = 0.96f) + ) + } +} + +@DayNightPreviews +@Composable +private fun SignalLoginCardPreview() { + Previews.Preview { + SignalLoginCard( + aci = ServiceId.ACI.from(UUID.fromString("a6b28482-2e32-83d0-7f23-91360a4c2b91")), + aep = AccountEntropyPool("uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t"), + onViewDetailsClicked = {} + ) + } +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/fonts/MonoTypeface.kt b/lib/signal-login/src/main/java/org/signal/signallogin/fonts/MonoTypeface.kt new file mode 100644 index 0000000000..f829dfc75a --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/fonts/MonoTypeface.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.signallogin.fonts + +import android.graphics.Typeface +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily + +/** + * Special monospace font, primarily used for rendering AEPs. + */ +object MonoTypeface { + private var cached: Typeface? = null + + @Composable + fun fontFamily(): FontFamily { + val context = LocalContext.current + return FontFamily(cached ?: Typeface.createFromAsset(context.assets, "fonts/MonoSpecial-Regular.otf").also { cached = it }) + } +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt new file mode 100644 index 0000000000..f5c80ae447 --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt @@ -0,0 +1,269 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.signallogin.viewdetails + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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 +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.compose.Texts +import org.signal.core.ui.compose.horizontalGutters +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.signallogin.R +import org.signal.signallogin.SignalLoginTestTags +import org.signal.signallogin.fonts.MonoTypeface + +/** Size of the miniature credential card artwork shown at the top of the screen, from the design. */ +private val MINI_CARD_WIDTH = 175.dp +private val MINI_CARD_HEIGHT = 100.dp + +/** Corner radius of the card artwork (26dp in its 363dp-wide coordinates), scaled down to the miniature size. */ +private val MINI_CARD_CORNER_RADIUS = 13.dp + +private val BUTTON_MAX_WIDTH = 331.dp + +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 + +/** + * Shows the user the full keys that make up their Signal Login and offers ways to save them. + */ +@Composable +fun SignalLoginViewDetailsScreen( + state: SignalLoginViewDetailsState, + onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + Scaffolds.Settings( + title = stringResource(R.string.SignalLoginViewDetailsScreen__signal_login), + onNavigationClick = { onEvent(SignalLoginViewDetailsScreenEvents.BackClicked) }, + navigationIcon = SignalIcons.ArrowStart.imageVector, + navigationContentDescription = stringResource(R.string.SignalLoginViewDetailsScreen__navigate_back), + modifier = modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_SCREEN) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + MiniCard( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 20.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Texts.SectionHeader(text = stringResource(R.string.SignalLoginViewDetailsScreen__account_key)) + + KeyBlock(text = state.accountKey) + + Texts.SectionHeader(text = stringResource(R.string.SignalLoginViewDetailsScreen__recovery_key)) + + RecoveryKeyBlock(groups = state.recoveryKeyGroups) + } + + Footer(onEvent = onEvent) + } + } +} + +/** + * A miniature of the credential card artwork, without any of the card's content. + */ +@Composable +private fun MiniCard(modifier: Modifier = Modifier) { + Image( + painter = painterResource(R.drawable.image_signal_login_card), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = modifier + .size(width = MINI_CARD_WIDTH, height = MINI_CARD_HEIGHT) + .shadow(elevation = 6.dp, shape = RoundedCornerShape(MINI_CARD_CORNER_RADIUS)) + ) +} + +/** + * A full credential rendered in the special monospace font on a rounded surface. + */ +@Composable +private fun KeyBlock( + text: String, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.keyBlockSurface()) { + Text( + text = text, + style = keyTextStyle() + ) + } +} + +/** + * The recovery key rendered as character groups. When four groups fit per row with at least + * [MIN_GROUP_SPACING] between them, renders rows of four groups evenly spaced across the full + * width. Otherwise renders the whole key as a single space-separated string that wraps naturally. + */ +@Composable +private fun RecoveryKeyBlock( + groups: List, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier.keyBlockSurface()) { + 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 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() } + + 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 + ) + } + } +} + +@Composable +private fun Modifier.keyBlockSurface(): Modifier { + return this + .horizontalGutters() + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(SignalTheme.colors.colorSurface2) + .padding(horizontal = 28.dp, vertical = 20.dp) +} + +@Composable +private fun keyTextStyle(): TextStyle { + return MaterialTheme.typography.bodyLarge.copy( + fontFamily = MonoTypeface.fontFamily(), + fontSize = 18.sp, + lineHeight = 28.sp, + letterSpacing = 1.44.sp + ) +} + +@Composable +private fun Footer(onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Buttons.MediumTonal( + onClick = { onEvent(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked) }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier + .widthIn(max = BUTTON_MAX_WIDTH) + .fillMaxWidth() + .testTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_TO_PASSWORD_MANAGER_BUTTON) + ) { + Text(stringResource(R.string.SignalLoginViewDetailsScreen__save_to_password_manager)) + } + + Buttons.MediumTonal( + onClick = { onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked) }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier + .widthIn(max = BUTTON_MAX_WIDTH) + .fillMaxWidth() + .testTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_AS_PDF_BUTTON) + ) { + Text(stringResource(R.string.SignalLoginViewDetailsScreen__save_as_pdf)) + } + } +} + +@DayNightPreviews +@Composable +private fun SignalLoginViewDetailsScreenPreview() { + Previews.Preview { + SignalLoginViewDetailsScreen( + state = SignalLoginViewDetailsState( + accountKey = "A6B28482-2E32-83D0-7F23-91360A4C2B91", + recoveryKey = "UY38JH2778HJJHJ8LK19GA61S672JSJ=89R=23S6A578=9BAP92J2YH5T326VV7T" + ), + onEvent = {} + ) + } +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreenEvents.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreenEvents.kt new file mode 100644 index 0000000000..82346a12e0 --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreenEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.signallogin.viewdetails + +sealed class SignalLoginViewDetailsScreenEvents { + /** The user tapped the back arrow. */ + data object BackClicked : SignalLoginViewDetailsScreenEvents() + + /** The user chose to store the credentials with the system password manager. */ + data object SaveToPasswordManagerClicked : SignalLoginViewDetailsScreenEvents() + + /** The user chose to save the credentials as a PDF. */ + data object SaveAsPdfClicked : SignalLoginViewDetailsScreenEvents() +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt new file mode 100644 index 0000000000..6722c8ee5a --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsState.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.signallogin.viewdetails + +import org.signal.core.util.censor + +/** + * State for the screen that shows the user the full keys that make up their Signal Login. + */ +data class SignalLoginViewDetailsState( + val accountKey: String = "", + val recoveryKey: String = "" +) { + companion object { + private const val GROUP_SIZE = 4 + } + + /** The recovery key broken into character groups, in display order. */ + val recoveryKeyGroups: List + get() = recoveryKey.chunked(GROUP_SIZE) + + override fun toString(): String = "SignalLoginViewDetailsState(accountKey=${accountKey.censor()}, recoveryKey=${recoveryKey.censor()})" +} diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsViewModel.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsViewModel.kt new file mode 100644 index 0000000000..a755fea890 --- /dev/null +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsViewModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.signallogin.viewdetails + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.signal.core.models.AccountEntropyPool +import org.signal.core.models.ServiceId +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log + +/** + * View model for [SignalLoginViewDetailsScreen]. + * + * The screen only renders the credentials it is given, so the state is fully derived from the constructor arguments. + * None of the actions the screen can produce are implemented yet -- every event is routed here and handled explicitly + * so that filling in the business logic is a matter of replacing the TODO branches. + */ +class SignalLoginViewDetailsViewModel( + aci: ServiceId.ACI, + aep: AccountEntropyPool +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(SignalLoginViewDetailsViewModel::class) + } + + private val _state = MutableStateFlow( + SignalLoginViewDetailsState( + accountKey = aci.toString().uppercase(), + recoveryKey = aep.displayValue + ) + ) + val state: StateFlow = _state.asStateFlow() + + init { + _state + .onEach { Log.d(TAG, "[State] $it") } + .launchIn(viewModelScope) + } + + override suspend fun processEvent(event: SignalLoginViewDetailsScreenEvents) { + when (event) { + is SignalLoginViewDetailsScreenEvents.BackClicked -> { + // TODO [phonenumberless] Navigate back once this screen is hooked into a flow. + Log.i(TAG, "Back clicked, but navigation isn't implemented yet.") + } + + is SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked -> { + // TODO [phonenumberless] Store the credentials via the credential manager. + Log.i(TAG, "Save to password manager clicked, but the flow isn't implemented yet.") + } + + is SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked -> { + // 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.") + } + } + } + + class Factory( + private val aci: ServiceId.ACI, + private val aep: AccountEntropyPool + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return SignalLoginViewDetailsViewModel(aci, aep) as T + } + } +} diff --git a/feature/registration/src/main/res/drawable/image_signal_login_card.xml b/lib/signal-login/src/main/res/drawable/image_signal_login_card.xml similarity index 100% rename from feature/registration/src/main/res/drawable/image_signal_login_card.xml rename to lib/signal-login/src/main/res/drawable/image_signal_login_card.xml diff --git a/lib/signal-login/src/main/res/values/strings.xml b/lib/signal-login/src/main/res/values/strings.xml new file mode 100644 index 0000000000..1f9d3627ea --- /dev/null +++ b/lib/signal-login/src/main/res/values/strings.xml @@ -0,0 +1,24 @@ + + + + + Account + + Recovery + + View details + + + + Signal Login + + Navigate back + + Account key + + Recovery key + + Save to password manager + + Save as PDF + diff --git a/settings.gradle.kts b/settings.gradle.kts index 1048250e64..bea7b3ff6d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -125,6 +125,7 @@ include(":lib:apng") include(":lib:emoji") include(":lib:archive") include(":lib:ui-components") +include(":lib:signal-login") // Feature modules include(":feature:app-settings")