mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add UI for signal login view details screen.
This commit is contained in:
committed by
Cody Henthorne
parent
5deaab3cf3
commit
91441f6fca
@@ -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
|
||||
|
||||
+16
-141
@@ -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 = {}
|
||||
)
|
||||
|
||||
+13
-13
@@ -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. -->
|
||||
|
||||
@@ -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" />
|
||||
Binary file not shown.
@@ -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 })
|
||||
}
|
||||
}
|
||||
+269
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -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()
|
||||
}
|
||||
+26
@@ -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()})"
|
||||
}
|
||||
+79
@@ -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>
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user