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 2ec4ba44c0..aa4d2f35b9 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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.navigationEntries( ) } + // -- Signal Login Screen + entry { + 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 { val viewModel: AddUsernameViewModel = viewModel( diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreen.kt new file mode 100644 index 0000000000..3dfb877c26 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreen.kt @@ -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? = 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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenActions.kt new file mode 100644 index 0000000000..5278e78a03 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenActions.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenEvents.kt new file mode 100644 index 0000000000..51e4d0bfb4 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginState.kt new file mode 100644 index 0000000000..115fa9ecc7 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginState.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginViewModel.kt new file mode 100644 index 0000000000..5bf64aa653 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signallogin/SignalLoginViewModel.kt @@ -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(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 = _state.asStateFlow() + + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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 create(modelClass: Class): T { + return SignalLoginViewModel(repository, parentEventEmitter) as T + } + } +} 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 index d972646640..cf9d8d69c2 100644 --- 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 @@ -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 -> { 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 7017b13b67..6c420a61f9 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 @@ -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" diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index ee695587f0..655720409e 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -652,6 +652,24 @@ Your Signal Login could not be saved. Please save it manually instead. + + + Signal Login + + Enter your 32-character account key to get started. + + Account key + + Need help? + + Next + + Too long. %1$d/%2$d characters. + + Invalid account key + + Incorrect account key + Add an optional username diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/AccountKeyVisualTransformationTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogin/AccountKeyVisualTransformationTest.kt new file mode 100644 index 0000000000..bdfa12eeca --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogin/AccountKeyVisualTransformationTest.kt @@ -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 +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginScreenTest.kt new file mode 100644 index 0000000000..e91776e2a7 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginScreenTest.kt @@ -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() + + @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 } + ) + } + } + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginViewModelTest.kt new file mode 100644 index 0000000000..51af41aa38 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogin/SignalLoginViewModelTest.kt @@ -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() + + 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() + 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() + val states = mutableListOf() + + 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 + } +}