diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index e3cb310c99..1f02872cc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index e7a0e1a9fa..9644bbc60f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -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 { diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt new file mode 100644 index 0000000000..a5bb235fdf --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt @@ -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() + } +} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt b/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt index 09e315166a..b8c9129a8e 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt @@ -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 ) } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt index 6bb60f5129..13ca486f4b 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt @@ -88,7 +88,8 @@ class RegistrationApplication : Application() { .show() } }, - isLinkAndSyncAvailable = true + isLinkAndSyncAvailable = true, + isPhoneNumberlessRegistrationAvailable = true ) ) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt index b0edaf2048..f37e666df8 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt @@ -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 ) } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt index 0ec445c05c..2d0802e322 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationDependencies.kt @@ -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)?, diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index ab827485ed..786106f31e 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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 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.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.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.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.navigationEntries( ) } + // -- Signal Login Payment Screen + entry { + 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 { + 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 { + 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 { val viewModel: PinEntryForSvrRestoreViewModel = viewModel( @@ -633,13 +719,7 @@ private fun EntryProviderScope.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.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) } } ) diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index d6912607a0..f23887df6c 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreen.kt new file mode 100644 index 0000000000..0d9c5c6727 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreen.kt @@ -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? = 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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenEvents.kt new file mode 100644 index 0000000000..9a294c3b86 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameState.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameState.kt new file mode 100644 index 0000000000..82b35f8946 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameState.kt @@ -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 + ) +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt new file mode 100644 index 0000000000..6a5875b297 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(AddUsernameViewModel::class) + } + + private val _state = MutableStateFlow(AddUsernameState()) + val state: StateFlow = _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 create(modelClass: Class): T { + return AddUsernameViewModel(repository, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt index 68f1766061..0c450fe7fb 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt @@ -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() { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt index 08e3ab1abe..7e822380a5 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreenEvents.kt @@ -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()})" } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt index 474c978d4f..67d91e965b 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt @@ -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. */ diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 5f8fe4fece..29f6bc96e2 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -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 = _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)) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/shared/BackTopAppBar.kt b/feature/registration/src/main/java/org/signal/registration/screens/shared/BackTopAppBar.kt new file mode 100644 index 0000000000..d6710faff4 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/shared/BackTopAppBar.kt @@ -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 + ) +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt new file mode 100644 index 0000000000..41937af1c7 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreen.kt @@ -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? = 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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenEvents.kt new file mode 100644 index 0000000000..a18d5cb474 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt new file mode 100644 index 0000000000..8606ed8a4c --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoState.kt @@ -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 + ) +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt new file mode 100644 index 0000000000..8e678425a4 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogininfo/SignalLoginInfoViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(SignalLoginInfoViewModel::class) + } + + private val _state = MutableStateFlow(SignalLoginInfoState(isPasswordManagerAvailable = isPasswordManagerAvailable)) + val state: StateFlow = _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 create(modelClass: Class): T { + return SignalLoginInfoViewModel(repository, parentEventEmitter, isPasswordManagerAvailable) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreen.kt new file mode 100644 index 0000000000..1039309f21 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreen.kt @@ -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? = 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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenEvents.kt new file mode 100644 index 0000000000..899a5f7bb8 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentState.kt new file mode 100644 index 0000000000..d99ed0c493 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentState.kt @@ -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 + ) +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt new file mode 100644 index 0000000000..35ef328db8 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(SignalLoginPaymentViewModel::class) + } + + private val _state = MutableStateFlow(SignalLoginPaymentState()) + val state: StateFlow = _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 create(modelClass: Class): T { + return SignalLoginPaymentViewModel(repository, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index 82a586f611..f30e7dcb2c 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -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" diff --git a/feature/registration/src/main/res/drawable/image_signal_login_avatar_background.xml b/feature/registration/src/main/res/drawable/image_signal_login_avatar_background.xml new file mode 100644 index 0000000000..bc1daff8f7 --- /dev/null +++ b/feature/registration/src/main/res/drawable/image_signal_login_avatar_background.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/feature/registration/src/main/res/drawable/image_signal_login_card.xml b/feature/registration/src/main/res/drawable/image_signal_login_card.xml new file mode 100644 index 0000000000..e09443fd2e --- /dev/null +++ b/feature/registration/src/main/res/drawable/image_signal_login_card.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/feature/registration/src/main/res/drawable/image_signal_login_key.xml b/feature/registration/src/main/res/drawable/image_signal_login_key.xml new file mode 100644 index 0000000000..07ed74de80 --- /dev/null +++ b/feature/registration/src/main/res/drawable/image_signal_login_key.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/registration/src/main/res/drawable/image_signal_login_ring.xml b/feature/registration/src/main/res/drawable/image_signal_login_ring.xml new file mode 100644 index 0000000000..13b5229dbb --- /dev/null +++ b/feature/registration/src/main/res/drawable/image_signal_login_ring.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/registration/src/main/res/drawable/symbol_heart_24.xml b/feature/registration/src/main/res/drawable/symbol_heart_24.xml new file mode 100644 index 0000000000..65d42e88d3 --- /dev/null +++ b/feature/registration/src/main/res/drawable/symbol_heart_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index b055d2684c..6752a21731 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -51,6 +51,8 @@ The number you entered doesn\'t appear to be valid. Please check the country code and phone number and try again. Next + + Register without number Is the phone number below correct? @@ -600,6 +602,90 @@ Cancel + + + Back + + + + Signal Login + + Register without a phone number using Signal Login. With a one-time purchase, you\'ll receive an account key to get started. + + Learn more + + One-time purchase + + No phone number needed + + Username for messaging & calls + + Signal is a non-profit — your purchase helps support our mission + + I have a Signal Login + + Use your existing account key + + Login and restore your account + + Thanks for supporting Signal + + Pay %1$s + + Pay + + Continue + + Your purchase could not be completed. You have not been charged. + + + + Your Signal Login + + Thanks for your purchase. Use your Signal Login to register and restore your account. If you forget your keys, you will not be able to recover your account. + + Account + + Recovery + + View details + + Account: %1$s\n\nRecovery: %2$s + + Save to password manager + + Save manually + + Your Signal Login could not be saved. Please save it manually instead. + + + + Add an optional username + + People can message you by username instead of a phone number. + + Choose your username + + Username + + Usernames are always paired with a set of numbers. + + Learn more + + Skip + + Next + + Usernames must be at least 3 characters + + Usernames must be at most 32 characters + + Usernames can only contain letters, numbers, and underscores + + Usernames cannot begin with a number + + This username is not available. Please try another. + Submit debug log?