mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Allow editing username discriminent in AddUsernameScreen.
This commit is contained in:
committed by
Michelle Tang
parent
f529caca5b
commit
3b2bf29ba0
+2
-2
@@ -456,8 +456,8 @@ class AppRegistrationNetworkController(
|
||||
RequestResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
|
||||
return AppDependencies.usernameService.reserveUsername(nickname)
|
||||
override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult<Username, ReserveUsernameError> {
|
||||
return AppDependencies.usernameService.reserveUsername(nickname, discriminator)
|
||||
}
|
||||
|
||||
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
|
||||
|
||||
+2
-2
@@ -260,12 +260,12 @@ class DebugNetworkController(
|
||||
return delegate.setProfile(givenName, familyName, avatar, discoverableByPhoneNumber)
|
||||
}
|
||||
|
||||
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
|
||||
override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult<Username, ReserveUsernameError> {
|
||||
NetworkDebugState.getOverride<RequestResult<Username, ReserveUsernameError>>("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<ConfirmedUsername, ConfirmUsernameError> {
|
||||
|
||||
+7
-2
@@ -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<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
|
||||
override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
|
||||
val aci = RegistrationPreferences.aci
|
||||
val password = RegistrationPreferences.servicePassword
|
||||
|
||||
@@ -967,7 +968,11 @@ class DemoNetworkController(
|
||||
}
|
||||
|
||||
val candidates: List<Username> = 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)
|
||||
|
||||
@@ -416,13 +416,14 @@ interface NetworkController {
|
||||
suspend fun restoreAccountRecord(timeout: Duration): RequestResult<Unit, RestoreAccountRecordError>
|
||||
|
||||
/**
|
||||
* 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<Username, ReserveUsernameError>
|
||||
suspend fun reserveUsername(nickname: String, discriminator: String? = null): RequestResult<Username, ReserveUsernameError>
|
||||
|
||||
/**
|
||||
* Confirms a reservation previously made via [reserveUsername], assigning the username to the account and creating
|
||||
|
||||
@@ -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<NavKey>.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,
|
||||
|
||||
+3
-3
@@ -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<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
|
||||
networkController.reserveUsername(nickname)
|
||||
suspend fun reserveUsername(nickname: String, discriminator: String? = null): RequestResult<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
|
||||
networkController.reserveUsername(nickname, discriminator)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+103
-38
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
-11
@@ -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
|
||||
}
|
||||
+17
-6
@@ -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<Username, ReserveUsernameError>) : 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<Username, ReserveUsernameError>) : 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()
|
||||
|
||||
|
||||
+24
-3
@@ -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. */
|
||||
|
||||
+90
-38
@@ -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<AddUsernameState> = _state.asStateFlow()
|
||||
|
||||
private val _actions = Channel<AddUsernameScreenActions>(Channel.BUFFERED)
|
||||
val actions: Flow<AddUsernameScreenActions> = _actions.receiveAsFlow()
|
||||
|
||||
private val nicknameChanges = MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private val entryChanges = MutableSharedFlow<AddUsernameScreenEvents.EntrySettled>(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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -681,8 +681,12 @@
|
||||
<string name="AddUsernameScreen__username">Username</string>
|
||||
<!-- Helper text below the username field. Followed by a "learn more" link. -->
|
||||
<string name="AddUsernameScreen__usernames_are_always_paired_with_a_set_of_numbers">Usernames are always paired with a set of numbers.</string>
|
||||
<!-- Link in the helper text that opens a support article about usernames. -->
|
||||
<!-- Link in the helper text that opens an explanation of the digits paired with a username. -->
|
||||
<string name="AddUsernameScreen__learn_more">Learn more</string>
|
||||
<!-- Title of the dialog explaining the digits paired with a username. -->
|
||||
<string name="AddUsernameScreen__what_is_this_number">What is this number?</string>
|
||||
<!-- Body of the dialog explaining the digits paired with a username. -->
|
||||
<string name="AddUsernameScreen__these_digits_help_keep">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.</string>
|
||||
<!-- Button that skips choosing a username. -->
|
||||
<string name="AddUsernameScreen__skip">Skip</string>
|
||||
<!-- Button that submits the chosen username. -->
|
||||
@@ -699,6 +703,18 @@
|
||||
<string name="AddUsernameScreen__this_username_is_not_available">This username is not available. Please try another.</string>
|
||||
<!-- Error shown when the user\'s temporary hold on a username expired before they submitted it. -->
|
||||
<string name="AddUsernameScreen__your_username_reservation_expired">Your username reservation expired. Please try again.</string>
|
||||
<!-- Error shown when the username formed by the entered name and the user's chosen number is already taken. -->
|
||||
<string name="AddUsernameScreen__this_username_is_not_available_try_another_number">This username is not available, try another number.</string>
|
||||
<!-- Validation error shown when the number the user entered after their username has too few digits. -->
|
||||
<string name="AddUsernameScreen__enter_a_minimum_of_d_digits">Invalid username, enter a minimum of %1$d digits.</string>
|
||||
<!-- Validation error shown when the number the user entered after their username has too many digits. -->
|
||||
<string name="AddUsernameScreen__enter_a_maximum_of_d_digits">Invalid username, enter a maximum of %1$d digits.</string>
|
||||
<!-- Validation error shown when the number the user entered after their username contains something other than digits. -->
|
||||
<string name="AddUsernameScreen__numbers_can_only_contain_digits">Invalid username, numbers can only contain digits.</string>
|
||||
<!-- Validation error shown when the number the user entered after their username is 00. -->
|
||||
<string name="AddUsernameScreen__this_number_cant_be_00">This number can\'t be 00. Enter a digit between 1–9</string>
|
||||
<!-- Validation error shown when the number the user entered after their username has a leading zero. -->
|
||||
<string name="AddUsernameScreen__this_number_cant_start_with_0">Numbers with more than 2 digits can\'t start with 0</string>
|
||||
|
||||
<!-- ContactSupportDialog -->
|
||||
<!-- Title of the dialog asking the user whether they want to attach a debug log to their support request -->
|
||||
|
||||
+7
-4
@@ -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<Username, ReserveUsernameError> = { nickname ->
|
||||
RequestResult.Success(Username("$nickname.42"))
|
||||
var onReserveUsername: suspend (nickname: String, discriminator: String?) -> RequestResult<Username, ReserveUsernameError> = { nickname, discriminator ->
|
||||
RequestResult.Success(Username("$nickname.${discriminator ?: "42"}"))
|
||||
}
|
||||
|
||||
var onConfirmUsername: suspend (Username) -> RequestResult<ConfirmedUsername, ConfirmUsernameError> = { username ->
|
||||
@@ -439,9 +441,10 @@ class FakeNetworkController(
|
||||
return RequestResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
|
||||
override suspend fun reserveUsername(nickname: String, discriminator: String?): RequestResult<Username, ReserveUsernameError> {
|
||||
lastReservedNickname = nickname
|
||||
return onReserveUsername(nickname)
|
||||
lastReservedDiscriminator = discriminator
|
||||
return onReserveUsername(nickname, discriminator)
|
||||
}
|
||||
|
||||
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
|
||||
|
||||
+116
@@ -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)
|
||||
}
|
||||
}
|
||||
+119
-12
@@ -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<AddUsernameScreenActions> {
|
||||
val actions = mutableListOf<AddUsernameScreenActions>()
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user