From 3b2bf29ba033008b3047d58c83812458199af89f Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 26 Aug 2026 21:56:59 -0400 Subject: [PATCH] Allow editing username discriminent in AddUsernameScreen. --- .../v2/AppRegistrationNetworkController.kt | 4 +- .../sample/debug/DebugNetworkController.kt | 4 +- .../dependencies/DemoNetworkController.kt | 9 +- .../signal/registration/NetworkController.kt | 9 +- .../registration/RegistrationNavigation.kt | 8 - .../registration/RegistrationRepository.kt | 6 +- .../screens/addusername/AddUsernameScreen.kt | 141 +++++++++++++----- .../addusername/AddUsernameScreenActions.kt | 11 -- .../addusername/AddUsernameScreenEvents.kt | 23 ++- .../screens/addusername/AddUsernameState.kt | 27 +++- .../addusername/AddUsernameViewModel.kt | 128 +++++++++++----- .../org/signal/registration/test/TestTags.kt | 1 + .../src/main/res/values/strings.xml | 18 ++- .../fakes/FakeNetworkController.kt | 11 +- .../addusername/AddUsernameScreenTest.kt | 116 ++++++++++++++ .../addusername/AddUsernameViewModelTest.kt | 131 ++++++++++++++-- 16 files changed, 513 insertions(+), 134 deletions(-) delete mode 100644 feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenActions.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameScreenTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index 9019eb6b44..32623b948d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -456,8 +456,8 @@ class AppRegistrationNetworkController( RequestResult.Success(Unit) } - override suspend fun reserveUsername(nickname: String): RequestResult { - return AppDependencies.usernameService.reserveUsername(nickname) + override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult { + return AppDependencies.usernameService.reserveUsername(nickname, discriminator) } override suspend fun confirmUsername(username: Username): RequestResult { diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index a0d0556b69..eeb027fa31 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -260,12 +260,12 @@ class DebugNetworkController( return delegate.setProfile(givenName, familyName, avatar, discoverableByPhoneNumber) } - override suspend fun reserveUsername(nickname: String): RequestResult { + override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult { NetworkDebugState.getOverride>("reserveUsername")?.let { Log.d(TAG, "[reserveUsername] Returning debug override") return it } - return delegate.reserveUsername(nickname) + return delegate.reserveUsername(nickname, discriminator) } override suspend fun confirmUsername(username: Username): RequestResult { diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index ac77088d69..092069d4e7 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -92,6 +92,7 @@ import org.whispersystems.signalservice.api.storage.StorageServiceApi import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2 +import org.whispersystems.signalservice.api.util.Usernames import org.whispersystems.signalservice.api.websocket.HealthMonitor import org.whispersystems.signalservice.api.websocket.SignalWebSocket import org.whispersystems.signalservice.api.websocket.WebSocketFactory @@ -957,7 +958,7 @@ class DemoNetworkController( RequestResult.Success(Unit) } - override suspend fun reserveUsername(nickname: String): RequestResult = withContext(Dispatchers.IO) { + override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult = withContext(Dispatchers.IO) { val aci = RegistrationPreferences.aci val password = RegistrationPreferences.servicePassword @@ -967,7 +968,11 @@ class DemoNetworkController( } val candidates: List = try { - Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH) + if (discriminator == null) { + Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH) + } else { + listOf(Username("$nickname${Usernames.DELIMITER}$discriminator")) + } } catch (e: BaseUsernameException) { Log.w(TAG, "[reserveUsername] Failed to generate candidates.", e) return@withContext RequestResult.NonSuccess(ReserveUsernameError.NicknameInvalid) diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 9d56255190..77fed46244 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -416,13 +416,14 @@ interface NetworkController { suspend fun restoreAccountRecord(timeout: Duration): RequestResult /** - * Reserves a username composed of [nickname] plus a server-assigned numeric discriminator. The service holds the - * reservation for a short time (~5 minutes), during which it can be finalized via [confirmUsername]. - * Reserving again replaces any previous reservation. + * Reserves a username composed of [nickname] plus a numeric discriminator. If [discriminator] is provided, only that + * exact username is attempted, otherwise the service assigns one. The service holds the reservation for a short time + * (~5 minutes), during which it can be finalized via [confirmUsername]. Reserving again replaces any previous + * reservation. * * `PUT /v1/accounts/username_hash/reserve` */ - suspend fun reserveUsername(nickname: String): RequestResult + suspend fun reserveUsername(nickname: String, discriminator: String? = null): RequestResult /** * Confirms a reservation previously made via [reserveUsername], assigning the username to the account and creating 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 aa4d2f35b9..c5b7551517 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -51,7 +51,6 @@ 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.AddUsernameScreenActions import org.signal.registration.screens.addusername.AddUsernameViewModel import org.signal.registration.screens.aepentry.EnterAepForLocalBackupResult import org.signal.registration.screens.aepentry.EnterAepForLocalBackupViewModel @@ -328,7 +327,6 @@ 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/" @@ -703,12 +701,6 @@ private fun EntryProviderScope.navigationEntries( ) ) val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current - CollectActions(viewModel.actions) { action -> - when (action) { - AddUsernameScreenActions.OpenLearnMoreArticle -> openUrl(context, USERNAME_LEARN_MORE_URL) - } - } AddUsernameScreen( state = state, 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 c1038758e6..db420fc864 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -769,11 +769,11 @@ class RegistrationRepository( } /** - * Reserves a username made from [nickname] plus a server-assigned discriminator. + * Reserves a username made from [nickname] plus [discriminator], or a server-assigned discriminator if none is given. * See [NetworkController.reserveUsername]. */ - suspend fun reserveUsername(nickname: String): RequestResult = withContext(Dispatchers.IO) { - networkController.reserveUsername(nickname) + suspend fun reserveUsername(nickname: String, discriminator: String? = null): RequestResult = withContext(Dispatchers.IO) { + networkController.reserveUsername(nickname, discriminator) } /** 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 index 81c329ba90..4e1e8db682 100644 --- 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 @@ -5,7 +5,6 @@ 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 @@ -21,11 +20,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField 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.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -40,6 +41,8 @@ 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.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -50,6 +53,8 @@ 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.input.KeyboardType +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp @@ -59,6 +64,7 @@ import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Dividers import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalIcons +import org.signal.core.util.UsernameUtil import org.signal.libsignal.usernames.Username import org.signal.registration.R import org.signal.registration.screens.OnePaneRegistrationScaffold @@ -66,7 +72,6 @@ import org.signal.registration.screens.RegistrationScaffold import org.signal.registration.screens.TwoPaneRegistrationScaffold import org.signal.registration.screens.attachDebugLogHelper import org.signal.registration.test.TestTags -import org.whispersystems.signalservice.api.util.discriminator /** Size of the avatar artwork, whose sphere occupies the inner 72dp of its 80dp box. */ private val AVATAR_SIZE = 80.dp @@ -74,6 +79,12 @@ 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 +/** The discriminator field is sized to its content, but never narrower than this many digits. */ +private const val DISCRIMINATOR_MIN_WIDTH_TEMPLATE = "00" + +/** Extra room on the discriminator field so the caret isn't clipped at the end of the text. */ +private val DISCRIMINATOR_CARET_ALLOWANCE = 2.dp + /** * Offers the user an optional username so people can reach them without a phone number. */ @@ -100,6 +111,15 @@ fun AddUsernameScreen( ) } + if (state.dialogs.learnMore) { + Dialogs.SimpleMessageDialog( + title = stringResource(R.string.AddUsernameScreen__what_is_this_number), + message = stringResource(R.string.AddUsernameScreen__these_digits_help_keep), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onEvent(AddUsernameScreenEvents.LearnMoreDialogDismissed) } + ) + } + Surface( modifier = modifier .fillMaxSize() @@ -201,6 +221,7 @@ private fun ColumnScope.UsernameEntry( onEvent: (AddUsernameScreenEvents) -> Unit ) { val focusRequester = remember { FocusRequester() } + val validationMessage: String? = state.validationError?.message() LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -227,10 +248,8 @@ private fun ColumnScope.UsernameEntry( singleLine = true, enabled = !state.showSpinner, isError = state.validationError != null, - supportingText = state.validationError?.let { error -> - { Text(stringResource(error.messageId)) } - }, - suffix = discriminatorSuffix(state), + supportingText = validationMessage?.let { message -> { Text(message) } }, + suffix = discriminatorSuffix(state, onEvent), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, @@ -278,41 +297,77 @@ private fun ColumnScope.UsernameEntry( } /** - * The trailing content of the username field: a spinner while a username is being reserved, and once one is - * reserved, its discriminator behind a divider (per the design, the discriminator is server-assigned and not - * directly editable). + * The trailing content of the username field: a spinner while a username is being reserved, and, behind a divider, the + * discriminator. The discriminator is assigned by the service, but the user can overwrite it to claim a specific one. */ -private fun discriminatorSuffix(state: AddUsernameState): (@Composable () -> Unit)? { - val discriminator = state.reservation?.discriminator +private fun discriminatorSuffix(state: AddUsernameState, onEvent: (AddUsernameScreenEvents) -> Unit): (@Composable () -> Unit)? { + if (!state.isReserving && !state.showDiscriminator) { + return null + } - return when { - state.isReserving -> { - { + return { + Row(verticalAlignment = Alignment.CenterVertically) { + if (state.isReserving) { CircularProgressIndicator( strokeWidth = 2.dp, modifier = Modifier.size(16.dp) ) + + Spacer(modifier = Modifier.width(16.dp)) + } + + if (state.showDiscriminator) { + Dividers.Vertical( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.height(20.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + DiscriminatorField(state = state, onEvent = onEvent) } } + } +} - discriminator != null -> { - { - Row(verticalAlignment = Alignment.CenterVertically) { - Dividers.Vertical( - thickness = 1.dp, - color = MaterialTheme.colorScheme.outline, - modifier = Modifier.height(20.dp) - ) +/** The editable discriminator, sized to its content so it hugs the right edge of the username field. */ +@Composable +private fun DiscriminatorField( + state: AddUsernameState, + onEvent: (AddUsernameScreenEvents) -> Unit +) { + val textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface) + val textMeasurer = rememberTextMeasurer() - Spacer(modifier = Modifier.width(16.dp)) + val width = with(LocalDensity.current) { + val content = textMeasurer.measure(state.discriminator, textStyle).size.width + val minimum = textMeasurer.measure(DISCRIMINATOR_MIN_WIDTH_TEMPLATE, textStyle).size.width + maxOf(content, minimum).toDp() + DISCRIMINATOR_CARET_ALLOWANCE + } - Text(text = discriminator) + BasicTextField( + value = state.discriminator, + onValueChange = { onEvent(AddUsernameScreenEvents.DiscriminatorChanged(it)) }, + textStyle = textStyle, + singleLine = true, + enabled = !state.showSpinner, + cursorBrush = SolidColor(if (state.validationError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (state.isSubmittable) { + onEvent(AddUsernameScreenEvents.NextClicked) } } - } - - else -> null - } + ), + modifier = Modifier + .width(width) + .testTag(TestTags.ADD_USERNAME_DISCRIMINATOR_FIELD) + ) } /** @@ -382,15 +437,20 @@ private fun Footer( } } -@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 - AddUsernameState.ValidationError.NOT_AVAILABLE -> R.string.AddUsernameScreen__this_username_is_not_available - } +@Composable +private fun AddUsernameState.ValidationError.message(): String = when (this) { + AddUsernameState.ValidationError.TOO_SHORT -> stringResource(R.string.AddUsernameScreen__usernames_must_be_at_least_3_characters) + AddUsernameState.ValidationError.TOO_LONG -> stringResource(R.string.AddUsernameScreen__usernames_must_be_at_most_32_characters) + AddUsernameState.ValidationError.INVALID_CHARACTERS -> stringResource(R.string.AddUsernameScreen__usernames_can_only_contain) + AddUsernameState.ValidationError.CANNOT_START_WITH_DIGIT -> stringResource(R.string.AddUsernameScreen__usernames_cannot_begin_with_a_number) + AddUsernameState.ValidationError.NOT_AVAILABLE -> stringResource(R.string.AddUsernameScreen__this_username_is_not_available) + AddUsernameState.ValidationError.DISCRIMINATOR_TOO_SHORT -> stringResource(R.string.AddUsernameScreen__enter_a_minimum_of_d_digits, UsernameUtil.MIN_DISCRIMINATOR_LENGTH) + AddUsernameState.ValidationError.DISCRIMINATOR_TOO_LONG -> stringResource(R.string.AddUsernameScreen__enter_a_maximum_of_d_digits, UsernameUtil.MAX_DISCRIMINATOR_LENGTH) + AddUsernameState.ValidationError.DISCRIMINATOR_INVALID_CHARACTERS -> stringResource(R.string.AddUsernameScreen__numbers_can_only_contain_digits) + AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_BE_00 -> stringResource(R.string.AddUsernameScreen__this_number_cant_be_00) + AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_START_WITH_ZERO -> stringResource(R.string.AddUsernameScreen__this_number_cant_start_with_0) + AddUsernameState.ValidationError.DISCRIMINATOR_NOT_AVAILABLE -> stringResource(R.string.AddUsernameScreen__this_username_is_not_available_try_another_number) +} @AllDevicePreviews @Composable @@ -419,7 +479,12 @@ private fun AddUsernameScreenFilledPreview() { private fun AddUsernameScreenReservedPreview() { Previews.Preview { AddUsernameScreen( - state = AddUsernameState(username = "alice", reservation = Username("alice.45")), + state = AddUsernameState( + username = "alice", + discriminator = "45", + showDiscriminator = true, + reservation = Username("alice.45") + ), onEvent = {} ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenActions.kt deleted file mode 100644 index e9d210a796..0000000000 --- a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenActions.kt +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.registration.screens.addusername - -sealed interface AddUsernameScreenActions { - /** Open the article explaining Signal usernames. */ - data object OpenLearnMoreArticle : AddUsernameScreenActions -} 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 index 72e9d1c4e7..90807110ef 100644 --- 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 @@ -16,19 +16,30 @@ sealed class AddUsernameScreenEvents { override fun toString(): String = "UsernameChanged(value=${value.censor()})" } - /** Internal: the user paused typing long enough for the entered nickname to be validated and reserved. */ - data class NicknameSettled(val value: String) : AddUsernameScreenEvents() { - override fun toString(): String = "NicknameSettled(value=${value.censor()})" + /** The user edited the discriminator field. */ + data class DiscriminatorChanged(val value: String) : AddUsernameScreenEvents() { + override fun toString(): String = "DiscriminatorChanged(value=${value.censor()})" } - /** Internal: a reservation attempt for [nickname] finished with [result]. */ - data class ReservationCompleted(val nickname: String, val result: RequestResult) : AddUsernameScreenEvents() { - override fun toString(): String = "ReservationCompleted(nickname=${nickname.censor()}, result=${result.javaClass.simpleName})" + /** + * Internal: the user paused typing long enough for the entered username to be validated and reserved. A null + * [discriminator] means the service should assign one. + */ + data class EntrySettled(val nickname: String, val discriminator: String?) : AddUsernameScreenEvents() { + override fun toString(): String = "EntrySettled(nickname=${nickname.censor()}, discriminator=${discriminator?.censor()})" + } + + /** Internal: a reservation attempt for [nickname] and [discriminator] finished with [result]. */ + data class ReservationCompleted(val nickname: String, val discriminator: String?, val result: RequestResult) : AddUsernameScreenEvents() { + override fun toString(): String = "ReservationCompleted(nickname=${nickname.censor()}, discriminator=${discriminator?.censor()}, result=${result.javaClass.simpleName})" } /** The user tapped the "learn more" link under the username field. */ data object LearnMoreClicked : AddUsernameScreenEvents() + /** The user dismissed the dialog explaining the digits after the username. */ + data object LearnMoreDialogDismissed : AddUsernameScreenEvents() + /** The user opted out of choosing a username. */ data object SkipClicked : 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 index c5a003f1ff..cb5604edcf 100644 --- 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 @@ -14,7 +14,13 @@ import org.signal.libsignal.usernames.Username data class AddUsernameState( /** The nickname (the part of the username before the discriminator) as typed by the user. */ val username: String = "", - /** Set when the entered nickname fails validation, describing why. */ + /** The discriminator (the digits after the nickname), either server-assigned or typed by the user. */ + val discriminator: String = "", + /** True when [discriminator] was typed by the user, meaning we reserve that exact discriminator rather than letting the service pick one. */ + val isDiscriminatorUserSet: Boolean = false, + /** Whether the discriminator field is shown. It stays hidden until the service has assigned a discriminator to display. */ + val showDiscriminator: Boolean = false, + /** Set when the entered username fails validation, describing why. */ val validationError: ValidationError? = null, /** The reserved username (nickname + discriminator) for the entered nickname, once one has been reserved. */ val reservation: Username? = null, @@ -27,7 +33,11 @@ data class AddUsernameState( val isSubmittable: Boolean get() = !showSpinner && !isReserving && username.isNotBlank() && validationError == null && reservation != null - override fun toString(): String = "AddUsernameState(username=${username.censor()}, validationError=$validationError, reservation=${reservation?.username?.censor()}, isReserving=$isReserving, showSpinner=$showSpinner, dialogs=$dialogs)" + /** The discriminator to reserve, or null when the service should assign one. */ + val requestedDiscriminator: String? + get() = discriminator.takeIf { isDiscriminatorUserSet } + + override fun toString(): String = "AddUsernameState(username=${username.censor()}, discriminator=${discriminator.censor()}, isDiscriminatorUserSet=$isDiscriminatorUserSet, showDiscriminator=$showDiscriminator, validationError=$validationError, reservation=${reservation?.username?.censor()}, isReserving=$isReserving, showSpinner=$showSpinner, dialogs=$dialogs)" enum class ValidationError { TOO_SHORT, @@ -36,10 +46,21 @@ data class AddUsernameState( CANNOT_START_WITH_DIGIT, /** The nickname is valid, but no username could be reserved for it. */ - NOT_AVAILABLE + NOT_AVAILABLE, + + DISCRIMINATOR_TOO_SHORT, + DISCRIMINATOR_TOO_LONG, + DISCRIMINATOR_INVALID_CHARACTERS, + DISCRIMINATOR_CANNOT_BE_00, + DISCRIMINATOR_CANNOT_START_WITH_ZERO, + + /** The nickname and user-chosen discriminator are both valid, but that pairing is already taken. */ + DISCRIMINATOR_NOT_AVAILABLE } data class Dialogs( + /** Explains what the digits after the username are for. */ + val learnMore: Boolean = false, val networkError: Boolean = false, val unknownError: Boolean = false, /** The reserved username was claimed by someone else before it could be confirmed. */ 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 index 239bc6c7fb..6cd91ff973 100644 --- 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 @@ -12,8 +12,6 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,7 +20,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.UsernameUtil @@ -34,15 +31,16 @@ import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute import org.signal.registration.screens.util.navigateTo +import org.whispersystems.signalservice.api.util.discriminator import kotlin.time.Duration.Companion.milliseconds /** * View model for [AddUsernameScreen]. * - * As the user types a nickname, we debounce their input and then validate it locally. If it's valid, we reserve a - * username for it on the service (the nickname plus a server-assigned numeric discriminator), which is what lets us - * show the discriminator while they type and detect taken nicknames early. Tapping "next" confirms the reservation, - * making it the account's actual username. + * As the user types, we debounce their input and then validate it locally. If it's valid, we reserve a username on the + * service, which is what lets us show the discriminator while they type and detect taken nicknames early. The + * discriminator is normally assigned by the service, but the user can type their own, in which case we reserve that + * exact pairing instead. Tapping "next" confirms the reservation, making it the account's actual username. */ @OptIn(FlowPreview::class) class AddUsernameViewModel( @@ -53,16 +51,13 @@ class AddUsernameViewModel( companion object { private val TAG = Log.tag(AddUsernameViewModel::class) - private val NICKNAME_DEBOUNCE = 500.milliseconds + private val ENTRY_DEBOUNCE = 500.milliseconds } private val _state = MutableStateFlow(AddUsernameState()) val state: StateFlow = _state.asStateFlow() - private val _actions = Channel(Channel.BUFFERED) - val actions: Flow = _actions.receiveAsFlow() - - private val nicknameChanges = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val entryChanges = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) /** The in-flight reservation request. Only one may be live at a time -- starting a new one cancels the old one. */ private var reserveJob: Job? = null @@ -72,10 +67,10 @@ class AddUsernameViewModel( .onEach { Log.d(TAG, "[State] $it") } .launchIn(viewModelScope) - nicknameChanges + entryChanges .distinctUntilChanged() - .debounce(NICKNAME_DEBOUNCE) - .onEach { onEvent(AddUsernameScreenEvents.NicknameSettled(it)) } + .debounce(ENTRY_DEBOUNCE) + .onEach { onEvent(it) } .launchIn(viewModelScope) } @@ -92,9 +87,11 @@ class AddUsernameViewModel( ) { when (event) { is AddUsernameScreenEvents.UsernameChanged -> applyUsernameChanged(state, event.value, stateEmitter) - is AddUsernameScreenEvents.NicknameSettled -> applyNicknameSettled(state, event.value, stateEmitter) + is AddUsernameScreenEvents.DiscriminatorChanged -> applyDiscriminatorChanged(state, event.value, stateEmitter) + is AddUsernameScreenEvents.EntrySettled -> applyEntrySettled(state, event, stateEmitter) is AddUsernameScreenEvents.ReservationCompleted -> applyReservationCompleted(state, event, stateEmitter) - is AddUsernameScreenEvents.LearnMoreClicked -> _actions.trySend(AddUsernameScreenActions.OpenLearnMoreArticle) + is AddUsernameScreenEvents.LearnMoreClicked -> stateEmitter(state.copy(dialogs = state.dialogs.copy(learnMore = true))) + is AddUsernameScreenEvents.LearnMoreDialogDismissed -> stateEmitter(state.copy(dialogs = state.dialogs.copy(learnMore = false))) is AddUsernameScreenEvents.SkipClicked -> applySkipClicked(parentEventEmitter) is AddUsernameScreenEvents.NextClicked -> applyNextClicked(state, parentEventEmitter, stateEmitter) is AddUsernameScreenEvents.NetworkErrorDialogDismissed -> applyDialogDismissed(state, stateEmitter) { it.copy(networkError = false) } @@ -112,28 +109,60 @@ class AddUsernameViewModel( reserveJob?.cancel() - stateEmitter( - state.copy( - username = username, - validationError = null, - reservation = null, - isReserving = false - ) + val updated = state.copy( + username = username, + validationError = null, + reservation = null, + isReserving = false ) - if (username.isNotBlank()) { - nicknameChanges.tryEmit(username) - } + stateEmitter(updated) + scheduleReservation(updated) } - private fun applyNicknameSettled(state: AddUsernameState, nickname: String, stateEmitter: (AddUsernameState) -> Unit) { - if (nickname != state.username || nickname.isBlank()) { + /** + * A blank discriminator hands control back to the service, matching the behavior of clearing the field in the app's + * username editor. + */ + private fun applyDiscriminatorChanged(state: AddUsernameState, discriminator: String, stateEmitter: (AddUsernameState) -> Unit) { + if (discriminator == state.discriminator) { return } - val validationError = checkNickname(nickname) - if (validationError != null) { - stateEmitter(state.copy(validationError = validationError)) + reserveJob?.cancel() + + val updated = state.copy( + discriminator = discriminator, + isDiscriminatorUserSet = discriminator.isNotBlank(), + validationError = null, + reservation = null, + isReserving = false + ) + + stateEmitter(updated) + scheduleReservation(updated) + } + + private fun scheduleReservation(state: AddUsernameState) { + if (state.username.isNotBlank()) { + entryChanges.tryEmit(AddUsernameScreenEvents.EntrySettled(state.username, state.requestedDiscriminator)) + } + } + + private fun applyEntrySettled(state: AddUsernameState, event: AddUsernameScreenEvents.EntrySettled, stateEmitter: (AddUsernameState) -> Unit) { + if (event.nickname != state.username || event.discriminator != state.requestedDiscriminator || event.nickname.isBlank()) { + return + } + + val nicknameError = checkNickname(event.nickname) + if (nicknameError != null) { + stateEmitter(state.copy(validationError = nicknameError)) + return + } + + val discriminatorError = event.discriminator?.let { checkDiscriminator(it) } + if (discriminatorError != null) { + stateEmitter(state.copy(validationError = discriminatorError)) return } @@ -141,8 +170,8 @@ class AddUsernameViewModel( reserveJob?.cancel() reserveJob = viewModelScope.launch { - val result = repository.reserveUsername(nickname) - onEvent(AddUsernameScreenEvents.ReservationCompleted(nickname, result)) + val result = repository.reserveUsername(event.nickname, event.discriminator) + onEvent(AddUsernameScreenEvents.ReservationCompleted(event.nickname, event.discriminator, result)) } } @@ -151,20 +180,32 @@ class AddUsernameViewModel( event: AddUsernameScreenEvents.ReservationCompleted, stateEmitter: (AddUsernameState) -> Unit ) { - if (event.nickname != state.username) { + if (event.nickname != state.username || event.discriminator != state.requestedDiscriminator) { return } when (val result = event.result) { is RequestResult.Success -> { Log.i(TAG, "Successfully reserved a username.") - stateEmitter(state.copy(isReserving = false, reservation = result.result)) + stateEmitter( + state.copy( + isReserving = false, + reservation = result.result, + discriminator = result.result.discriminator, + showDiscriminator = true + ) + ) } is RequestResult.NonSuccess -> when (result.error) { is ReserveUsernameError.NicknameInvalid, is ReserveUsernameError.NotAvailable -> { Log.w(TAG, "Could not reserve a username: ${result.error}") - stateEmitter(state.copy(isReserving = false, validationError = AddUsernameState.ValidationError.NOT_AVAILABLE)) + val error = if (event.discriminator != null) { + AddUsernameState.ValidationError.DISCRIMINATOR_NOT_AVAILABLE + } else { + AddUsernameState.ValidationError.NOT_AVAILABLE + } + stateEmitter(state.copy(isReserving = false, validationError = error)) } is ReserveUsernameError.RateLimited -> { @@ -254,7 +295,7 @@ class AddUsernameViewModel( stateEmitter(state.copy(dialogs = clearDialog(state.dialogs))) if (state.username.isNotBlank() && state.validationError == null && state.reservation == null && !state.isReserving) { - onEvent(AddUsernameScreenEvents.NicknameSettled(state.username)) + onEvent(AddUsernameScreenEvents.EntrySettled(state.username, state.requestedDiscriminator)) } } @@ -268,6 +309,17 @@ class AddUsernameViewModel( } } + private fun checkDiscriminator(discriminator: String): AddUsernameState.ValidationError? { + return when (UsernameUtil.checkDiscriminator(discriminator)) { + null -> null + UsernameUtil.InvalidReason.TOO_SHORT -> AddUsernameState.ValidationError.DISCRIMINATOR_TOO_SHORT + UsernameUtil.InvalidReason.TOO_LONG -> AddUsernameState.ValidationError.DISCRIMINATOR_TOO_LONG + UsernameUtil.InvalidReason.INVALID_NUMBER_00 -> AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_BE_00 + UsernameUtil.InvalidReason.INVALID_NUMBER_PREFIX_0 -> AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_START_WITH_ZERO + else -> AddUsernameState.ValidationError.DISCRIMINATOR_INVALID_CHARACTERS + } + } + class Factory( private val repository: RegistrationRepository, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit 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 6c420a61f9..14af05bc0e 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 @@ -71,6 +71,7 @@ object TestTags { // Add Username Screen const val ADD_USERNAME_SCREEN = "add_username_screen" const val ADD_USERNAME_FIELD = "add_username_field" + const val ADD_USERNAME_DISCRIMINATOR_FIELD = "add_username_discriminator_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" diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 204e53938d..afe93aba08 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -681,8 +681,12 @@ Username Usernames are always paired with a set of numbers. - + Learn more + + What is this number? + + These digits help keep your username private so you can avoid unwanted messages. Share your username with only the people and groups you\'d like to chat with. If you change usernames you\'ll get a new set of digits. Skip @@ -699,6 +703,18 @@ This username is not available. Please try another. Your username reservation expired. Please try again. + + This username is not available, try another number. + + Invalid username, enter a minimum of %1$d digits. + + Invalid username, enter a maximum of %1$d digits. + + Invalid username, numbers can only contain digits. + + This number can\'t be 00. Enter a digit between 1–9 + + Numbers with more than 2 digits can\'t start with 0 diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt index 6980076167..e63e000608 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt @@ -114,6 +114,8 @@ class FakeNetworkController( private set var lastReservedNickname: String? = null private set + var lastReservedDiscriminator: String? = null + private set var lastConfirmedUsername: Username? = null private set @@ -211,8 +213,8 @@ class FakeNetworkController( RequestResult.Success(Unit) } - var onReserveUsername: suspend (nickname: String) -> RequestResult = { nickname -> - RequestResult.Success(Username("$nickname.42")) + var onReserveUsername: suspend (nickname: String, discriminator: String?) -> RequestResult = { nickname, discriminator -> + RequestResult.Success(Username("$nickname.${discriminator ?: "42"}")) } var onConfirmUsername: suspend (Username) -> RequestResult = { username -> @@ -439,9 +441,10 @@ class FakeNetworkController( return RequestResult.Success(Unit) } - override suspend fun reserveUsername(nickname: String): RequestResult { + override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult { lastReservedNickname = nickname - return onReserveUsername(nickname) + lastReservedDiscriminator = discriminator + return onReserveUsername(nickname, discriminator) } override suspend fun confirmUsername(username: Username): RequestResult { diff --git a/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameScreenTest.kt new file mode 100644 index 0000000000..c28e2b2f18 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameScreenTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.addusername + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +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.compose.ui.test.performTextReplacement +import androidx.test.core.app.ApplicationProvider +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +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.libsignal.usernames.Username +import org.signal.registration.test.TestTags + +/** + * Tests for [AddUsernameScreen] that validate event emissions. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class AddUsernameScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + private val reservedState = AddUsernameState( + username = "maya", + discriminator = "45", + showDiscriminator = true, + reservation = Username("maya.45") + ) + + @Test + fun `the discriminator field is hidden until the service assigns one`() { + composeTestRule.setContent { + SignalTheme { + AddUsernameScreen(state = AddUsernameState(), onEvent = {}) + } + } + + composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_DISCRIMINATOR_FIELD).assertDoesNotExist() + } + + @Test + fun `the discriminator field is shown once one has been assigned`() { + composeTestRule.setContent { + SignalTheme { + AddUsernameScreen(state = reservedState, onEvent = {}) + } + } + + composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_DISCRIMINATOR_FIELD).assertIsDisplayed() + } + + @Test + fun `when typing in the discriminator field, DiscriminatorChanged is emitted`() { + var emittedEvent: AddUsernameScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + AddUsernameScreen(state = reservedState, onEvent = { emittedEvent = it }) + } + } + + composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_DISCRIMINATOR_FIELD).performTextReplacement("77") + + assertThat(emittedEvent).isNotNull() + assertThat(emittedEvent).isEqualTo(AddUsernameScreenEvents.DiscriminatorChanged("77")) + } + + @Test + fun `when typing in the username field, UsernameChanged is emitted`() { + var emittedEvent: AddUsernameScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + AddUsernameScreen(state = AddUsernameState(), onEvent = { emittedEvent = it }) + } + } + + composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_FIELD).performTextInput("maya") + + assertThat(emittedEvent).isEqualTo(AddUsernameScreenEvents.UsernameChanged("maya")) + } + + @Test + fun `skip emits SkipClicked`() { + var emittedEvent: AddUsernameScreenEvents? = null + + composeTestRule.setContent { + SignalTheme { + AddUsernameScreen(state = AddUsernameState(), onEvent = { emittedEvent = it }) + } + } + + composeTestRule.onNodeWithTag(TestTags.ADD_USERNAME_SKIP_BUTTON).performClick() + + assertThat(emittedEvent).isEqualTo(AddUsernameScreenEvents.SkipClicked) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt index e809924089..85bde4e0ac 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt @@ -17,8 +17,6 @@ import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -63,19 +61,17 @@ class AddUsernameViewModelTest { Dispatchers.resetMain() } - private fun TestScope.collectActions(): List { - val actions = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } } - return actions - } - @Test - fun `LearnMoreClicked emits an action to open the learn more article`() = runTest(testDispatcher) { - val actions = collectActions() + fun `LearnMoreClicked shows the dialog explaining the discriminator`() = runTest(testDispatcher) { + viewModel.onEvent(AddUsernameScreenEvents.LearnMoreClicked) + advanceUntilIdle() - viewModel.applyEvent(AddUsernameState(), AddUsernameScreenEvents.LearnMoreClicked, parentEventEmitter) {} + assertThat(viewModel.state.value.dialogs.learnMore).isTrue() - assertThat(actions).containsExactly(AddUsernameScreenActions.OpenLearnMoreArticle) + viewModel.onEvent(AddUsernameScreenEvents.LearnMoreDialogDismissed) + advanceUntilIdle() + + assertThat(viewModel.state.value.dialogs.learnMore).isFalse() } @Test @@ -90,6 +86,117 @@ class AddUsernameViewModelTest { assertThat(viewModel.state.value.isSubmittable).isTrue() } + @Test + fun `a successful reservation surfaces the service-assigned discriminator`() = runTest(testDispatcher) { + coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) + + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya")) + advanceUntilIdle() + + assertThat(viewModel.state.value.discriminator).isEqualTo("45") + assertThat(viewModel.state.value.showDiscriminator).isTrue() + assertThat(viewModel.state.value.isDiscriminatorUserSet).isFalse() + } + + @Test + fun `typing a discriminator reserves that exact username`() = runTest(testDispatcher) { + coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) + coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.Success(Username("maya.77")) + + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77")) + advanceUntilIdle() + + assertThat(viewModel.state.value.reservation).isEqualTo(Username("maya.77")) + assertThat(viewModel.state.value.isDiscriminatorUserSet).isTrue() + assertThat(viewModel.state.value.isSubmittable).isTrue() + } + + @Test + fun `editing the nickname keeps a user-chosen discriminator`() = runTest(testDispatcher) { + coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) + coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.Success(Username("maya.77")) + coEvery { mockRepository.reserveUsername("mayab", "77") } returns RequestResult.Success(Username("mayab.77")) + + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("mayab")) + advanceUntilIdle() + + assertThat(viewModel.state.value.reservation).isEqualTo(Username("mayab.77")) + } + + @Test + fun `clearing the discriminator hands it back to the service`() = runTest(testDispatcher) { + coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) + coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.Success(Username("maya.77")) + + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("")) + advanceUntilIdle() + + assertThat(viewModel.state.value.isDiscriminatorUserSet).isFalse() + assertThat(viewModel.state.value.discriminator).isEqualTo("45") + assertThat(viewModel.state.value.reservation).isEqualTo(Username("maya.45")) + } + + @Test + fun `a too-short discriminator produces a validation error`() = runTest(testDispatcher) { + coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) + + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("7")) + advanceUntilIdle() + + assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.DISCRIMINATOR_TOO_SHORT) + assertThat(viewModel.state.value.isSubmittable).isFalse() + } + + @Test + fun `a discriminator of 00 produces a validation error`() = runTest(testDispatcher) { + coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) + + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("00")) + advanceUntilIdle() + + assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_BE_00) + } + + @Test + fun `a discriminator with a leading zero produces a validation error`() = runTest(testDispatcher) { + coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) + + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("012")) + advanceUntilIdle() + + assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.DISCRIMINATOR_CANNOT_START_WITH_ZERO) + } + + @Test + fun `an unavailable user-chosen discriminator points the error at the number`() = runTest(testDispatcher) { + coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45")) + coEvery { mockRepository.reserveUsername("maya", "77") } returns RequestResult.NonSuccess(ReserveUsernameError.NotAvailable) + + viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya")) + advanceUntilIdle() + viewModel.onEvent(AddUsernameScreenEvents.DiscriminatorChanged("77")) + advanceUntilIdle() + + assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.DISCRIMINATOR_NOT_AVAILABLE) + assertThat(viewModel.state.value.isSubmittable).isFalse() + } + @Test fun `editing the nickname clears any existing reservation`() = runTest(testDispatcher) { coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))