mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-06 05:14:50 +01:00
Add scaffolding for new reg screens.
This commit is contained in:
committed by
Alex Hart
parent
44334936d7
commit
66ff18c310
@@ -433,6 +433,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
new AppRegistrationNetworkController(this, AppDependencies.getRegistrationApiV2()),
|
||||
new AppRegistrationStorageController(this),
|
||||
Environment.IS_LINK_AND_SYNC_AVAILABLE,
|
||||
Environment.PHONENUMBERLESS_REGISTRATION,
|
||||
null,
|
||||
context -> {
|
||||
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
|
||||
|
||||
@@ -28,6 +28,8 @@ object Environment {
|
||||
@JvmField
|
||||
val IS_LINK_AND_SYNC_AVAILABLE: Boolean = true
|
||||
|
||||
const val PHONENUMBERLESS_REGISTRATION: Boolean = false
|
||||
|
||||
object Backups {
|
||||
@JvmStatic
|
||||
fun supportsGooglePlayBilling(): Boolean {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isFalse
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Guards flags in [Environment] that are only meant to be flipped on for local development.
|
||||
*/
|
||||
class EnvironmentTest {
|
||||
|
||||
/**
|
||||
* The phone-numberless registration flow is incomplete. If this test fails, someone left the flag enabled after
|
||||
* testing locally. Do not "fix" it by updating the test.
|
||||
*/
|
||||
@Test
|
||||
fun `phone-numberless registration is disabled`() {
|
||||
assertThat(Environment.PHONENUMBERLESS_REGISTRATION).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,8 @@ private fun SampleNavHost(
|
||||
context = context.applicationContext,
|
||||
networkController = registrationDependencies.networkController,
|
||||
storageController = registrationDependencies.storageController,
|
||||
isLinkAndSyncAvailable = registrationDependencies.isLinkAndSyncAvailable
|
||||
isLinkAndSyncAvailable = registrationDependencies.isLinkAndSyncAvailable,
|
||||
isPhoneNumberlessRegistrationAvailable = registrationDependencies.isPhoneNumberlessRegistrationAvailable
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -88,7 +88,8 @@ class RegistrationApplication : Application() {
|
||||
.show()
|
||||
}
|
||||
},
|
||||
isLinkAndSyncAvailable = true
|
||||
isLinkAndSyncAvailable = true,
|
||||
isPhoneNumberlessRegistrationAvailable = true
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ class RegistrationActivity : ComponentActivity() {
|
||||
context = this.application,
|
||||
networkController = RegistrationDependencies.get().networkController,
|
||||
storageController = RegistrationDependencies.get().storageController,
|
||||
isLinkAndSyncAvailable = RegistrationDependencies.get().isLinkAndSyncAvailable
|
||||
isLinkAndSyncAvailable = RegistrationDependencies.get().isLinkAndSyncAvailable,
|
||||
isPhoneNumberlessRegistrationAvailable = RegistrationDependencies.get().isPhoneNumberlessRegistrationAvailable
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import org.signal.registration.util.SensitiveLog
|
||||
/**
|
||||
* Injection point for dependencies needed by this module.
|
||||
*
|
||||
* @param isPhoneNumberlessRegistrationAvailable Whether the in-progress phone-numberless registration flow (registering
|
||||
* with a purchased Signal Login instead of a phone number) is offered. The screens are always present in the
|
||||
* navigation graph; this only gates them off at runtime while the flow is unfinished.
|
||||
* @param sensitiveLogger A logger for logging sensitive material. The intention is this would only be used in the demo app for testing + debugging, while
|
||||
* the actual app would just pass null.
|
||||
* @param debugLogCallback Callback to launch the debug log viewer. The actual app provides the real implementation.
|
||||
@@ -22,6 +25,7 @@ class RegistrationDependencies(
|
||||
val networkController: NetworkController,
|
||||
val storageController: StorageController,
|
||||
val isLinkAndSyncAvailable: Boolean,
|
||||
val isPhoneNumberlessRegistrationAvailable: Boolean,
|
||||
val sensitiveLogger: Log.Logger?,
|
||||
val debugLogCallback: ((Context) -> Unit)?,
|
||||
val proxyConfigCallback: ((Context) -> Unit)?,
|
||||
|
||||
+101
-27
@@ -7,6 +7,7 @@
|
||||
|
||||
package org.signal.registration
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
@@ -48,6 +49,9 @@ import org.signal.network.api.RegistrationApiV2.SvrCredentials
|
||||
import org.signal.registration.screens.accountlocked.AccountLockedScreen
|
||||
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
|
||||
import org.signal.registration.screens.accountlocked.AccountLockedState
|
||||
import org.signal.registration.screens.addusername.AddUsernameScreen
|
||||
import org.signal.registration.screens.addusername.AddUsernameScreenEvents
|
||||
import org.signal.registration.screens.addusername.AddUsernameViewModel
|
||||
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupResult
|
||||
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupViewModel
|
||||
import org.signal.registration.screens.aepentry.EnterAepForRemoteBackupPostRegistrationViewModel
|
||||
@@ -104,6 +108,11 @@ import org.signal.registration.screens.restoreselection.ArchiveRestoreOption
|
||||
import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionScreen
|
||||
import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionViewModel
|
||||
import org.signal.registration.screens.restoreselection.RegisteredState
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreen
|
||||
import org.signal.registration.screens.signallogininfo.SignalLoginInfoViewModel
|
||||
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreen
|
||||
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreenEvents
|
||||
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentViewModel
|
||||
import org.signal.registration.screens.util.navigateBack
|
||||
import org.signal.registration.screens.util.navigateTo
|
||||
import org.signal.registration.screens.verificationcode.VerificationCodeScreen
|
||||
@@ -147,6 +156,18 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
|
||||
@Serializable
|
||||
data object VerificationCodeEntry : RegistrationRoute
|
||||
|
||||
/** Buy a Signal Login (or declare an existing one) in order to register without a phone number. */
|
||||
@Serializable
|
||||
data object SignalLoginPayment : RegistrationRoute
|
||||
|
||||
/** Presents a freshly-purchased Signal Login and asks the user to save it. */
|
||||
@Serializable
|
||||
data object SignalLoginInfo : RegistrationRoute
|
||||
|
||||
/** Optional username selection for a phone-numberless account. */
|
||||
@Serializable
|
||||
data object AddUsername : RegistrationRoute
|
||||
|
||||
@Serializable
|
||||
@TypeParceler<SessionMetadata, SessionMetadataParceler>
|
||||
data class Captcha(val session: SessionMetadata) : RegistrationRoute
|
||||
@@ -299,6 +320,19 @@ private const val AEP_FOR_LOCAL_BACKUP_RESULT = "aep_for_local_backup_result"
|
||||
private const val LOCAL_BACKUP_RESTORE_RESULT = "local_backup_restore_result"
|
||||
private const val PHONE_NUMBER_DISCOVERABILITY_RESULT = "phone_number_discoverability_result"
|
||||
private const val PIN_LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059792"
|
||||
private const val USERNAME_LEARN_MORE_URL = "https://support.signal.org/hc/articles/5389476324250"
|
||||
|
||||
// TODO [phonenumberless] Point at the real support article once it exists.
|
||||
private const val SIGNAL_LOGIN_LEARN_MORE_URL = "https://support.signal.org/"
|
||||
|
||||
/** Opens [url] in a browser, surfacing a toast if the device has none. */
|
||||
private fun openUrl(context: Context, url: String) {
|
||||
LinkActions.openUrl(context, url) { error ->
|
||||
when (error) {
|
||||
OpenUrlError.NoBrowserFound -> Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the navigation graph for the registration flow using Navigation 3.
|
||||
@@ -414,13 +448,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
state = state,
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
WelcomeScreenEvents.ViewTermsAndPrivacy -> {
|
||||
LinkActions.openUrl(context, termsAndPrivacyUrl) { error ->
|
||||
when (error) {
|
||||
OpenUrlError.NoBrowserFound -> Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
WelcomeScreenEvents.ViewTermsAndPrivacy -> openUrl(context, termsAndPrivacyUrl)
|
||||
else -> viewModel.onEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -473,9 +501,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
state = state,
|
||||
onEvent = {
|
||||
when (it) {
|
||||
LinkAccountScreenEvent.GetHelpClick -> LinkActions.openUrl(context, url) {
|
||||
Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
LinkAccountScreenEvent.GetHelpClick -> openUrl(context, url)
|
||||
else -> viewModel.onEvent(it)
|
||||
}
|
||||
}
|
||||
@@ -499,9 +525,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
state = state,
|
||||
onEvent = {
|
||||
when (it) {
|
||||
MessageSyncScreenEvent.LearnMoreClick -> LinkActions.openUrl(context, url) {
|
||||
Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
MessageSyncScreenEvent.LearnMoreClick -> openUrl(context, url)
|
||||
else -> viewModel.onEvent(it)
|
||||
}
|
||||
}
|
||||
@@ -600,6 +624,68 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
)
|
||||
}
|
||||
|
||||
// -- Signal Login Payment Screen
|
||||
entry<RegistrationRoute.SignalLoginPayment> {
|
||||
val viewModel: SignalLoginPaymentViewModel = viewModel(
|
||||
factory = SignalLoginPaymentViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentEventEmitter = registrationViewModel::onEvent
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
SignalLoginPaymentScreen(
|
||||
state = state,
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
SignalLoginPaymentScreenEvents.LearnMoreClicked -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
|
||||
else -> viewModel.onEvent(event)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// -- Signal Login Info Screen
|
||||
entry<RegistrationRoute.SignalLoginInfo> {
|
||||
val context = LocalContext.current
|
||||
val viewModel: SignalLoginInfoViewModel = viewModel(
|
||||
factory = SignalLoginInfoViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentEventEmitter = registrationViewModel::onEvent,
|
||||
isPasswordManagerAvailable = RegistrationCredentialManager.isSupported(context)
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
SignalLoginInfoScreen(
|
||||
state = state,
|
||||
onEvent = { viewModel.onEvent(it) }
|
||||
)
|
||||
}
|
||||
|
||||
// -- Add Username Screen
|
||||
entry<RegistrationRoute.AddUsername> {
|
||||
val viewModel: AddUsernameViewModel = viewModel(
|
||||
factory = AddUsernameViewModel.Factory(
|
||||
repository = registrationRepository,
|
||||
parentEventEmitter = registrationViewModel::onEvent
|
||||
)
|
||||
)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
AddUsernameScreen(
|
||||
state = state,
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
AddUsernameScreenEvents.LearnMoreClicked -> openUrl(context, USERNAME_LEARN_MORE_URL)
|
||||
else -> viewModel.onEvent(event)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// -- SVR Restore PIN Entry Screen (for users with existing backup data)
|
||||
entry<RegistrationRoute.PinEntryForSvrRestore> {
|
||||
val viewModel: PinEntryForSvrRestoreViewModel = viewModel(
|
||||
@@ -633,13 +719,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
state = state,
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
PinCreationScreenEvents.LearnMore -> {
|
||||
LinkActions.openUrl(context, PIN_LEARN_MORE_URL) { error ->
|
||||
when (error) {
|
||||
OpenUrlError.NoBrowserFound -> Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
PinCreationScreenEvents.LearnMore -> openUrl(context, PIN_LEARN_MORE_URL)
|
||||
|
||||
else -> viewModel.onEvent(event)
|
||||
}
|
||||
@@ -698,13 +778,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.Welcome)
|
||||
}
|
||||
|
||||
AccountLockedScreenEvents.LearnMore -> {
|
||||
LinkActions.openUrl(context, learnMoreUrl) { error ->
|
||||
when (error) {
|
||||
OpenUrlError.NoBrowserFound -> Toast.makeText(context, R.string.LinkActions_error_no_browser_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountLockedScreenEvents.LearnMore -> openUrl(context, learnMoreUrl)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+7
-1
@@ -79,7 +79,13 @@ import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController, val isLinkAndSyncAvailable: Boolean) {
|
||||
class RegistrationRepository(
|
||||
val context: Context,
|
||||
val networkController: NetworkController,
|
||||
val storageController: StorageController,
|
||||
val isLinkAndSyncAvailable: Boolean,
|
||||
val isPhoneNumberlessRegistrationAvailable: Boolean = false
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationRepository::class)
|
||||
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.addusername
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
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.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.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.SignalIcons
|
||||
import org.signal.registration.R
|
||||
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.test.TestTags
|
||||
|
||||
/** Size of the avatar artwork, whose sphere occupies the inner 72dp of its 80dp box. */
|
||||
private val AVATAR_SIZE = 80.dp
|
||||
|
||||
/** Size of the glyph centered on the avatar, per the design's 36dp icon box. */
|
||||
private val AVATAR_GLYPH_SIZE = 36.dp
|
||||
|
||||
/**
|
||||
* Offers the user an optional username so people can reach them without a phone number.
|
||||
*/
|
||||
@Composable
|
||||
fun AddUsernameScreen(
|
||||
state: AddUsernameState,
|
||||
onEvent: (AddUsernameScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val simpleError: Pair<String, AddUsernameScreenEvents>? = when {
|
||||
state.dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to AddUsernameScreenEvents.NetworkErrorDialogDismissed
|
||||
state.dialogs.usernameUnavailable -> stringResource(R.string.AddUsernameScreen__this_username_is_not_available) to AddUsernameScreenEvents.UsernameUnavailableDialogDismissed
|
||||
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to AddUsernameScreenEvents.UnknownErrorDialogDismissed
|
||||
else -> null
|
||||
}
|
||||
|
||||
simpleError?.let { (message, dismissedEvent) ->
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onEvent(dismissedEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TestTags.ADD_USERNAME_SCREEN)
|
||||
) {
|
||||
when (val params = RegistrationScaffold.rememberLayoutParams()) {
|
||||
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(params, state, onEvent)
|
||||
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(params, state, onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnePaneLayout(
|
||||
params: RegistrationScaffold.Params.OnePane,
|
||||
state: AddUsernameState,
|
||||
onEvent: (AddUsernameScreenEvents) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
OnePaneRegistrationScaffold(
|
||||
params = params,
|
||||
content = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Description()
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
UsernameEntry(state = state, onEvent = onEvent)
|
||||
}
|
||||
},
|
||||
footer = { Footer(params, state, scrollState.canScrollForward, onEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TwoPaneLayout(
|
||||
params: RegistrationScaffold.Params.TwoPane,
|
||||
state: AddUsernameState,
|
||||
onEvent: (AddUsernameScreenEvents) -> Unit
|
||||
) {
|
||||
val firstPaneScrollState = rememberScrollState()
|
||||
val secondPaneScrollState = rememberScrollState()
|
||||
|
||||
TwoPaneRegistrationScaffold(
|
||||
params = params,
|
||||
firstPane = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(firstPaneScrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Description(twoPane = true)
|
||||
}
|
||||
},
|
||||
secondPane = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(secondPaneScrollState)
|
||||
.padding(paddingValues),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
UsernameEntry(state = state, onEvent = onEvent)
|
||||
}
|
||||
},
|
||||
footer = { Footer(params, state, firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, onEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Description(twoPane: Boolean = false) {
|
||||
Text(
|
||||
text = stringResource(R.string.AddUsernameScreen__add_an_optional_username),
|
||||
style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.attachDebugLogHelper()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.AddUsernameScreen__people_can_message_you_by_username),
|
||||
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.UsernameEntry(
|
||||
state: AddUsernameState,
|
||||
onEvent: (AddUsernameScreenEvents) -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
UsernameAvatar(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.AddUsernameScreen__choose_your_username),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
TextField(
|
||||
value = state.username,
|
||||
onValueChange = { onEvent(AddUsernameScreenEvents.UsernameChanged(it)) },
|
||||
label = { Text(stringResource(R.string.AddUsernameScreen__username)) },
|
||||
singleLine = true,
|
||||
enabled = !state.showSpinner,
|
||||
isError = state.validationError != null,
|
||||
supportingText = state.validationError?.let { error ->
|
||||
{ Text(stringResource(error.messageId)) }
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (state.isSubmittable) {
|
||||
onEvent(AddUsernameScreenEvents.NextClicked)
|
||||
}
|
||||
}
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(TestTags.ADD_USERNAME_FIELD)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append(stringResource(R.string.AddUsernameScreen__usernames_are_always_paired_with_a_set_of_numbers))
|
||||
append(' ')
|
||||
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(
|
||||
tag = "learn-more",
|
||||
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)),
|
||||
linkInteractionListener = { onEvent(AddUsernameScreenEvents.LearnMoreClicked) }
|
||||
)
|
||||
) {
|
||||
append(stringResource(R.string.AddUsernameScreen__learn_more))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.testTag(TestTags.ADD_USERNAME_LEARN_MORE_LINK)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The designed avatar sphere with the "@" glyph on top. The sphere art is shared, so the glyph is drawn separately
|
||||
* rather than baked into the drawable.
|
||||
*/
|
||||
@Composable
|
||||
private fun UsernameAvatar(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.size(AVATAR_SIZE)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_signal_login_avatar_background),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = SignalIcons.At.painter,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(AVATAR_GLYPH_SIZE)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Footer(
|
||||
params: RegistrationScaffold.Params,
|
||||
state: AddUsernameState,
|
||||
isElevated: Boolean,
|
||||
onEvent: (AddUsernameScreenEvents) -> Unit
|
||||
) {
|
||||
RegistrationScaffold.FooterSurface(isElevated = isElevated) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(params.footerPadding)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onEvent(AddUsernameScreenEvents.SkipClicked) },
|
||||
enabled = !state.showSpinner,
|
||||
modifier = Modifier.testTag(TestTags.ADD_USERNAME_SKIP_BUTTON)
|
||||
) {
|
||||
Text(stringResource(R.string.AddUsernameScreen__skip))
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onEvent(AddUsernameScreenEvents.NextClicked) },
|
||||
enabled = state.isSubmittable,
|
||||
modifier = Modifier.testTag(TestTags.ADD_USERNAME_NEXT_BUTTON)
|
||||
) {
|
||||
if (state.showSpinner) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.AddUsernameScreen__next))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
private val AddUsernameState.ValidationError.messageId: Int
|
||||
get() = when (this) {
|
||||
AddUsernameState.ValidationError.TOO_SHORT -> R.string.AddUsernameScreen__usernames_must_be_at_least_3_characters
|
||||
AddUsernameState.ValidationError.TOO_LONG -> R.string.AddUsernameScreen__usernames_must_be_at_most_32_characters
|
||||
AddUsernameState.ValidationError.INVALID_CHARACTERS -> R.string.AddUsernameScreen__usernames_can_only_contain
|
||||
AddUsernameState.ValidationError.CANNOT_START_WITH_DIGIT -> R.string.AddUsernameScreen__usernames_cannot_begin_with_a_number
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun AddUsernameScreenPreview() {
|
||||
Previews.Preview {
|
||||
AddUsernameScreen(
|
||||
state = AddUsernameState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun AddUsernameScreenFilledPreview() {
|
||||
Previews.Preview {
|
||||
AddUsernameScreen(
|
||||
state = AddUsernameState(username = "alice"),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun AddUsernameScreenErrorPreview() {
|
||||
Previews.Preview {
|
||||
AddUsernameScreen(
|
||||
state = AddUsernameState(
|
||||
username = "al",
|
||||
validationError = AddUsernameState.ValidationError.TOO_SHORT
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.addusername
|
||||
|
||||
import org.signal.core.util.censor
|
||||
|
||||
sealed class AddUsernameScreenEvents {
|
||||
/** The user edited the username field. */
|
||||
data class UsernameChanged(val value: String) : AddUsernameScreenEvents() {
|
||||
override fun toString(): String = "UsernameChanged(value=${value.censor()})"
|
||||
}
|
||||
|
||||
/** The user tapped the "learn more" link under the username field. */
|
||||
data object LearnMoreClicked : AddUsernameScreenEvents()
|
||||
|
||||
/** The user opted out of choosing a username. */
|
||||
data object SkipClicked : AddUsernameScreenEvents()
|
||||
|
||||
/** The user submitted the entered username. */
|
||||
data object NextClicked : AddUsernameScreenEvents()
|
||||
|
||||
/** The user dismissed the network error dialog. */
|
||||
data object NetworkErrorDialogDismissed : AddUsernameScreenEvents()
|
||||
|
||||
/** The user dismissed the unknown error dialog. */
|
||||
data object UnknownErrorDialogDismissed : AddUsernameScreenEvents()
|
||||
|
||||
/** The user dismissed the username-unavailable dialog. */
|
||||
data object UsernameUnavailableDialogDismissed : AddUsernameScreenEvents()
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.addusername
|
||||
|
||||
import org.signal.core.util.censor
|
||||
|
||||
/**
|
||||
* State for the optional username entry screen.
|
||||
*/
|
||||
data class AddUsernameState(
|
||||
val username: String = "",
|
||||
/** Set when the entered nickname fails validation, describing why. */
|
||||
val validationError: ValidationError? = null,
|
||||
val showSpinner: Boolean = false,
|
||||
val dialogs: Dialogs = Dialogs()
|
||||
) {
|
||||
/** Whether the entered nickname is complete enough to submit. */
|
||||
val isSubmittable: Boolean
|
||||
get() = !showSpinner && username.isNotBlank() && validationError == null
|
||||
|
||||
override fun toString(): String = "AddUsernameState(username=${username.censor()}, validationError=$validationError, showSpinner=$showSpinner, dialogs=$dialogs)"
|
||||
|
||||
enum class ValidationError {
|
||||
TOO_SHORT,
|
||||
TOO_LONG,
|
||||
INVALID_CHARACTERS,
|
||||
CANNOT_START_WITH_DIGIT
|
||||
}
|
||||
|
||||
data class Dialogs(
|
||||
val networkError: Boolean = false,
|
||||
val unknownError: Boolean = false,
|
||||
/** The nickname is valid but no discriminator was available for it. */
|
||||
val usernameUnavailable: Boolean = false
|
||||
)
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.addusername
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRepository
|
||||
|
||||
/**
|
||||
* View model for [AddUsernameScreen].
|
||||
*
|
||||
* Username reservation and confirmation endpoints aren't wired into this flow yet, so nothing is validated or
|
||||
* submitted. Every event the screen can produce is routed here and handled explicitly so that filling in the business
|
||||
* logic is a matter of replacing the TODO branches.
|
||||
*/
|
||||
class AddUsernameViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : EventDrivenViewModel<AddUsernameScreenEvents>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(AddUsernameViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(AddUsernameState())
|
||||
val state: StateFlow<AddUsernameState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
_state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: AddUsernameScreenEvents) {
|
||||
applyEvent(_state.value, event, parentEventEmitter) { _state.value = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(
|
||||
state: AddUsernameState,
|
||||
event: AddUsernameScreenEvents,
|
||||
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
stateEmitter: (AddUsernameState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
is AddUsernameScreenEvents.UsernameChanged -> {
|
||||
// TODO [phonenumberless] Validate the nickname and populate AddUsernameState.validationError.
|
||||
stateEmitter(state.copy(username = event.value))
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.LearnMoreClicked -> {
|
||||
// Handled by the navigation layer, which owns URL launching.
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.SkipClicked -> {
|
||||
// TODO [phonenumberless] Advance the flow without reserving a username.
|
||||
Log.i(TAG, "Skip clicked, but the flow isn't implemented yet.")
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.NextClicked -> {
|
||||
// TODO [phonenumberless] Reserve and confirm the username, then advance the flow.
|
||||
Log.i(TAG, "Next clicked, but the flow isn't implemented yet.")
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.NetworkErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(networkError = false)))
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.UnknownErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.UsernameUnavailableDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(usernameUnavailable = false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return AddUsernameViewModel(repository, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -35,6 +35,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
@@ -427,6 +428,18 @@ private fun NextButton(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (state.isPhoneNumberlessRegistrationAvailable) {
|
||||
TextButton(
|
||||
onClick = { onEvent(PhoneNumberEntryScreenEvents.RegisterWithoutNumber) },
|
||||
enabled = !state.showSpinner,
|
||||
modifier = Modifier.testTag(TestTags.PHONE_NUMBER_REGISTER_WITHOUT_NUMBER_BUTTON)
|
||||
) {
|
||||
Text(stringResource(R.string.RegistrationActivity_register_without_number))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onEvent(PhoneNumberEntryScreenEvents.NextClicked) },
|
||||
enabled = !state.showSpinner && state.isNumberPossible,
|
||||
@@ -629,6 +642,17 @@ private fun PhoneNumberScreenPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun PhoneNumberScreenRegisterWithoutNumberPreview() {
|
||||
Previews.Preview {
|
||||
PhoneNumberScreen(
|
||||
state = PhoneNumberEntryState(isPhoneNumberlessRegistrationAvailable = true),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun PhoneNumberScreenSpinnerPreview() {
|
||||
|
||||
+3
@@ -53,6 +53,9 @@ sealed class PhoneNumberEntryScreenEvents {
|
||||
/** The user chose to link this device to an existing account instead of registering a new number. */
|
||||
data object LinkDevice : PhoneNumberEntryScreenEvents()
|
||||
|
||||
/** The user chose to register with a Signal Login instead of a phone number. */
|
||||
data object RegisterWithoutNumber : PhoneNumberEntryScreenEvents()
|
||||
|
||||
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents() {
|
||||
override fun toString(): String = "CaptchaCompleted(token=${token.censor()})"
|
||||
}
|
||||
|
||||
+4
-2
@@ -34,9 +34,11 @@ data class PhoneNumberEntryState(
|
||||
/** Whether the entered number is definitively invalid. A still-too-short number is not considered invalid, since the user may simply be mid-entry. */
|
||||
val isNumberInvalid: Boolean = false,
|
||||
/** Gates whether the link device option is shown in the overflow menu. */
|
||||
val isLinkAndSyncAvailable: Boolean = false
|
||||
val isLinkAndSyncAvailable: Boolean = false,
|
||||
/** Gates whether the "register without number" option is shown in the footer. */
|
||||
val isPhoneNumberlessRegistrationAvailable: Boolean = false
|
||||
) {
|
||||
override fun toString(): String = "PhoneNumberEntryState(regionCode=$regionCode, countryCode=$countryCode, countryName=$countryName, countryEmoji=$countryEmoji, nationalNumber=${nationalNumber.censor()}, formattedNumber=${formattedNumber.censor()}, sessionE164=$sessionE164, sessionMetadata=$sessionMetadata, smsVerificationCodeRequest=$smsVerificationCodeRequest, showSpinner=$showSpinner, dialogs=$dialogs, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, restoredSvrCredentials=${restoredSvrCredentials.size} items, pendingRestoreOption=$pendingRestoreOption, initialized=$initialized, isNumberPossible=$isNumberPossible, isNumberInvalid=$isNumberInvalid, isLinkAndSyncAvailable=$isLinkAndSyncAvailable)"
|
||||
override fun toString(): String = "PhoneNumberEntryState(regionCode=$regionCode, countryCode=$countryCode, countryName=$countryName, countryEmoji=$countryEmoji, nationalNumber=${nationalNumber.censor()}, formattedNumber=${formattedNumber.censor()}, sessionE164=$sessionE164, sessionMetadata=$sessionMetadata, smsVerificationCodeRequest=$smsVerificationCodeRequest, showSpinner=$showSpinner, dialogs=$dialogs, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, restoredSvrCredentials=${restoredSvrCredentials.size} items, pendingRestoreOption=$pendingRestoreOption, initialized=$initialized, isNumberPossible=$isNumberPossible, isNumberInvalid=$isNumberInvalid, isLinkAndSyncAvailable=$isLinkAndSyncAvailable, isPhoneNumberlessRegistrationAvailable=$isPhoneNumberlessRegistrationAvailable)"
|
||||
|
||||
data class Dialogs(
|
||||
/** Asks the user to confirm the number they entered before submitting it. */
|
||||
|
||||
+9
-1
@@ -58,7 +58,12 @@ class PhoneNumberEntryViewModel(
|
||||
private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance()
|
||||
private var formatter: AsYouTypeFormatter = phoneNumberUtil.getAsYouTypeFormatter("US")
|
||||
|
||||
private val _state = MutableStateFlow(PhoneNumberEntryState(isLinkAndSyncAvailable = repository.isLinkAndSyncAvailable))
|
||||
private val _state = MutableStateFlow(
|
||||
PhoneNumberEntryState(
|
||||
isLinkAndSyncAvailable = repository.isLinkAndSyncAvailable,
|
||||
isPhoneNumberlessRegistrationAvailable = repository.isPhoneNumberlessRegistrationAvailable
|
||||
)
|
||||
)
|
||||
val state: StateFlow<PhoneNumberEntryState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
@@ -141,6 +146,9 @@ class PhoneNumberEntryViewModel(
|
||||
is PhoneNumberEntryScreenEvents.LinkDevice -> {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.LinkAccount())
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.RegisterWithoutNumber -> {
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginPayment)
|
||||
}
|
||||
is PhoneNumberEntryScreenEvents.CaptchaCompleted -> {
|
||||
stateEmitter(applyCaptchaCompleted(state, event.token, parentEventEmitter))
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.shared
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.registration.R
|
||||
|
||||
/**
|
||||
* Title-less top app bar holding only a back arrow, matching the header of the registration screens that can be backed
|
||||
* out of.
|
||||
*
|
||||
* @param scrollBehavior Hoisted by the caller so it can attach [scrollBehavior]'s nested-scroll connection to its
|
||||
* scrolling content, which is what lets the bar collapse.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BackTopAppBar(
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
Scaffolds.DefaultTopAppBar(
|
||||
title = "",
|
||||
titleContent = { _, _ -> },
|
||||
onNavigationClick = onBackClick,
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
navigationContentDescription = stringResource(R.string.RegistrationScreen__back),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
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
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.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
|
||||
|
||||
/**
|
||||
* Presents the Signal Login the user just purchased and prompts them to save it, either into the system password
|
||||
* manager or by recording it themselves.
|
||||
*/
|
||||
@Composable
|
||||
fun SignalLoginInfoScreen(
|
||||
state: SignalLoginInfoState,
|
||||
onEvent: (SignalLoginInfoScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (state.dialogs.credentialDetails) {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
title = stringResource(R.string.SignalLoginInfoScreen__your_signal_login),
|
||||
message = stringResource(
|
||||
R.string.SignalLoginInfoScreen__account_s_recovery_s,
|
||||
state.accountIdentifier,
|
||||
state.recoveryKey
|
||||
),
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onEvent(SignalLoginInfoScreenEvents.CredentialDetailsDismissed) }
|
||||
)
|
||||
}
|
||||
|
||||
val simpleError: Pair<String, SignalLoginInfoScreenEvents>? = when {
|
||||
state.dialogs.saveFailed -> stringResource(R.string.SignalLoginInfoScreen__your_signal_login_could_not_be_saved) to SignalLoginInfoScreenEvents.SaveFailedDialogDismissed
|
||||
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to SignalLoginInfoScreenEvents.UnknownErrorDialogDismissed
|
||||
else -> null
|
||||
}
|
||||
|
||||
simpleError?.let { (message, dismissedEvent) ->
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onEvent(dismissedEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_INFO_SCREEN)
|
||||
) {
|
||||
when (val params = RegistrationScaffold.rememberLayoutParams()) {
|
||||
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(params, state, onEvent)
|
||||
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(params, state, onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun OnePaneLayout(
|
||||
params: RegistrationScaffold.Params.OnePane,
|
||||
state: SignalLoginInfoState,
|
||||
onEvent: (SignalLoginInfoScreenEvents) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
|
||||
|
||||
OnePaneRegistrationScaffold(
|
||||
params = params,
|
||||
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginInfoScreenEvents.BackClicked) }) },
|
||||
content = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Header()
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
CredentialCard(
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = { Footer(params, state, scrollState.canScrollForward, onEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TwoPaneLayout(
|
||||
params: RegistrationScaffold.Params.TwoPane,
|
||||
state: SignalLoginInfoState,
|
||||
onEvent: (SignalLoginInfoScreenEvents) -> Unit
|
||||
) {
|
||||
val firstPaneScrollState = rememberScrollState()
|
||||
val secondPaneScrollState = rememberScrollState()
|
||||
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
|
||||
|
||||
TwoPaneRegistrationScaffold(
|
||||
params = params,
|
||||
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginInfoScreenEvents.BackClicked) }) },
|
||||
firstPane = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(firstPaneScrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Header(twoPane = true)
|
||||
}
|
||||
},
|
||||
secondPane = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(secondPaneScrollState)
|
||||
.padding(paddingValues),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CredentialCard(
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = { Footer(params, state, firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, onEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.Header(twoPane: Boolean = false) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_signal_login_key),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.size(width = 84.dp, height = 92.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.SignalLoginInfoScreen__your_signal_login),
|
||||
style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.attachDebugLogHelper()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.SignalLoginInfoScreen__thanks_for_your_purchase),
|
||||
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Footer(
|
||||
params: RegistrationScaffold.Params,
|
||||
state: SignalLoginInfoState,
|
||||
isElevated: Boolean,
|
||||
onEvent: (SignalLoginInfoScreenEvents) -> Unit
|
||||
) {
|
||||
RegistrationScaffold.FooterSurface(isElevated = isElevated) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(params.footerPadding)
|
||||
) {
|
||||
if (state.isPasswordManagerAvailable) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onEvent(SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked) },
|
||||
enabled = !state.showSpinner,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(max = params.maxButtonWidth)
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_TO_PASSWORD_MANAGER_BUTTON)
|
||||
) {
|
||||
if (state.showSpinner) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.SignalLoginInfoScreen__save_to_password_manager))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onEvent(SignalLoginInfoScreenEvents.SaveManuallyClicked) },
|
||||
enabled = !state.showSpinner,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = SignalTheme.colors.colorSurface2,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(max = params.maxButtonWidth)
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_INFO_SAVE_MANUALLY_BUTTON)
|
||||
) {
|
||||
Text(stringResource(R.string.SignalLoginInfoScreen__save_manually))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginInfoScreenPreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginInfoScreen(
|
||||
state = SignalLoginInfoState(
|
||||
accountIdentifier = "H7KQ2B91",
|
||||
recoveryKey = "3TXPVV7T",
|
||||
isPasswordManagerAvailable = true
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginInfoScreenNoPasswordManagerPreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginInfoScreen(
|
||||
state = SignalLoginInfoState(
|
||||
accountIdentifier = "H7KQ2B91",
|
||||
recoveryKey = "3TXPVV7T"
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogininfo
|
||||
|
||||
sealed class SignalLoginInfoScreenEvents {
|
||||
/** Emitted once when the screen is created to load the purchased credentials into the state. */
|
||||
data object Initialize : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user tapped the back arrow. */
|
||||
data object BackClicked : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user tapped "view details" on the credential card to reveal the full values. */
|
||||
data object ViewDetailsClicked : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user dismissed the full-credential details. */
|
||||
data object CredentialDetailsDismissed : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user chose to store the credentials with the system password manager. */
|
||||
data object SaveToPasswordManagerClicked : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user chose to record the credentials themselves rather than using a password manager. */
|
||||
data object SaveManuallyClicked : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user dismissed the failed-save dialog. */
|
||||
data object SaveFailedDialogDismissed : SignalLoginInfoScreenEvents()
|
||||
|
||||
/** The user dismissed the unknown error dialog. */
|
||||
data object UnknownErrorDialogDismissed : SignalLoginInfoScreenEvents()
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogininfo
|
||||
|
||||
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.
|
||||
*/
|
||||
data class SignalLoginInfoState(
|
||||
val accountIdentifier: String = "",
|
||||
val recoveryKey: String = "",
|
||||
val isPasswordManagerAvailable: Boolean = false,
|
||||
val showSpinner: Boolean = false,
|
||||
val dialogs: Dialogs = Dialogs()
|
||||
) {
|
||||
companion object {
|
||||
const val VISIBLE_SUFFIX_LENGTH = 4
|
||||
}
|
||||
|
||||
val accountSuffix: String
|
||||
get() = accountIdentifier.takeLast(VISIBLE_SUFFIX_LENGTH)
|
||||
|
||||
val recoverySuffix: String
|
||||
get() = recoveryKey.takeLast(VISIBLE_SUFFIX_LENGTH)
|
||||
|
||||
override fun toString(): String = "SignalLoginInfoState(accountIdentifier=${accountIdentifier.censor()}, recoveryKey=${recoveryKey.censor()}, " +
|
||||
"isPasswordManagerAvailable=$isPasswordManagerAvailable, showSpinner=$showSpinner, dialogs=$dialogs)"
|
||||
|
||||
data class Dialogs(
|
||||
/** Shows the full, unmasked credentials. */
|
||||
val credentialDetails: Boolean = false,
|
||||
val saveFailed: Boolean = false,
|
||||
val unknownError: Boolean = false
|
||||
)
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signallogininfo
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.screens.util.navigateBack
|
||||
|
||||
/**
|
||||
* View model for [SignalLoginInfoScreen].
|
||||
*
|
||||
* The credentials this screen displays come from a purchase endpoint that doesn't exist yet, so nothing is loaded and
|
||||
* neither save action does anything. Every event the screen can produce is routed here and handled explicitly so that
|
||||
* filling in the business logic is a matter of replacing the TODO branches.
|
||||
*/
|
||||
class SignalLoginInfoViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
isPasswordManagerAvailable: Boolean
|
||||
) : EventDrivenViewModel<SignalLoginInfoScreenEvents>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SignalLoginInfoViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(SignalLoginInfoState(isPasswordManagerAvailable = isPasswordManagerAvailable))
|
||||
val state: StateFlow<SignalLoginInfoState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
_state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
onEvent(SignalLoginInfoScreenEvents.Initialize)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: SignalLoginInfoScreenEvents) {
|
||||
applyEvent(_state.value, event, parentEventEmitter) { _state.value = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(
|
||||
state: SignalLoginInfoState,
|
||||
event: SignalLoginInfoScreenEvents,
|
||||
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
stateEmitter: (SignalLoginInfoState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
is SignalLoginInfoScreenEvents.Initialize -> {
|
||||
// TODO [phonenumberless] Load the purchased account identifier and recovery key.
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.BackClicked -> {
|
||||
parentEventEmitter.navigateBack()
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.ViewDetailsClicked -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(credentialDetails = true)))
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.CredentialDetailsDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(credentialDetails = false)))
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SaveToPasswordManagerClicked -> {
|
||||
// TODO [phonenumberless] Store the credentials via the credential manager, then advance the flow.
|
||||
Log.i(TAG, "Save to password manager clicked, but the flow isn't implemented yet.")
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SaveManuallyClicked -> {
|
||||
// TODO [phonenumberless] Advance to the confirm-you-saved-it step.
|
||||
Log.i(TAG, "Save manually clicked, but the flow isn't implemented yet.")
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.SaveFailedDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(saveFailed = false)))
|
||||
}
|
||||
|
||||
is SignalLoginInfoScreenEvents.UnknownErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
private val isPasswordManagerAvailable: Boolean
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return SignalLoginInfoViewModel(repository, parentEventEmitter, isPasswordManagerAvailable) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signalloginpayment
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.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.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.SignalIcons
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.registration.R
|
||||
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.screens.signalloginpayment.SignalLoginPaymentState.Option
|
||||
import org.signal.registration.test.TestTags
|
||||
|
||||
private val CARD_SHAPE = RoundedCornerShape(18.dp)
|
||||
private val CARD_BORDER_WIDTH = 3.5.dp
|
||||
|
||||
/**
|
||||
* Lets the user buy a Signal Login so they can register without a phone number, or indicate that they already have one.
|
||||
*/
|
||||
@Composable
|
||||
fun SignalLoginPaymentScreen(
|
||||
state: SignalLoginPaymentState,
|
||||
onEvent: (SignalLoginPaymentScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val simpleError: Pair<String, SignalLoginPaymentScreenEvents>? = when {
|
||||
state.dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to SignalLoginPaymentScreenEvents.NetworkErrorDialogDismissed
|
||||
state.dialogs.purchaseFailed -> stringResource(R.string.SignalLoginPaymentScreen__your_purchase_could_not_be_completed) to SignalLoginPaymentScreenEvents.PurchaseFailedDialogDismissed
|
||||
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to SignalLoginPaymentScreenEvents.UnknownErrorDialogDismissed
|
||||
else -> null
|
||||
}
|
||||
|
||||
simpleError?.let { (message, dismissedEvent) ->
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = stringResource(android.R.string.ok),
|
||||
onDismiss = { onEvent(dismissedEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_PAYMENT_SCREEN)
|
||||
) {
|
||||
when (val params = RegistrationScaffold.rememberLayoutParams()) {
|
||||
is RegistrationScaffold.Params.OnePane -> OnePaneLayout(params, state, onEvent)
|
||||
is RegistrationScaffold.Params.TwoPane -> TwoPaneLayout(params, state, onEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun OnePaneLayout(
|
||||
params: RegistrationScaffold.Params.OnePane,
|
||||
state: SignalLoginPaymentState,
|
||||
onEvent: (SignalLoginPaymentScreenEvents) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
|
||||
|
||||
OnePaneRegistrationScaffold(
|
||||
params = params,
|
||||
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginPaymentScreenEvents.BackClicked) }) },
|
||||
content = { paddingValues ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Header(onEvent = onEvent)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
OptionCards(state = state, onEvent = onEvent)
|
||||
}
|
||||
},
|
||||
footer = { Footer(params, state, scrollState.canScrollForward, onEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TwoPaneLayout(
|
||||
params: RegistrationScaffold.Params.TwoPane,
|
||||
state: SignalLoginPaymentState,
|
||||
onEvent: (SignalLoginPaymentScreenEvents) -> Unit
|
||||
) {
|
||||
val firstPaneScrollState = rememberScrollState()
|
||||
val secondPaneScrollState = rememberScrollState()
|
||||
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
|
||||
|
||||
TwoPaneRegistrationScaffold(
|
||||
params = params,
|
||||
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginPaymentScreenEvents.BackClicked) }) },
|
||||
firstPane = { paddingValues ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(firstPaneScrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Header(twoPane = true, onEvent = onEvent)
|
||||
}
|
||||
},
|
||||
secondPane = { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(secondPaneScrollState)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
OptionCards(state = state, onEvent = onEvent)
|
||||
}
|
||||
},
|
||||
footer = { Footer(params, state, firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, onEvent) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(
|
||||
onEvent: (SignalLoginPaymentScreenEvents) -> Unit,
|
||||
twoPane: Boolean = false
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_signal_login_ring),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.SignalLoginPaymentScreen__signal_login),
|
||||
style = if (twoPane) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.attachDebugLogHelper()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append(stringResource(R.string.SignalLoginPaymentScreen__register_without_a_phone_number))
|
||||
append(' ')
|
||||
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(
|
||||
tag = "learn-more",
|
||||
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)),
|
||||
linkInteractionListener = { onEvent(SignalLoginPaymentScreenEvents.LearnMoreClicked) }
|
||||
)
|
||||
) {
|
||||
append(stringResource(R.string.SignalLoginPaymentScreen__learn_more))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_PAYMENT_LEARN_MORE_LINK)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OptionCards(
|
||||
state: SignalLoginPaymentState,
|
||||
onEvent: (SignalLoginPaymentScreenEvents) -> Unit
|
||||
) {
|
||||
OptionCard(
|
||||
title = {
|
||||
if (state.formattedPrice != null) {
|
||||
Text(text = state.formattedPrice, style = MaterialTheme.typography.titleMedium)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
subtitle = stringResource(R.string.SignalLoginPaymentScreen__one_time_purchase),
|
||||
selected = state.selectedOption == Option.Purchase,
|
||||
onClick = { onEvent(SignalLoginPaymentScreenEvents.OptionSelected(Option.Purchase)) },
|
||||
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_PAYMENT_PURCHASE_OPTION)
|
||||
) {
|
||||
FeatureRow(painterResource(R.drawable.symbol_no_phone_44), stringResource(R.string.SignalLoginPaymentScreen__no_phone_number_needed))
|
||||
FeatureRow(SignalIcons.At.painter, stringResource(R.string.SignalLoginPaymentScreen__username_for_messaging_and_calls))
|
||||
FeatureRow(painterResource(R.drawable.symbol_heart_24), stringResource(R.string.SignalLoginPaymentScreen__signal_is_a_non_profit))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OptionCard(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.SignalLoginPaymentScreen__i_have_a_signal_login),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
subtitle = stringResource(R.string.SignalLoginPaymentScreen__use_your_existing_account_key),
|
||||
selected = state.selectedOption == Option.ExistingLogin,
|
||||
onClick = { onEvent(SignalLoginPaymentScreenEvents.OptionSelected(Option.ExistingLogin)) },
|
||||
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_PAYMENT_EXISTING_LOGIN_OPTION)
|
||||
) {
|
||||
FeatureRow(SignalIcons.DevicePhone.painter, stringResource(R.string.SignalLoginPaymentScreen__login_and_restore_your_account))
|
||||
FeatureRow(painterResource(R.drawable.symbol_heart_24), stringResource(R.string.SignalLoginPaymentScreen__thanks_for_supporting_signal))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OptionCard(
|
||||
title: @Composable () -> Unit,
|
||||
subtitle: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
features: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CARD_SHAPE)
|
||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||
.border(
|
||||
width = CARD_BORDER_WIDTH,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||
shape = CARD_SHAPE
|
||||
)
|
||||
.padding(CARD_BORDER_WIDTH)
|
||||
.clip(CARD_SHAPE)
|
||||
.background(SignalTheme.colors.colorSurface2)
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp)
|
||||
) {
|
||||
title()
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
features()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureRow(
|
||||
icon: Painter,
|
||||
text: String
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Footer(
|
||||
params: RegistrationScaffold.Params,
|
||||
state: SignalLoginPaymentState,
|
||||
isElevated: Boolean,
|
||||
onEvent: (SignalLoginPaymentScreenEvents) -> Unit
|
||||
) {
|
||||
RegistrationScaffold.FooterSurface(isElevated = isElevated) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(params.footerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onEvent(SignalLoginPaymentScreenEvents.ContinueClicked) },
|
||||
enabled = state.isActionEnabled,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(max = params.maxButtonWidth)
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON)
|
||||
) {
|
||||
if (state.showSpinner) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = when {
|
||||
state.selectedOption == Option.ExistingLogin -> stringResource(R.string.SignalLoginPaymentScreen__continue)
|
||||
state.formattedPrice != null -> stringResource(R.string.SignalLoginPaymentScreen__pay_s, state.formattedPrice)
|
||||
else -> stringResource(R.string.SignalLoginPaymentScreen__pay)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginPaymentScreenPreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginPaymentScreen(
|
||||
state = SignalLoginPaymentState(formattedPrice = "$1.99"),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginPaymentScreenExistingLoginPreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginPaymentScreen(
|
||||
state = SignalLoginPaymentState(
|
||||
formattedPrice = "$1.99",
|
||||
selectedOption = Option.ExistingLogin
|
||||
),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun SignalLoginPaymentScreenLoadingPricePreview() {
|
||||
Previews.Preview {
|
||||
SignalLoginPaymentScreen(
|
||||
state = SignalLoginPaymentState(),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signalloginpayment
|
||||
|
||||
sealed class SignalLoginPaymentScreenEvents {
|
||||
/** Emitted once when the screen is created to load initial data (namely the purchase price) into the state. */
|
||||
data object Initialize : SignalLoginPaymentScreenEvents()
|
||||
|
||||
/** The user tapped the back arrow. */
|
||||
data object BackClicked : SignalLoginPaymentScreenEvents()
|
||||
|
||||
/** The user tapped the "learn more" link in the description. */
|
||||
data object LearnMoreClicked : SignalLoginPaymentScreenEvents()
|
||||
|
||||
/** The user selected one of the two options. */
|
||||
data class OptionSelected(val option: SignalLoginPaymentState.Option) : SignalLoginPaymentScreenEvents()
|
||||
|
||||
/** The user tapped the primary action button, committing to the currently selected option. */
|
||||
data object ContinueClicked : SignalLoginPaymentScreenEvents()
|
||||
|
||||
/** The user dismissed the network error dialog. */
|
||||
data object NetworkErrorDialogDismissed : SignalLoginPaymentScreenEvents()
|
||||
|
||||
/** The user dismissed the unknown error dialog. */
|
||||
data object UnknownErrorDialogDismissed : SignalLoginPaymentScreenEvents()
|
||||
|
||||
/** The user dismissed the failed-purchase dialog. */
|
||||
data object PurchaseFailedDialogDismissed : SignalLoginPaymentScreenEvents()
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signalloginpayment
|
||||
|
||||
/**
|
||||
* State for the Signal Login purchase screen, where the user either buys a Signal Login or indicates they already have
|
||||
* one.
|
||||
*/
|
||||
data class SignalLoginPaymentState(
|
||||
val selectedOption: Option = Option.Purchase,
|
||||
/**
|
||||
* Localized, currency-formatted price of the one-time purchase, as reported by the billing library. Null until the
|
||||
* price has been loaded.
|
||||
*/
|
||||
val formattedPrice: String? = null,
|
||||
val showSpinner: Boolean = false,
|
||||
val dialogs: Dialogs = Dialogs()
|
||||
) {
|
||||
/** Whether we know enough to let the user act on the selected option. */
|
||||
val isActionEnabled: Boolean
|
||||
get() = !showSpinner && (selectedOption == Option.ExistingLogin || formattedPrice != null)
|
||||
|
||||
enum class Option {
|
||||
/** Buy a new Signal Login. */
|
||||
Purchase,
|
||||
|
||||
/** Register with an account key the user already owns. */
|
||||
ExistingLogin
|
||||
}
|
||||
|
||||
data class Dialogs(
|
||||
val networkError: Boolean = false,
|
||||
val unknownError: Boolean = false,
|
||||
val purchaseFailed: Boolean = false
|
||||
)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.registration.screens.signalloginpayment
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.screens.util.navigateBack
|
||||
|
||||
class SignalLoginPaymentViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : EventDrivenViewModel<SignalLoginPaymentScreenEvents>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SignalLoginPaymentViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(SignalLoginPaymentState())
|
||||
val state: StateFlow<SignalLoginPaymentState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
_state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
onEvent(SignalLoginPaymentScreenEvents.Initialize)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: SignalLoginPaymentScreenEvents) {
|
||||
applyEvent(_state.value, event, parentEventEmitter) { _state.value = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
suspend fun applyEvent(
|
||||
state: SignalLoginPaymentState,
|
||||
event: SignalLoginPaymentScreenEvents,
|
||||
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
stateEmitter: (SignalLoginPaymentState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
is SignalLoginPaymentScreenEvents.Initialize -> {
|
||||
// TODO [phonenumberless] Load the price from the billing library and populate SignalLoginPaymentState.formattedPrice.
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.BackClicked -> {
|
||||
parentEventEmitter.navigateBack()
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.LearnMoreClicked -> {
|
||||
// Handled by the navigation layer, which owns URL launching.
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.OptionSelected -> {
|
||||
stateEmitter(state.copy(selectedOption = event.option))
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.ContinueClicked -> {
|
||||
// TODO [phonenumberless] Launch the purchase flow, or navigate to account key entry for an existing login.
|
||||
Log.i(TAG, "Continue clicked for ${state.selectedOption}, but the flow isn't implemented yet.")
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.NetworkErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(networkError = false)))
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.UnknownErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
|
||||
}
|
||||
|
||||
is SignalLoginPaymentScreenEvents.PurchaseFailedDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(purchaseFailed = false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return SignalLoginPaymentViewModel(repository, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,28 @@ object TestTags {
|
||||
const val PHONE_NUMBER_COUNTRY_CODE_FIELD = "phone_number_country_code_field"
|
||||
const val PHONE_NUMBER_PHONE_FIELD = "phone_number_phone_field"
|
||||
const val PHONE_NUMBER_NEXT_BUTTON = "phone_number_next_button"
|
||||
const val PHONE_NUMBER_REGISTER_WITHOUT_NUMBER_BUTTON = "phone_number_register_without_number_button"
|
||||
|
||||
// Signal Login Payment Screen
|
||||
const val SIGNAL_LOGIN_PAYMENT_SCREEN = "signal_login_payment_screen"
|
||||
const val SIGNAL_LOGIN_PAYMENT_LEARN_MORE_LINK = "signal_login_payment_learn_more_link"
|
||||
const val SIGNAL_LOGIN_PAYMENT_PURCHASE_OPTION = "signal_login_payment_purchase_option"
|
||||
const val SIGNAL_LOGIN_PAYMENT_EXISTING_LOGIN_OPTION = "signal_login_payment_existing_login_option"
|
||||
const val SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON = "signal_login_payment_continue_button"
|
||||
|
||||
// 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"
|
||||
|
||||
// Add Username Screen
|
||||
const val ADD_USERNAME_SCREEN = "add_username_screen"
|
||||
const val ADD_USERNAME_FIELD = "add_username_field"
|
||||
const val ADD_USERNAME_LEARN_MORE_LINK = "add_username_learn_more_link"
|
||||
const val ADD_USERNAME_SKIP_BUTTON = "add_username_skip_button"
|
||||
const val ADD_USERNAME_NEXT_BUTTON = "add_username_next_button"
|
||||
|
||||
// Verification Code Screen
|
||||
const val VERIFICATION_CODE_INPUT = "verification_code_input"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="80dp"
|
||||
android:height="80dp"
|
||||
android:viewportWidth="80"
|
||||
android:viewportHeight="80">
|
||||
<group>
|
||||
<clip-path android:pathData="M40,4h0a36,36 0 0,1 36,36v0a36,36 0 0,1 -36,36h-0a36,36 0 0,1 -36,-36v-0a36,36 0 0,1 36,-36Z" />
|
||||
<path
|
||||
android:pathData="M40,4h0a36,36 0 0,1 36,36v0a36,36 0 0,1 -36,36h-0a36,36 0 0,1 -36,-36v-0a36,36 0 0,1 36,-36Z"
|
||||
android:fillColor="#96A4C1"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M36.4002,6.7h0.9a29.7,29.7 0 0,1 29.7,29.7v0a29.7,29.7 0 0,1 -29.7,29.7h-0.9a29.7,29.7 0 0,1 -29.7,-29.7v-0a29.7,29.7 0 0,1 29.7,-29.7Z"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.1"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M-14.8999,5.8a28.8,28.8 0 1,0 57.6,0a28.8,28.8 0 1,0 -57.6,0Z"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillAlpha="0.2"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M40,4.75h0a35.25,35.25 0 0,1 35.25,35.25v0a35.25,35.25 0 0,1 -35.25,35.25h-0a35.25,35.25 0 0,1 -35.25,-35.25v-0a35.25,35.25 0 0,1 35.25,-35.25Z"
|
||||
android:strokeColor="#4A5775"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</vector>
|
||||
@@ -0,0 +1,39 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:width="363dp"
|
||||
android:height="220dp"
|
||||
android:viewportWidth="363"
|
||||
android:viewportHeight="220"
|
||||
tools:ignore="VectorRaster">
|
||||
<path
|
||||
android:pathData="M26,0h311a26,26 0 0,1 26,26v168a26,26 0 0,1 -26,26h-311a26,26 0 0,1 -26,-26v-168a26,26 0 0,1 26,-26Z"
|
||||
android:fillColor="#343DBF"
|
||||
/>
|
||||
<group android:translateX="-8" android:translateY="-4">
|
||||
<clip-path android:pathData="M34,4h311a26,26 0 0,1 26,26v168a26,26 0 0,1 -26,26h-311a26,26 0 0,1 -26,-26v-168a26,26 0 0,1 26,-26Z" />
|
||||
<path
|
||||
android:pathData="M72.5 79.5C24.5 134.3 -26.1667 126.667 -45.5 116L-82 240L401.5 226.5L429 -81C391 -55.5 341.5 10 266 19C217.221 24.8148 132.5 11 72.5 79.5Z"
|
||||
android:fillColor="#3F47C1"
|
||||
android:fillAlpha="0.5"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M89.5 129.5C41.5 184.3 -9.16666 176.667 -28.5 166L-65 290L418.5 276.5L446 -31C408 -5.5 358.5 60 283 69C234.221 74.8148 149.5 61 89.5 129.5Z"
|
||||
android:fillColor="#8389E5"
|
||||
android:fillAlpha="0.2"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M109.5 188.5C61.5 243.3 10.8333 235.667 -8.5 225L-45 319L438.5 305.5L466 28C428 53.5 378.5 119 303 128C254.221 133.815 169.5 120 109.5 188.5Z"
|
||||
android:fillColor="#858CE4"
|
||||
android:fillAlpha="0.24"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M38.7139 46.2578C35.7061 46.2578 33.6436 44.958 33.042 43.1533C32.9668 42.917 32.9131 42.6592 32.9131 42.4014C32.9131 41.7031 33.3428 41.252 33.998 41.252C34.5459 41.252 34.9004 41.499 35.1367 42.0791C35.6201 43.5293 37.0273 44.2061 38.8213 44.2061C40.7871 44.2061 42.1943 43.1963 42.1943 41.8105C42.1943 40.6074 41.3779 39.8555 39.3047 39.4043L37.6074 39.0498C34.5137 38.3945 33.1064 36.9551 33.1064 34.7207C33.1064 32.0674 35.4375 30.2412 38.7354 30.2412C41.4102 30.2412 43.5156 31.498 44.1172 33.5713C44.1709 33.7217 44.2031 33.9043 44.2031 34.1299C44.2031 34.7529 43.7627 35.1611 43.1396 35.1611C42.5596 35.1611 42.2051 34.8926 41.958 34.334C41.4316 32.9053 40.25 32.293 38.7031 32.293C36.8877 32.293 35.5449 33.1738 35.5449 34.5918C35.5449 35.7197 36.3506 36.4717 38.3594 36.9014L40.0459 37.2559C43.3008 37.9434 44.6328 39.2432 44.6328 41.499C44.6328 44.4209 42.334 46.2578 38.7139 46.2578ZM48.098 32.9912C47.3568 32.9912 46.7553 32.3896 46.7553 31.6592C46.7553 30.918 47.3568 30.3379 48.098 30.3379C48.85 30.3379 49.4516 30.918 49.4516 31.6592C49.4516 32.3896 48.85 32.9912 48.098 32.9912ZM48.098 46.2041C47.3998 46.2041 46.9379 45.7207 46.9379 44.9795V35.7305C46.9379 34.9785 47.3998 34.4951 48.098 34.4951C48.7963 34.4951 49.2582 34.9785 49.2582 35.7305V44.9795C49.2582 45.7207 48.7963 46.2041 48.098 46.2041ZM56.7195 50.2539C54.6678 50.2324 53.0457 49.4375 52.2723 48.2129C52.1219 47.9551 52.0574 47.7188 52.0574 47.4502C52.0574 46.9131 52.4549 46.5264 53.0457 46.5264C53.4002 46.5264 53.6258 46.6445 53.9373 46.9561C54.8611 47.9229 55.6561 48.3418 56.7625 48.3633C58.6531 48.3848 59.8025 47.3105 59.8025 45.7314V43.9375H59.7488C59.115 45.1621 57.783 45.9678 56.1717 45.9678C53.325 45.9678 51.4773 43.7441 51.4773 40.2529C51.4773 36.7188 53.3035 34.5166 56.2254 34.5166C57.826 34.5166 59.0721 35.3223 59.7596 36.6006H59.8025V35.6875C59.8025 34.9355 60.3074 34.4951 60.9734 34.4951C61.6395 34.4951 62.1336 34.9355 62.1336 35.6875V45.6885C62.1336 48.4814 60.0818 50.2861 56.7195 50.2539ZM56.7947 44.0771C58.6102 44.0771 59.8133 42.5947 59.8133 40.2744C59.8133 37.9541 58.6102 36.4287 56.7947 36.4287C55.0115 36.4287 53.8514 37.9111 53.8514 40.2637C53.8514 42.627 55.0115 44.0771 56.7947 44.0771ZM65.9318 46.2041C65.2443 46.2041 64.7717 45.7422 64.7717 44.9795V35.666C64.7717 34.957 65.2014 34.4951 65.8781 34.4951C66.5441 34.4951 67.0061 34.957 67.0061 35.6768V36.5684H67.0598C67.6721 35.2793 68.8537 34.5059 70.5939 34.5059C73.0861 34.5059 74.5148 36.0957 74.5148 38.6846V44.9795C74.5148 45.7422 74.0314 46.2041 73.3439 46.2041C72.6672 46.2041 72.1838 45.7422 72.1838 44.9795V39.1357C72.1838 37.4385 71.3889 36.5039 69.7775 36.5039C68.1447 36.5039 67.092 37.6641 67.092 39.415V44.9795C67.092 45.7422 66.6086 46.2041 65.9318 46.2041ZM80.4186 46.1934C78.2057 46.1934 76.648 44.8184 76.648 42.7881C76.648 40.8115 78.1734 39.5977 80.8482 39.4473L83.9527 39.2646V38.3945C83.9527 37.1377 83.1041 36.3857 81.6861 36.3857C80.5689 36.3857 79.8277 36.7832 79.1832 37.8145C78.9469 38.1582 78.6461 38.3086 78.2379 38.3086C77.6578 38.3086 77.2389 37.9219 77.2389 37.3418C77.2389 37.1055 77.3033 36.8477 77.443 36.5898C78.0338 35.3115 79.7418 34.4951 81.7721 34.4951C84.5113 34.4951 86.2623 35.9453 86.2623 38.2119V45.0332C86.2623 45.7637 85.8004 46.2041 85.1451 46.2041C84.5006 46.2041 84.0602 45.7852 84.0387 45.0977V44.1416H83.985C83.3297 45.3984 81.8902 46.1934 80.4186 46.1934ZM81.0523 44.3564C82.6744 44.3564 83.9527 43.2393 83.9527 41.7676V40.876L81.1598 41.0479C79.774 41.1445 78.9898 41.7568 78.9898 42.7236C78.9898 43.7119 79.817 44.3564 81.0523 44.3564ZM90.1143 46.2041C89.4375 46.2041 88.9541 45.7422 88.9541 44.9795V31.5195C88.9541 30.7568 89.4375 30.2949 90.1143 30.2949C90.791 30.2949 91.2744 30.7568 91.2744 31.5195V44.9795C91.2744 45.7422 90.791 46.2041 90.1143 46.2041Z"
|
||||
android:fillColor="#FFFFFF"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M26,1h311a25,25 0 0,1 25,25v168a25,25 0 0,1 -25,25h-311a25,25 0 0,1 -25,-25v-168a25,25 0 0,1 25,-25Z"
|
||||
android:strokeColor="#333BA8"
|
||||
android:strokeWidth="2"
|
||||
/>
|
||||
</vector>
|
||||
@@ -0,0 +1,104 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="84dp"
|
||||
android:height="92dp"
|
||||
android:viewportWidth="84"
|
||||
android:viewportHeight="92">
|
||||
<path
|
||||
android:pathData="M58.7386 77.8406L52.0618 84.5174C50.6324 85.9465 48.3313 85.9467 46.9221 84.5374L37.4162 75.0316C36.0071 73.6223 36.0079 71.322 37.437 69.8926L43.2209 64.1087L37.4687 58.3565C36.0594 56.9472 36.0601 54.6456 37.4894 53.2162L41.0996 49.6061V49.1918L41.1002 44.8973L41.1009 44.2586L40.5216 43.99C38.2489 42.9335 36.1219 41.4686 34.2515 39.5982C25.6262 30.9725 25.6646 16.9343 34.3565 8.24242C41.3286 1.27036 51.7388 -0.131794 60.0347 4.03292C62.0856 5.06258 64.0064 6.43166 65.7123 8.13745C67.6332 10.0584 69.1274 12.2516 70.1904 14.596C73.8896 22.755 72.3633 32.7385 65.608 39.4939C64.0343 41.0676 62.2828 42.3596 60.4186 43.3671L59.8959 43.6495L59.8938 44.2441L59.8137 75.2615C59.8112 76.1658 59.4714 77.0378 58.8635 77.7088L58.7386 77.8406Z"
|
||||
android:fillColor="#ABAFF8"
|
||||
android:strokeColor="#61739A"
|
||||
android:strokeWidth="2"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M59.3836 78.5787C60.2521 77.7102 60.7397 76.5321 60.739 75.3038L60.7205 44.3334C62.6627 43.2881 64.4852 41.9466 66.1204 40.3114C73.1449 33.2869 74.7044 22.8754 70.816 14.3471C69.698 11.8951 68.1311 9.60081 66.1204 7.59007C64.3348 5.8045 62.3253 4.36856 60.1797 3.28625C51.5061 -1.08894 40.6488 0.340326 33.399 7.59007C24.3633 16.6258 24.3633 31.2757 33.399 40.3114C35.3569 42.2693 37.5826 43.8054 39.9598 44.9164L39.9728 49.205L36.6707 52.5071C34.8636 54.3142 34.8636 57.2442 36.6707 59.0513L41.7243 64.1049L36.6716 69.1576C34.8645 70.9647 34.8645 73.8947 36.6716 75.7018L46.1939 85.2241C48.0011 87.0313 50.9311 87.0313 52.7382 85.2241L59.3836 78.5787Z" />
|
||||
<path
|
||||
android:pathData="M68.9027 78.5788C69.7712 77.7103 70.2588 76.5321 70.258 75.3039L70.2395 44.3334C72.1817 43.2881 74.0042 41.9467 75.6394 40.3115C82.6639 33.287 84.2235 22.8755 80.335 14.3471C79.2171 11.8951 77.6502 9.60086 75.6394 7.59012C73.8539 5.80454 71.8443 4.3686 69.6987 3.2863C61.0251 -1.0889 50.1678 0.340372 42.9181 7.59011C33.8823 16.6259 33.8823 31.2757 42.9181 40.3115C44.8759 42.2693 47.1016 43.8054 49.4788 44.9164L49.4919 49.205L46.1898 52.5071C44.3826 54.3142 44.3826 57.2442 46.1898 59.0514L51.2434 64.1049L46.1907 69.1576C44.3835 70.9648 44.3835 73.8947 46.1907 75.7019L55.713 85.2242C57.5201 87.0313 60.4501 87.0313 62.2573 85.2242L68.9027 78.5788Z"
|
||||
android:fillColor="#C1C4FA"
|
||||
android:fillType="evenOdd"
|
||||
/>
|
||||
<group android:rotation="135" android:pivotX="76.6625" android:pivotY="18.3801">
|
||||
<path
|
||||
android:pathData="M54.5467,18.3801a22.1158,22.1158 0 1,0 44.2316,0a22.1158,22.1158 0 1,0 -44.2316,0Z"
|
||||
android:fillColor="#CED1FB"
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M58.7386 77.8406L52.0618 84.5174C50.6324 85.9465 48.3313 85.9467 46.9221 84.5374L37.4162 75.0316C36.0071 73.6223 36.0079 71.322 37.437 69.8926L43.2209 64.1087L37.4687 58.3565C36.0594 56.9472 36.0601 54.6456 37.4894 53.2162L41.0996 49.6061V49.1918L41.1002 44.8973L41.1009 44.2586L40.5216 43.99C38.2489 42.9335 36.1219 41.4686 34.2515 39.5982C25.6262 30.9725 25.6646 16.9343 34.3565 8.24242C41.3286 1.27036 51.7388 -0.131794 60.0347 4.03292C62.0856 5.06258 64.0064 6.43166 65.7123 8.13745C67.6332 10.0584 69.1274 12.2516 70.1904 14.596C73.8896 22.755 72.3633 32.7385 65.608 39.4939C64.0343 41.0676 62.2828 42.3596 60.4186 43.3671L59.8959 43.6495L59.8938 44.2441L59.8137 75.2615C59.8112 76.1658 59.4714 77.0378 58.8635 77.7088L58.7386 77.8406Z"
|
||||
android:strokeColor="#4046A9"
|
||||
android:strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M52.7071 25.7867C50.6337 27.8598 47.0695 27.8099 44.7432 25.4835C42.417 23.1572 42.3676 19.5937 44.4407 17.5203C46.5141 15.447 50.0782 15.4958 52.4046 17.8221C54.7311 20.1485 54.7805 23.7133 52.7071 25.7867Z"
|
||||
android:fillColor="#4C6EB8"
|
||||
android:strokeColor="#4046A9"
|
||||
android:strokeWidth="2"
|
||||
/>
|
||||
<group android:rotation="135" android:pivotX="48.3938" android:pivotY="21.8486">
|
||||
<path
|
||||
android:pathData="M42.5484,21.8486a5.8454,5.8454 0 1,0 11.6909,0a5.8454,5.8454 0 1,0 -11.6909,0Z"
|
||||
android:fillColor="#EFEFF0"
|
||||
android:strokeColor="#4046A9"
|
||||
android:strokeWidth="2"
|
||||
/>
|
||||
</group>
|
||||
<group android:rotation="90" android:pivotX="54.7119" android:pivotY="46.1505">
|
||||
<path
|
||||
android:pathData="M55.8541,46.1505h27.485a1.1422,1.1422 0 0,1 1.1422,1.1422v0a1.1422,1.1422 0 0,1 -1.1422,1.1422h-27.485a1.1422,1.1422 0 0,1 -1.1422,-1.1422v-0a1.1422,1.1422 0 0,1 1.1422,-1.1422Z"
|
||||
android:fillColor="#8A8FE2"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M37.7894 85.2245C35.9737 87.0399 33.0395 87.0485 31.2355 85.2445L21.7296 75.7387C19.9257 73.9347 19.935 71.0011 21.7504 69.1855L26.8272 64.1087L21.7821 59.0636C19.978 57.2596 19.9872 54.3247 21.8028 52.5091L25.1202 49.1918L25.1209 44.8973C22.7436 43.7923 20.5195 42.2598 18.5649 40.3053C9.54481 31.2848 9.59178 16.6134 18.6699 7.53531C25.9536 0.251636 36.8315 -1.21452 45.504 3.13937C47.6493 4.21638 49.6574 5.64795 51.4399 7.43035C53.4472 9.43769 55.0092 11.7309 56.121 14.1831C59.9879 22.7117 58.393 33.1435 51.3356 40.201C49.6928 41.8438 47.8633 43.1931 45.9149 44.2462L45.8341 75.2629C45.8309 76.493 45.3388 77.6752 44.4662 78.5477L37.7894 85.2245Z"
|
||||
android:fillColor="#C0CBE2"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M37.7894 85.2245C35.9737 87.0399 33.0395 87.0485 31.2355 85.2445L21.7296 75.7387C19.9257 73.9347 19.935 71.0011 21.7504 69.1855L26.8272 64.1087L21.7821 59.0636C19.978 57.2596 19.9872 54.3247 21.8028 52.5091L25.1202 49.1918L25.1209 44.8973C22.7436 43.7923 20.5195 42.2598 18.5649 40.3053C9.54481 31.2848 9.59178 16.6134 18.6699 7.53531C25.9536 0.251636 36.8315 -1.21452 45.504 3.13937C47.6493 4.21638 49.6574 5.64795 51.4399 7.43035C53.4472 9.43769 55.0092 11.7309 56.121 14.1831C59.9879 22.7117 58.393 33.1435 51.3356 40.201C49.6928 41.8438 47.8633 43.1931 45.9149 44.2462L45.8341 75.2629C45.8309 76.493 45.3388 77.6752 44.4662 78.5477L37.7894 85.2245Z"
|
||||
android:strokeColor="#61739A"
|
||||
android:strokeWidth="1"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M44.4041 78.5787C45.2726 77.7102 45.7602 76.5321 45.7595 75.3038L45.741 44.3334C47.6832 43.2881 49.5057 41.9466 51.1409 40.3114C58.1654 33.2869 59.7249 22.8754 55.8365 14.3471C54.7185 11.8951 53.1516 9.60081 51.1409 7.59007C49.3553 5.8045 47.3458 4.36856 45.2002 3.28625C36.5266 -1.08894 25.6693 0.340326 18.4195 7.59007C9.3838 16.6258 9.3838 31.2757 18.4196 40.3114C20.3774 42.2693 22.6031 43.8054 24.9803 44.9164L24.9933 49.205L21.6913 52.5071C19.8841 54.3142 19.8841 57.2442 21.6913 59.0513L26.7448 64.1049L21.6922 69.1576C19.885 70.9647 19.885 73.8947 21.6922 75.7018L31.2145 85.2241C33.0216 87.0313 35.9516 87.0313 37.7587 85.2241L44.4041 78.5787Z" />
|
||||
<path
|
||||
android:pathData="M53.9232 78.5788C54.7917 77.7103 55.2793 76.5321 55.2785 75.3039L55.26 44.3334C57.2022 43.2881 59.0248 41.9467 60.6599 40.3115C67.6844 33.287 69.244 22.8755 65.3555 14.3471C64.2376 11.8951 62.6707 9.60086 60.6599 7.59012C58.8744 5.80454 56.8648 4.3686 54.7192 3.2863C46.0456 -1.0889 35.1883 0.340372 27.9386 7.59011C18.9028 16.6259 18.9028 31.2757 27.9386 40.3115C29.8964 42.2693 32.1221 43.8054 34.4993 44.9164L34.5124 49.205L31.2103 52.5071C29.4031 54.3142 29.4031 57.2442 31.2103 59.0514L36.2639 64.1049L31.2112 69.1576C29.404 70.9648 29.4041 73.8947 31.2112 75.7019L40.7335 85.2242C42.5406 87.0313 45.4706 87.0313 47.2778 85.2242L53.9232 78.5788Z"
|
||||
android:fillColor="#D9E0EE"
|
||||
android:fillType="evenOdd"
|
||||
/>
|
||||
<group android:rotation="135" android:pivotX="61.683" android:pivotY="18.3801">
|
||||
<path
|
||||
android:pathData="M39.5672,18.3801a22.1158,22.1158 0 1,0 44.2316,0a22.1158,22.1158 0 1,0 -44.2316,0Z"
|
||||
android:fillColor="#EAEEF6"
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M43.7591 77.8406L37.0823 84.5174C35.6529 85.9465 33.3519 85.9467 31.9426 84.5374L22.4368 75.0316C21.0276 73.6223 21.0284 71.322 22.4575 69.8926L28.2414 64.1087L22.4892 58.3565C21.0799 56.9472 21.0806 54.6456 22.5099 53.2162L26.1201 49.6061V49.1918L26.1208 44.8973L26.1214 44.2586L25.5421 43.99C23.2694 42.9335 21.1424 41.4686 19.272 39.5982C10.6467 30.9725 10.6851 16.9343 19.377 8.24242C26.3491 1.27036 36.7593 -0.131794 45.0552 4.03292C47.1061 5.06258 49.0269 6.43166 50.7328 8.13745C52.6537 10.0584 54.1479 12.2516 55.2109 14.596C58.9101 22.755 57.3838 32.7385 50.6285 39.4939C49.0548 41.0676 47.3033 42.3596 45.4391 43.3671L44.9164 43.6495L44.9143 44.2441L44.8342 75.2615C44.8317 76.1658 44.4919 77.0378 43.884 77.7088L43.7591 77.8406Z"
|
||||
android:strokeColor="#4A5775"
|
||||
android:strokeWidth="2"
|
||||
/>
|
||||
<group android:rotation="135" android:pivotX="34.6809" android:pivotY="21.8489">
|
||||
<path
|
||||
android:pathData="M28.8355,21.8489a5.8454,5.8454 0 1,0 11.6909,0a5.8454,5.8454 0 1,0 -11.6909,0Z"
|
||||
android:fillColor="#EFEFF0"
|
||||
android:strokeColor="#4A5775"
|
||||
android:strokeWidth="2"
|
||||
/>
|
||||
</group>
|
||||
<group android:rotation="90" android:pivotX="39.7324" android:pivotY="46.1505">
|
||||
<path
|
||||
android:pathData="M40.8746,46.1505h27.485a1.1422,1.1422 0 0,1 1.1422,1.1422v0a1.1422,1.1422 0 0,1 -1.1422,1.1422h-27.485a1.1422,1.1422 0 0,1 -1.1422,-1.1422v-0a1.1422,1.1422 0 0,1 1.1422,-1.1422Z"
|
||||
android:fillColor="#808EAC"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M42.25,75a14.75,14.75 0 1,0 29.5,0a14.75,14.75 0 1,0 -29.5,0Z"
|
||||
android:fillColor="#A3B881"
|
||||
android:strokeColor="#618427"
|
||||
android:strokeWidth="2.5"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M63.3236 68.6363C63.9042 68.9992 64.0807 69.7639 63.7178 70.3445L56.6345 81.6778C56.4215 82.0186 56.0565 82.235 55.6553 82.2583C55.2541 82.2817 54.8664 82.109 54.6154 81.7952L50.3654 76.4827C49.9377 75.9481 50.0244 75.168 50.559 74.7404C51.0936 74.3127 51.8736 74.3994 52.3013 74.934L55.4589 78.881L61.6155 69.0305C61.9783 68.45 62.7431 68.2735 63.3236 68.6363Z"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
/>
|
||||
</vector>
|
||||
@@ -0,0 +1,166 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="64dp"
|
||||
android:height="64dp"
|
||||
android:viewportWidth="64"
|
||||
android:viewportHeight="64">
|
||||
<path
|
||||
android:pathData="M25.7392,6.9565a6.9565,6.9565 0 1,0 13.913,0a6.9565,6.9565 0 1,0 -13.913,0Z"
|
||||
android:fillColor="#C0CBE2"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M25.7392,6.9565a6.9565,6.9565 0 1,0 13.913,0a6.9565,6.9565 0 1,0 -13.913,0Z" />
|
||||
<path
|
||||
android:pathData="M25.0435,5.2174a5.913,5.913 0 1,0 11.8261,0a5.913,5.913 0 1,0 -11.8261,0Z"
|
||||
android:fillColor="#D9E0EE"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M25.0434,2.7826a3.4783,3.4783 0 1,0 6.9565,0a3.4783,3.4783 0 1,0 -6.9565,0Z"
|
||||
android:fillColor="#EAEEF6"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M26.4892,6.9565a6.2065,6.2065 0 1,0 12.413,0a6.2065,6.2065 0 1,0 -12.413,0Z"
|
||||
android:strokeColor="#4A5775"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M25.7392,57.0434a6.9565,6.9565 0 1,0 13.913,0a6.9565,6.9565 0 1,0 -13.913,0Z"
|
||||
android:fillColor="#C0CBE2"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M25.7392,57.0434a6.9565,6.9565 0 1,0 13.913,0a6.9565,6.9565 0 1,0 -13.913,0Z" />
|
||||
<path
|
||||
android:pathData="M25.0435,55.3043a5.913,5.913 0 1,0 11.8261,0a5.913,5.913 0 1,0 -11.8261,0Z"
|
||||
android:fillColor="#D9E0EE"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M25.0434,52.8695a3.4783,3.4783 0 1,0 6.9565,0a3.4783,3.4783 0 1,0 -6.9565,0Z"
|
||||
android:fillColor="#EAEEF6"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M26.4892,57.0434a6.2065,6.2065 0 1,0 12.413,0a6.2065,6.2065 0 1,0 -12.413,0Z"
|
||||
android:strokeColor="#4A5775"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M0,32.6957a6.9565,6.9565 0 1,0 13.913,0a6.9565,6.9565 0 1,0 -13.913,0Z"
|
||||
android:fillColor="#C0CBE2"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M0,32.6957a6.9565,6.9565 0 1,0 13.913,0a6.9565,6.9565 0 1,0 -13.913,0Z" />
|
||||
<path
|
||||
android:pathData="M-0.6957,30.9565a5.913,5.913 0 1,0 11.8261,0a5.913,5.913 0 1,0 -11.8261,0Z"
|
||||
android:fillColor="#D9E0EE"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M-0.6957,28.5217a3.4783,3.4783 0 1,0 6.9565,0a3.4783,3.4783 0 1,0 -6.9565,0Z"
|
||||
android:fillColor="#EAEEF6"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M0.75,32.6957a6.2065,6.2065 0 1,0 12.413,0a6.2065,6.2065 0 1,0 -12.413,0Z"
|
||||
android:strokeColor="#4A5775"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M9.0435,14.6087a5.5652,5.5652 0 1,0 11.1304,0a5.5652,5.5652 0 1,0 -11.1304,0Z"
|
||||
android:fillColor="#ABAFF8"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M9.0435,14.6087a5.5652,5.5652 0 1,0 11.1304,0a5.5652,5.5652 0 1,0 -11.1304,0Z" />
|
||||
<path
|
||||
android:pathData="M8.487,13.2174a4.7304,4.7304 0 1,0 9.4609,0a4.7304,4.7304 0 1,0 -9.4609,0Z"
|
||||
android:fillColor="#C1C4FA"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M8.4869,11.2695a2.7826,2.7826 0 1,0 5.5652,0a2.7826,2.7826 0 1,0 -5.5652,0Z"
|
||||
android:fillColor="#CED1FB"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M9.7935,14.6087a4.8152,4.8152 0 1,0 9.6304,0a4.8152,4.8152 0 1,0 -9.6304,0Z"
|
||||
android:strokeColor="#4046A9"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M9.0435,49.3913a5.5652,5.5652 0 1,0 11.1304,0a5.5652,5.5652 0 1,0 -11.1304,0Z"
|
||||
android:fillColor="#ABAFF8"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M9.0435,49.3913a5.5652,5.5652 0 1,0 11.1304,0a5.5652,5.5652 0 1,0 -11.1304,0Z" />
|
||||
<path
|
||||
android:pathData="M8.487,48a4.7304,4.7304 0 1,0 9.4609,0a4.7304,4.7304 0 1,0 -9.4609,0Z"
|
||||
android:fillColor="#C1C4FA"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M8.4869,46.0521a2.7826,2.7826 0 1,0 5.5652,0a2.7826,2.7826 0 1,0 -5.5652,0Z"
|
||||
android:fillColor="#CED1FB"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M9.7935,49.3913a4.8152,4.8152 0 1,0 9.6304,0a4.8152,4.8152 0 1,0 -9.6304,0Z"
|
||||
android:strokeColor="#4046A9"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M45.2174,14.6087a5.5652,5.5652 0 1,0 11.1304,0a5.5652,5.5652 0 1,0 -11.1304,0Z"
|
||||
android:fillColor="#ABAFF8"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M45.2174,14.6087a5.5652,5.5652 0 1,0 11.1304,0a5.5652,5.5652 0 1,0 -11.1304,0Z" />
|
||||
<path
|
||||
android:pathData="M44.6609,13.2174a4.7304,4.7304 0 1,0 9.4609,0a4.7304,4.7304 0 1,0 -9.4609,0Z"
|
||||
android:fillColor="#C1C4FA"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M44.6609,11.2695a2.7826,2.7826 0 1,0 5.5652,0a2.7826,2.7826 0 1,0 -5.5652,0Z"
|
||||
android:fillColor="#CED1FB"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M45.9674,14.6087a4.8152,4.8152 0 1,0 9.6304,0a4.8152,4.8152 0 1,0 -9.6304,0Z"
|
||||
android:strokeColor="#4046A9"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M45.2174,49.3913a5.5652,5.5652 0 1,0 11.1304,0a5.5652,5.5652 0 1,0 -11.1304,0Z"
|
||||
android:fillColor="#ABAFF8"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M45.2174,49.3913a5.5652,5.5652 0 1,0 11.1304,0a5.5652,5.5652 0 1,0 -11.1304,0Z" />
|
||||
<path
|
||||
android:pathData="M44.6609,48a4.7304,4.7304 0 1,0 9.4609,0a4.7304,4.7304 0 1,0 -9.4609,0Z"
|
||||
android:fillColor="#C1C4FA"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M44.6609,46.0521a2.7826,2.7826 0 1,0 5.5652,0a2.7826,2.7826 0 1,0 -5.5652,0Z"
|
||||
android:fillColor="#CED1FB"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M45.9674,49.3913a4.8152,4.8152 0 1,0 9.6304,0a4.8152,4.8152 0 1,0 -9.6304,0Z"
|
||||
android:strokeColor="#4046A9"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M50.0869,32.6957a6.9565,6.9565 0 1,0 13.913,0a6.9565,6.9565 0 1,0 -13.913,0Z"
|
||||
android:fillColor="#C0CBE2"
|
||||
/>
|
||||
<group>
|
||||
<clip-path android:pathData="M50.0869,32.6957a6.9565,6.9565 0 1,0 13.913,0a6.9565,6.9565 0 1,0 -13.913,0Z" />
|
||||
<path
|
||||
android:pathData="M49.3913,30.9565a5.913,5.913 0 1,0 11.8261,0a5.913,5.913 0 1,0 -11.8261,0Z"
|
||||
android:fillColor="#D9E0EE"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M49.3912,28.5217a3.4783,3.4783 0 1,0 6.9565,0a3.4783,3.4783 0 1,0 -6.9565,0Z"
|
||||
android:fillColor="#EAEEF6"
|
||||
/>
|
||||
<path
|
||||
android:pathData="M50.8369,32.6957a6.2065,6.2065 0 1,0 12.413,0a6.2065,6.2065 0 1,0 -12.413,0Z"
|
||||
android:strokeColor="#4A5775"
|
||||
android:strokeWidth="1.5"
|
||||
/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M2.886,4.053c1.12,-1.249 2.672,-1.928 4.364,-1.928 1.929,0 3.659,1.066 4.75,2.606 1.091,-1.54 2.821,-2.606 4.75,-2.606 1.692,0 3.244,0.679 4.364,1.928 1.117,1.245 1.761,3.005 1.761,5.11 0,2.321 -1.254,5.03 -3.12,7.453 -1.882,2.446 -4.481,4.725 -7.364,6.167a0.875,0.875 0,0 1,-0.782 0c-2.883,-1.442 -5.482,-3.72 -7.365,-6.167 -1.865,-2.422 -3.119,-5.132 -3.119,-7.454 0,-2.104 0.644,-3.864 1.761,-5.11ZM4.19,5.22c-0.783,0.873 -1.314,2.194 -1.314,3.941 0,1.766 0.996,4.1 2.756,6.387C7.286,17.7 9.535,19.7 12,21.015c2.465,-1.317 4.714,-3.316 6.37,-5.466 1.76,-2.287 2.755,-4.621 2.755,-6.387 0,-1.747 -0.53,-3.068 -1.314,-3.94 -0.78,-0.87 -1.854,-1.347 -3.061,-1.347 -1.666,0 -3.28,1.246 -3.928,3.02a0.875,0.875 0,0 1,-1.644 0c-0.648,-1.774 -2.262,-3.02 -3.928,-3.02 -1.207,0 -2.28,0.476 -3.061,1.346Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -51,6 +51,8 @@
|
||||
<!-- Dialog body shown when the entered phone number is rejected as invalid -->
|
||||
<string name="RegistrationActivity_the_number_you_entered_is_not_valid">The number you entered doesn\'t appear to be valid. Please check the country code and phone number and try again.</string>
|
||||
<string name="RegistrationActivity_next">Next</string>
|
||||
<!-- Button on the phone number screen that starts registering with a purchased Signal Login instead of a phone number -->
|
||||
<string name="RegistrationActivity_register_without_number">Register without number</string>
|
||||
<!-- Dialog title to confirm phone number -->
|
||||
<string name="RegistrationActivity_is_the_phone_number">Is the phone number below correct?</string>
|
||||
<!-- Dialog body to confirm phone number -->
|
||||
@@ -600,6 +602,90 @@
|
||||
<!-- Button to cancel out of the captcha screen. -->
|
||||
<string name="CaptchaScreen__cancel">Cancel</string>
|
||||
|
||||
<!-- Shared registration strings -->
|
||||
<!-- Content description for the back arrow in a registration screen header. -->
|
||||
<string name="RegistrationScreen__back">Back</string>
|
||||
|
||||
<!-- Signal Login purchase screen -->
|
||||
<!-- Title of the screen where the user buys a Signal Login to register without a phone number. -->
|
||||
<string name="SignalLoginPaymentScreen__signal_login">Signal Login</string>
|
||||
<!-- Description of what a Signal Login is. Followed by a "learn more" link. -->
|
||||
<string name="SignalLoginPaymentScreen__register_without_a_phone_number">Register without a phone number using Signal Login. With a one-time purchase, you\'ll receive an account key to get started.</string>
|
||||
<!-- Link at the end of the description that opens a support article about Signal Login. -->
|
||||
<string name="SignalLoginPaymentScreen__learn_more">Learn more</string>
|
||||
<!-- Subtitle of the purchase option, clarifying the price is not a subscription. -->
|
||||
<string name="SignalLoginPaymentScreen__one_time_purchase">One-time purchase</string>
|
||||
<!-- Selling point of the purchase option: no phone number is required. -->
|
||||
<string name="SignalLoginPaymentScreen__no_phone_number_needed">No phone number needed</string>
|
||||
<!-- Selling point of the purchase option: a username is used to reach the user. -->
|
||||
<string name="SignalLoginPaymentScreen__username_for_messaging_and_calls">Username for messaging & calls</string>
|
||||
<!-- Selling point of the purchase option: the purchase supports Signal. -->
|
||||
<string name="SignalLoginPaymentScreen__signal_is_a_non_profit">Signal is a non-profit — your purchase helps support our mission</string>
|
||||
<!-- Title of the option for users who already bought a Signal Login. -->
|
||||
<string name="SignalLoginPaymentScreen__i_have_a_signal_login">I have a Signal Login</string>
|
||||
<!-- Subtitle of the existing-login option. -->
|
||||
<string name="SignalLoginPaymentScreen__use_your_existing_account_key">Use your existing account key</string>
|
||||
<!-- Selling point of the existing-login option: the account can be restored. -->
|
||||
<string name="SignalLoginPaymentScreen__login_and_restore_your_account">Login and restore your account</string>
|
||||
<!-- Selling point of the existing-login option, thanking the user for their previous purchase. -->
|
||||
<string name="SignalLoginPaymentScreen__thanks_for_supporting_signal">Thanks for supporting Signal</string>
|
||||
<!-- Action button that starts the purchase. Placeholder is the localized price, e.g. "$1.99". -->
|
||||
<string name="SignalLoginPaymentScreen__pay_s">Pay %1$s</string>
|
||||
<!-- Action button that starts the purchase, shown while the price is still loading. -->
|
||||
<string name="SignalLoginPaymentScreen__pay">Pay</string>
|
||||
<!-- Action button shown when the user indicated they already have a Signal Login. -->
|
||||
<string name="SignalLoginPaymentScreen__continue">Continue</string>
|
||||
<!-- Error shown when the purchase does not go through. -->
|
||||
<string name="SignalLoginPaymentScreen__your_purchase_could_not_be_completed">Your purchase could not be completed. You have not been charged.</string>
|
||||
|
||||
<!-- Signal Login details screen -->
|
||||
<!-- Title of the screen that shows the user their newly purchased Signal Login. -->
|
||||
<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. -->
|
||||
<string name="SignalLoginInfoScreen__save_to_password_manager">Save to password manager</string>
|
||||
<!-- Action button for users who would rather write the Signal Login down themselves. -->
|
||||
<string name="SignalLoginInfoScreen__save_manually">Save manually</string>
|
||||
<!-- Error shown when the Signal Login could not be stored in the password manager. -->
|
||||
<string name="SignalLoginInfoScreen__your_signal_login_could_not_be_saved">Your Signal Login could not be saved. Please save it manually instead.</string>
|
||||
|
||||
<!-- Add username screen -->
|
||||
<!-- Title of the screen offering the user an optional username. -->
|
||||
<string name="AddUsernameScreen__add_an_optional_username">Add an optional username</string>
|
||||
<!-- Description explaining what a username is used for. -->
|
||||
<string name="AddUsernameScreen__people_can_message_you_by_username">People can message you by username instead of a phone number.</string>
|
||||
<!-- Prompt shown above the username field. -->
|
||||
<string name="AddUsernameScreen__choose_your_username">Choose your username</string>
|
||||
<!-- Label/hint for the username field. -->
|
||||
<string name="AddUsernameScreen__username">Username</string>
|
||||
<!-- Helper text below the username field. Followed by a "learn more" link. -->
|
||||
<string name="AddUsernameScreen__usernames_are_always_paired_with_a_set_of_numbers">Usernames are always paired with a set of numbers.</string>
|
||||
<!-- Link in the helper text that opens a support article about usernames. -->
|
||||
<string name="AddUsernameScreen__learn_more">Learn more</string>
|
||||
<!-- Button that skips choosing a username. -->
|
||||
<string name="AddUsernameScreen__skip">Skip</string>
|
||||
<!-- Button that submits the chosen username. -->
|
||||
<string name="AddUsernameScreen__next">Next</string>
|
||||
<!-- Validation error shown when the entered username is too short. -->
|
||||
<string name="AddUsernameScreen__usernames_must_be_at_least_3_characters">Usernames must be at least 3 characters</string>
|
||||
<!-- Validation error shown when the entered username is too long. -->
|
||||
<string name="AddUsernameScreen__usernames_must_be_at_most_32_characters">Usernames must be at most 32 characters</string>
|
||||
<!-- Validation error shown when the entered username contains disallowed characters. -->
|
||||
<string name="AddUsernameScreen__usernames_can_only_contain">Usernames can only contain letters, numbers, and underscores</string>
|
||||
<!-- Validation error shown when the entered username starts with a digit. -->
|
||||
<string name="AddUsernameScreen__usernames_cannot_begin_with_a_number">Usernames cannot begin with a number</string>
|
||||
<!-- Error shown when the chosen username cannot be reserved. -->
|
||||
<string name="AddUsernameScreen__this_username_is_not_available">This username is not available. Please try another.</string>
|
||||
|
||||
<!-- ContactSupportDialog -->
|
||||
<!-- Title of the dialog asking the user whether they want to attach a debug log to their support request -->
|
||||
<string name="ContactSupportDialog__submit_debug_log">Submit debug log?</string>
|
||||
|
||||
Reference in New Issue
Block a user