Add signal login screen scaffolding.

This commit is contained in:
Greyson Parrelli
2026-08-25 12:14:21 -04:00
parent 7a9fe0b0c1
commit 87df7029ad
12 changed files with 998 additions and 2 deletions
@@ -109,6 +109,9 @@ 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.signallogin.SignalLoginScreen
import org.signal.registration.screens.signallogin.SignalLoginScreenActions
import org.signal.registration.screens.signallogin.SignalLoginViewModel
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreen
import org.signal.registration.screens.signallogininfo.SignalLoginInfoViewModel
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreen
@@ -165,6 +168,10 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
data object SignalLoginInfo : RegistrationRoute
/** Log in with the account key of a Signal Login the user already owns. */
@Serializable
data object SignalLogin : RegistrationRoute
/** Optional username selection for a phone-numberless account. */
@Serializable
data object AddUsername : RegistrationRoute
@@ -665,6 +672,28 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
}
// -- Signal Login Screen
entry<RegistrationRoute.SignalLogin> {
val viewModel: SignalLoginViewModel = viewModel(
factory = SignalLoginViewModel.Factory(
repository = registrationRepository,
parentEventEmitter = registrationViewModel::onEvent
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
SignalLoginScreenActions.OpenNeedHelpArticle -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
}
}
SignalLoginScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- Add Username Screen
entry<RegistrationRoute.AddUsername> {
val viewModel: AddUsernameViewModel = viewModel(
@@ -0,0 +1,421 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
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.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.shape.RoundedCornerShape
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.ExperimentalMaterial3Api
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
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.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
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.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
/**
* Logs an existing Signal Login in by asking for its account key.
*/
@Composable
fun SignalLoginScreen(
state: SignalLoginState,
onEvent: (SignalLoginScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val simpleError: Pair<String, SignalLoginScreenEvents>? = when {
state.dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to SignalLoginScreenEvents.NetworkErrorDialogDismissed
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to SignalLoginScreenEvents.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_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: SignalLoginState,
onEvent: (SignalLoginScreenEvents) -> Unit
) {
val scrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
OnePaneRegistrationScaffold(
params = params,
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginScreenEvents.BackClicked) }) },
content = { paddingValues ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(paddingValues)
) {
Header()
Spacer(modifier = Modifier.height(32.dp))
AccountKeyTextField(state = state, onEvent = onEvent)
}
},
footer = { Footer(params, state, scrollState.canScrollForward, onEvent) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TwoPaneLayout(
params: RegistrationScaffold.Params.TwoPane,
state: SignalLoginState,
onEvent: (SignalLoginScreenEvents) -> Unit
) {
val firstPaneScrollState = rememberScrollState()
val secondPaneScrollState = rememberScrollState()
val topBarScrollBehavior = RegistrationScaffold.rememberTopBarScrollBehavior()
TwoPaneRegistrationScaffold(
params = params,
topBar = { BackTopAppBar(scrollBehavior = topBarScrollBehavior, onBackClick = { onEvent(SignalLoginScreenEvents.BackClicked) }) },
firstPane = { paddingValues ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(firstPaneScrollState)
.padding(paddingValues)
) {
Header(twoPane = true)
}
},
secondPane = { paddingValues ->
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.verticalScroll(secondPaneScrollState)
.padding(paddingValues)
) {
AccountKeyTextField(state = state, onEvent = onEvent)
}
},
footer = { Footer(params, state, firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, onEvent) }
)
}
@Composable
private fun Header(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.SignalLoginScreen__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 = stringResource(R.string.SignalLoginScreen__enter_your_32_character_account_key),
style = if (twoPane) MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal) else MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
@Composable
private fun AccountKeyTextField(
state: SignalLoginState,
onEvent: (SignalLoginScreenEvents) -> Unit
) {
val focusRequester = remember { FocusRequester() }
var requestFocus by remember { mutableStateOf(true) }
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = state.accountKey,
onValueChange = { onEvent(SignalLoginScreenEvents.AccountKeyChanged(it)) },
label = { Text(stringResource(R.string.SignalLoginScreen__account_key)) },
enabled = !state.isSubmitting,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = MonoTypeface.fontFamily(),
fontSize = 18.sp,
letterSpacing = 1.44.sp
),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onNext = {
if (state.isNextEnabled) {
keyboardController?.hide()
onEvent(SignalLoginScreenEvents.NextClicked)
}
}
),
supportingText = {
when (val error = state.accountKeyError) {
is AccountKeyError.TooLong -> Text(stringResource(R.string.SignalLoginScreen__too_long, error.count, SignalLoginState.ACCOUNT_KEY_LENGTH))
is AccountKeyError.Invalid -> Text(stringResource(R.string.SignalLoginScreen__invalid_account_key))
is AccountKeyError.Incorrect -> Text(stringResource(R.string.SignalLoginScreen__incorrect_account_key))
null -> {}
}
},
isError = state.accountKeyError != null,
visualTransformation = AccountKeyVisualTransformation,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.SIGNAL_LOGIN_ACCOUNT_KEY_FIELD)
.focusRequester(focusRequester)
.onGloballyPositioned {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
)
}
@Composable
private fun Footer(
params: RegistrationScaffold.Params,
state: SignalLoginState,
isElevated: Boolean,
onEvent: (SignalLoginScreenEvents) -> Unit
) {
RegistrationScaffold.FooterSurface(isElevated = isElevated) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(params.footerPadding)
) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(1f)
) {
NeedHelpButton(onEvent)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.weight(1f)
) {
NextButton(state, onEvent)
}
}
}
}
@Composable
private fun NeedHelpButton(onEvent: (SignalLoginScreenEvents) -> Unit) {
TextButton(
shape = RoundedCornerShape(0.dp),
onClick = { onEvent(SignalLoginScreenEvents.NeedHelpClicked) },
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_NEED_HELP_BUTTON)
) {
Text(text = stringResource(R.string.SignalLoginScreen__need_help))
}
}
@Composable
private fun NextButton(state: SignalLoginState, onEvent: (SignalLoginScreenEvents) -> Unit) {
Buttons.LargeTonal(
enabled = state.isNextEnabled,
onClick = { onEvent(SignalLoginScreenEvents.NextClicked) },
modifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON)
) {
if (state.isSubmitting) {
CircularProgressIndicator(
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
} else {
Text(text = stringResource(R.string.SignalLoginScreen__next))
}
}
}
/**
* Renders an account key the way its ACI is normally written: uppercased and split into 8-4-4-4-12 groups by dashes.
* The dashes are display-only, so what the view model sees is always the unformatted key.
*/
internal object AccountKeyVisualTransformation : VisualTransformation {
/** Offsets in the raw key that a dash is inserted in front of. */
private val DASH_OFFSETS = intArrayOf(8, 12, 16, 20)
override fun filter(text: AnnotatedString): TransformedText {
val transformed = buildString {
for ((index, character) in text.text.withIndex()) {
if (index in DASH_OFFSETS) {
append('-')
}
append(character.uppercaseChar())
}
}
return TransformedText(
text = AnnotatedString(transformed),
offsetMapping = AccountKeyOffsetMapping(text.length)
)
}
/**
* A dash is only present if the key is long enough to have a character after it, so [inputLength] decides which of
* [DASH_OFFSETS] actually made it into the transformed text.
*/
private class AccountKeyOffsetMapping(private val inputLength: Int) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = offset + DASH_OFFSETS.count { it <= offset && it < inputLength }
override fun transformedToOriginal(offset: Int): Int = offset - DASH_OFFSETS.withIndex().count { (index, dashOffset) -> dashOffset < inputLength && dashOffset + index < offset }
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginScreenPreview() {
Previews.Preview {
SignalLoginScreen(
state = SignalLoginState(),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginScreenFilledPreview() {
Previews.Preview {
SignalLoginScreen(
state = SignalLoginState(accountKey = "a6b284822e3283d07f2391360a4c2b91"),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginScreenSubmittingPreview() {
Previews.Preview {
SignalLoginScreen(
state = SignalLoginState(
accountKey = "a6b284822e3283d07f2391360a4c2b91",
isSubmitting = true
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun SignalLoginScreenErrorPreview() {
Previews.Preview {
SignalLoginScreen(
state = SignalLoginState(
accountKey = "a6b284822e3283d07f2391360a4c2b91",
accountKeyError = AccountKeyError.Incorrect
),
onEvent = {}
)
}
}
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
sealed interface SignalLoginScreenActions {
/** Open the article explaining where to find your account key. */
data object OpenNeedHelpArticle : SignalLoginScreenActions
}
@@ -0,0 +1,30 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import org.signal.core.util.censor
sealed class SignalLoginScreenEvents {
/** The user tapped the back arrow. */
data object BackClicked : SignalLoginScreenEvents()
/** The user edited the account key field. Carries the raw text, formatting and all. */
data class AccountKeyChanged(val value: String) : SignalLoginScreenEvents() {
override fun toString(): String = "AccountKeyChanged(value=${value.censor()})"
}
/** The user tapped "Need help?". */
data object NeedHelpClicked : SignalLoginScreenEvents()
/** The user submitted the account key, either with the next button or the keyboard's next action. */
data object NextClicked : SignalLoginScreenEvents()
/** The user dismissed the network error dialog. */
data object NetworkErrorDialogDismissed : SignalLoginScreenEvents()
/** The user dismissed the unknown error dialog. */
data object UnknownErrorDialogDismissed : SignalLoginScreenEvents()
}
@@ -0,0 +1,50 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import org.signal.core.util.censor
/**
* State for the screen where a user who already owns a Signal Login types in their account key to log in.
*
* [accountKey] holds the key without any of the formatting the user sees: the screen renders the dashes and
* uppercasing itself, so what is stored here is always the raw lowercase value.
*/
data class SignalLoginState(
val accountKey: String = "",
val accountKeyError: AccountKeyError? = null,
val isSubmitting: Boolean = false,
val dialogs: Dialogs = Dialogs()
) {
/** Whether the entered key is complete and well-formed enough to send to the service. */
val isNextEnabled: Boolean
get() = accountKey.length == ACCOUNT_KEY_LENGTH && accountKeyError == null && !isSubmitting
override fun toString(): String = "SignalLoginState(accountKey=${accountKey.censor()}, accountKeyError=$accountKeyError, isSubmitting=$isSubmitting, dialogs=$dialogs)"
data class Dialogs(
val networkError: Boolean = false,
val unknownError: Boolean = false
)
companion object {
/** An account key is an ACI with its dashes removed, so it is always this many hex characters. */
const val ACCOUNT_KEY_LENGTH = 32
}
}
/** Why the entered account key can't be submitted. Shown beneath the text field rather than in a dialog. */
sealed interface AccountKeyError {
/** More than [SignalLoginState.ACCOUNT_KEY_LENGTH] characters were entered. */
data class TooLong(val count: Int) : AccountKeyError
/** The entered text contains characters that can't appear in an account key. */
data object Invalid : AccountKeyError
/** The service didn't recognize the entered account key. */
data object Incorrect : AccountKeyError
}
@@ -0,0 +1,118 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
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 kotlinx.coroutines.flow.receiveAsFlow
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 [SignalLoginScreen].
*
* Logging in with an account key requires an endpoint that doesn't exist yet, so [SignalLoginScreenEvents.NextClicked]
* is deliberately left as a stub. Everything the screen needs to validate and format what the user types is here.
*/
class SignalLoginViewModel(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<SignalLoginScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(SignalLoginViewModel::class)
/** Formatting the user may have pasted along with the key, which we accept and discard. */
private val FORMATTING_CHARACTERS = Regex("""[\s-]""")
private fun Char.isAccountKeyCharacter(): Boolean = this in '0'..'9' || this in 'a'..'f'
}
private val _state = MutableStateFlow(SignalLoginState())
val state: StateFlow<SignalLoginState> = _state.asStateFlow()
private val _actions = Channel<SignalLoginScreenActions>(Channel.BUFFERED)
val actions: Flow<SignalLoginScreenActions> = _actions.receiveAsFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: SignalLoginScreenEvents) {
applyEvent(_state.value, event, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: SignalLoginState,
event: SignalLoginScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (SignalLoginState) -> Unit
) {
when (event) {
is SignalLoginScreenEvents.BackClicked -> {
parentEventEmitter.navigateBack()
}
is SignalLoginScreenEvents.AccountKeyChanged -> {
val accountKey = event.value.replace(FORMATTING_CHARACTERS, "").lowercase()
stateEmitter(state.copy(accountKey = accountKey, accountKeyError = validate(accountKey)))
}
is SignalLoginScreenEvents.NeedHelpClicked -> {
_actions.trySend(SignalLoginScreenActions.OpenNeedHelpArticle)
}
is SignalLoginScreenEvents.NextClicked -> {
Log.i(TAG, "Next clicked, but logging in with an account key isn't implemented yet.")
}
is SignalLoginScreenEvents.NetworkErrorDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(networkError = false)))
}
is SignalLoginScreenEvents.UnknownErrorDialogDismissed -> {
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
}
}
}
/**
* Checks an already-normalized [accountKey]. A key that is merely incomplete isn't an error — the next button stays
* disabled until it is the right length, without nagging the user as they type.
*/
private fun validate(accountKey: String): AccountKeyError? {
return when {
accountKey.length > SignalLoginState.ACCOUNT_KEY_LENGTH -> AccountKeyError.TooLong(accountKey.length)
accountKey.any { !it.isAccountKeyCharacter() } -> AccountKeyError.Invalid
else -> null
}
}
class Factory(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SignalLoginViewModel(repository, parentEventEmitter) as T
}
}
}
@@ -21,7 +21,9 @@ 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.RegistrationRoute
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
class SignalLoginPaymentViewModel(
private val repository: RegistrationRepository,
@@ -75,8 +77,12 @@ class SignalLoginPaymentViewModel(
}
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.")
if (state.selectedOption == SignalLoginPaymentState.Option.ExistingLogin) {
parentEventEmitter.navigateTo(RegistrationRoute.SignalLogin)
} else {
// TODO [phonenumberless] Launch the purchase flow.
Log.i(TAG, "Continue clicked for ${state.selectedOption}, but the purchase flow isn't implemented yet.")
}
}
is SignalLoginPaymentScreenEvents.NetworkErrorDialogDismissed -> {
@@ -56,6 +56,12 @@ object TestTags {
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 Screen
const val SIGNAL_LOGIN_SCREEN = "signal_login_screen"
const val SIGNAL_LOGIN_ACCOUNT_KEY_FIELD = "signal_login_account_key_field"
const val SIGNAL_LOGIN_NEED_HELP_BUTTON = "signal_login_need_help_button"
const val SIGNAL_LOGIN_NEXT_BUTTON = "signal_login_next_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"
@@ -652,6 +652,24 @@
<!-- Error shown when the Signal Login could not be stored in the password manager. -->
<string name="SignalLoginInfoScreen__your_signal_login_could_not_be_saved">Your Signal Login could not be saved. Please save it manually instead.</string>
<!-- Signal Login account key entry screen -->
<!-- Title of the screen where a user with an existing Signal Login types in their account key. -->
<string name="SignalLoginScreen__signal_login">Signal Login</string>
<!-- Description telling the user what to enter to log in. -->
<string name="SignalLoginScreen__enter_your_32_character_account_key">Enter your 32-character account key to get started.</string>
<!-- Label of the text field the account key is typed into. -->
<string name="SignalLoginScreen__account_key">Account key</string>
<!-- Button that opens a support article for users who can\'t find their account key. -->
<string name="SignalLoginScreen__need_help">Need help?</string>
<!-- Action button that submits the entered account key. -->
<string name="SignalLoginScreen__next">Next</string>
<!-- Error shown when too many characters were entered. First placeholder is how many were entered, second is the maximum. -->
<string name="SignalLoginScreen__too_long">Too long. %1$d/%2$d characters.</string>
<!-- Error shown when the entered account key contains characters that can\'t appear in one. -->
<string name="SignalLoginScreen__invalid_account_key">Invalid account key</string>
<!-- Error shown when the service doesn\'t recognize the entered account key. -->
<string name="SignalLoginScreen__incorrect_account_key">Incorrect account key</string>
<!-- Add username screen -->
<!-- Title of the screen offering the user an optional username. -->
<string name="AddUsernameScreen__add_an_optional_username">Add an optional username</string>
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import androidx.compose.ui.text.AnnotatedString
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class AccountKeyVisualTransformationTest {
companion object {
private const val FULL_KEY = "a6b284822e3283d07f2391360a4c2b91"
private const val FULL_KEY_FORMATTED = "A6B28482-2E32-83D0-7F23-91360A4C2B91"
}
@Test
fun `a full key is uppercased and split into UUID groups`() {
assertThat(transform(FULL_KEY)).isEqualTo(FULL_KEY_FORMATTED)
}
@Test
fun `an empty key transforms to nothing`() {
assertThat(transform("")).isEqualTo("")
}
@Test
fun `no trailing dash is added to a key that ends on a group boundary`() {
assertThat(transform("a6b28482")).isEqualTo("A6B28482")
assertThat(transform("a6b284822e32")).isEqualTo("A6B28482-2E32")
}
@Test
fun `a dash appears as soon as the next group is started`() {
assertThat(transform("a6b284822")).isEqualTo("A6B28482-2")
}
@Test
fun `every cursor position maps into the transformed text and back`() {
for (length in 0..FULL_KEY.length) {
val key = FULL_KEY.take(length)
val mapping = AccountKeyVisualTransformation.filter(AnnotatedString(key)).offsetMapping
val transformedLength = transform(key).length
for (offset in 0..length) {
val transformed = mapping.originalToTransformed(offset)
assertThat(transformed in 0..transformedLength).isEqualTo(true)
assertThat(mapping.transformedToOriginal(transformed)).isEqualTo(offset)
}
}
}
private fun transform(text: String): String = AccountKeyVisualTransformation.filter(AnnotatedString(text)).text.text
}
@@ -0,0 +1,101 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import android.app.Application
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.test.TestTags
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class SignalLoginScreenTest {
companion object {
private const val VALID_ACCOUNT_KEY = "a6b284822e3283d07f2391360a4c2b91"
}
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<SignalLoginScreenEvents>()
@Test
fun `when text is typed into the account key field, AccountKeyChanged is emitted`() {
setContent(SignalLoginState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_ACCOUNT_KEY_FIELD).performTextInput("a6b2")
assertThat(events).contains(SignalLoginScreenEvents.AccountKeyChanged("a6b2"))
}
@Test
fun `when Need help is clicked, NeedHelpClicked is emitted`() {
setContent(SignalLoginState())
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEED_HELP_BUTTON).performClick()
assertThat(events).contains(SignalLoginScreenEvents.NeedHelpClicked)
}
@Test
fun `when Next is clicked with a complete key, NextClicked is emitted`() {
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).performClick()
assertThat(events).contains(SignalLoginScreenEvents.NextClicked)
}
@Test
fun `given an incomplete key, Next is disabled`() {
setContent(SignalLoginState(accountKey = "a6b28482"))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsNotEnabled()
}
@Test
fun `given a complete key, Next is enabled`() {
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsEnabled()
}
@Test
fun `given a submission is in flight, Next is disabled`() {
setContent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY, isSubmitting = true))
composeTestRule.onNodeWithTag(TestTags.SIGNAL_LOGIN_NEXT_BUTTON).assertIsNotEnabled()
}
private fun setContent(state: SignalLoginState) {
composeTestRule.setContent {
SignalTheme {
SignalLoginScreen(
state = state,
onEvent = { events += it }
)
}
}
}
}
@@ -0,0 +1,148 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signallogin
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
@OptIn(ExperimentalCoroutinesApi::class)
class SignalLoginViewModelTest {
companion object {
private const val VALID_ACCOUNT_KEY = "a6b284822e3283d07f2391360a4c2b91"
}
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var mockRepository: RegistrationRepository
private lateinit var viewModel: SignalLoginViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
viewModel = SignalLoginViewModel(repository = mockRepository, parentEventEmitter = {})
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `BackClicked navigates back`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
viewModel.applyEvent(SignalLoginState(), SignalLoginScreenEvents.BackClicked, { parentEvents.add(it) }) {}
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `AccountKeyChanged strips formatting and lowercases the entered key`() = runTest(testDispatcher) {
val state = applyAccountKey("A6B28482-2E32-83D0-7F23 91360A4C2B91")
assertThat(state.accountKey).isEqualTo(VALID_ACCOUNT_KEY)
assertThat(state.accountKeyError).isNull()
assertThat(state.isNextEnabled).isTrue()
}
@Test
fun `AccountKeyChanged does not report an error for a partially typed key`() = runTest(testDispatcher) {
val state = applyAccountKey("a6b28482")
assertThat(state.accountKeyError).isNull()
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `AccountKeyChanged reports non-hex characters as invalid`() = runTest(testDispatcher) {
val state = applyAccountKey(VALID_ACCOUNT_KEY.dropLast(1) + "z")
assertThat(state.accountKeyError).isEqualTo(AccountKeyError.Invalid)
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `AccountKeyChanged reports an over-long key as too long`() = runTest(testDispatcher) {
val state = applyAccountKey(VALID_ACCOUNT_KEY + "ab")
assertThat(state.accountKeyError).isEqualTo(AccountKeyError.TooLong(34))
assertThat(state.isNextEnabled).isFalse()
}
@Test
fun `NeedHelpClicked opens the help article`() = runTest(testDispatcher) {
val actions = mutableListOf<SignalLoginScreenActions>()
backgroundScope.launch { viewModel.actions.toList(actions) }
viewModel.applyEvent(SignalLoginState(), SignalLoginScreenEvents.NeedHelpClicked, {}) {}
assertThat(actions).containsExactly(SignalLoginScreenActions.OpenNeedHelpArticle)
}
@Test
fun `NextClicked does nothing yet because logging in is not implemented`() = runTest(testDispatcher) {
val parentEvents = mutableListOf<RegistrationFlowEvent>()
val states = mutableListOf<SignalLoginState>()
viewModel.applyEvent(SignalLoginState(accountKey = VALID_ACCOUNT_KEY), SignalLoginScreenEvents.NextClicked, { parentEvents.add(it) }) { states.add(it) }
assertThat(parentEvents).isEmpty()
assertThat(states).isEmpty()
}
@Test
fun `NetworkErrorDialogDismissed clears the dialog`() = runTest(testDispatcher) {
var state: SignalLoginState? = null
viewModel.applyEvent(
SignalLoginState(dialogs = SignalLoginState.Dialogs(networkError = true)),
SignalLoginScreenEvents.NetworkErrorDialogDismissed,
{}
) { state = it }
assertThat(state!!.dialogs.networkError).isFalse()
}
@Test
fun `UnknownErrorDialogDismissed clears the dialog`() = runTest(testDispatcher) {
var state: SignalLoginState? = null
viewModel.applyEvent(
SignalLoginState(dialogs = SignalLoginState.Dialogs(unknownError = true)),
SignalLoginScreenEvents.UnknownErrorDialogDismissed,
{}
) { state = it }
assertThat(state!!.dialogs.unknownError).isFalse()
}
private suspend fun applyAccountKey(value: String): SignalLoginState {
var state = SignalLoginState()
viewModel.applyEvent(state, SignalLoginScreenEvents.AccountKeyChanged(value), {}) { state = it }
return state
}
}