Add UI for signal login view details screen.

This commit is contained in:
Greyson Parrelli
2026-08-21 17:36:10 -04:00
committed by Cody Henthorne
parent 5deaab3cf3
commit 91441f6fca
18 changed files with 709 additions and 161 deletions
+1
View File
@@ -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
@@ -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 = {}
)
@@ -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(
@@ -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"
@@ -643,12 +643,6 @@
<string name="SignalLoginInfoScreen__your_signal_login">Your Signal Login</string>
<!-- Description warning the user that a lost Signal Login cannot be recovered. -->
<string name="SignalLoginInfoScreen__thanks_for_your_purchase">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.</string>
<!-- Label for the account portion of the Signal Login on the credential card. -->
<string name="SignalLoginInfoScreen__account">Account</string>
<!-- Label for the recovery portion of the Signal Login on the credential card. -->
<string name="SignalLoginInfoScreen__recovery">Recovery</string>
<!-- Button on the credential card that reveals the full Signal Login. -->
<string name="SignalLoginInfoScreen__view_details">View details</string>
<!-- Body of the dialog revealing the full Signal Login. First placeholder is the account key, second is the recovery key. -->
<string name="SignalLoginInfoScreen__account_s_recovery_s">Account: %1$s\n\nRecovery: %2$s</string>
<!-- Action button that stores the Signal Login in the device password manager. -->
+25
View File
@@ -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)
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -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"
}
@@ -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 = {}
)
}
}
@@ -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 })
}
}
@@ -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<String>,
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 = {}
)
}
}
@@ -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()
}
@@ -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<String>
get() = recoveryKey.chunked(GROUP_SIZE)
override fun toString(): String = "SignalLoginViewDetailsState(accountKey=${accountKey.censor()}, recoveryKey=${recoveryKey.censor()})"
}
@@ -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<SignalLoginViewDetailsScreenEvents>(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<SignalLoginViewDetailsState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return SignalLoginViewDetailsViewModel(aci, aep) as T
}
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Signal Login credential card -->
<!-- Label for the account portion of the Signal Login on the credential card. -->
<string name="SignalLoginCard__account">Account</string>
<!-- Label for the recovery portion of the Signal Login on the credential card. -->
<string name="SignalLoginCard__recovery">Recovery</string>
<!-- Button on the credential card that reveals the full Signal Login. -->
<string name="SignalLoginCard__view_details">View details</string>
<!-- Signal Login view details screen -->
<!-- Title of the screen that shows the user the full keys that make up their Signal Login. -->
<string name="SignalLoginViewDetailsScreen__signal_login">Signal Login</string>
<!-- 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>
<!-- Section header above the recovery key. -->
<string name="SignalLoginViewDetailsScreen__recovery_key">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. -->
<string name="SignalLoginViewDetailsScreen__save_as_pdf">Save as PDF</string>
</resources>
+1
View File
@@ -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")