Add ability to set custom username discriminators.

This commit is contained in:
Alex Hart
2024-01-09 11:37:39 -04:00
committed by GitHub
parent fb75440769
commit 17a6fcafa1
12 changed files with 952 additions and 75 deletions

View File

@@ -3,14 +3,12 @@ package org.thoughtcrime.securesms.profiles.manage;
import android.animation.LayoutTransition;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -25,25 +23,22 @@ import androidx.navigation.fragment.NavHostFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.textfield.TextInputLayout;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.databinding.UsernameEditFragmentBinding;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FragmentResultContract;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
import java.util.Objects;
public class UsernameEditFragment extends LoggingFragment {
private static final float DISABLED_ALPHA = 0.5f;
public static final String IGNORE_TEXT_CHANGE_EVENT = "ignore.text.change.event";
private UsernameEditViewModel viewModel;
private UsernameEditFragmentBinding binding;
@@ -99,6 +94,7 @@ public class UsernameEditFragment extends LoggingFragment {
lifecycleDisposable.add(viewModel.getUiState().subscribe(this::onUiStateChanged));
lifecycleDisposable.add(viewModel.getEvents().subscribe(this::onEvent));
lifecycleDisposable.add(viewModel.getUsernameInputState().subscribe(this::presentUsernameInputState));
binding.usernameSubmitButton.setOnClickListener(v -> viewModel.onUsernameSubmitted());
binding.usernameDeleteButton.setOnClickListener(v -> viewModel.onUsernameDeleted());
@@ -112,10 +108,23 @@ public class UsernameEditFragment extends LoggingFragment {
binding.usernameText.addTextChangedListener(new SimpleTextWatcher() {
@Override
public void onTextChanged(@NonNull String text) {
if (binding.usernameText.getTag() != IGNORE_TEXT_CHANGE_EVENT) {
viewModel.onNicknameUpdated(text);
}
}
});
binding.usernameText.setOnEditorActionListener((v, actionId, event) -> {
binding.discriminatorText.setText(usernameState.getDiscriminator());
binding.discriminatorText.addTextChangedListener(new SimpleTextWatcher() {
@Override
public void onTextChanged(@NonNull String text) {
if (binding.discriminatorText.getTag() != IGNORE_TEXT_CHANGE_EVENT) {
viewModel.onDiscriminatorUpdated(text);
}
}
});
binding.discriminatorText.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
viewModel.onUsernameSubmitted();
return true;
@@ -127,24 +136,9 @@ public class UsernameEditFragment extends LoggingFragment {
binding.usernameDescription.setLearnMoreVisible(true);
binding.usernameDescription.setOnLinkClickListener(this::onLearnMore);
initializeSuffix();
ViewUtil.focusAndShowKeyboard(binding.usernameText);
}
private void initializeSuffix() {
TextView suffixTextView = binding.usernameTextWrapper.getSuffixTextView();
Drawable pipe = Objects.requireNonNull(ContextCompat.getDrawable(requireContext(), R.drawable.pipe_divider));
pipe.setBounds(0, 0, (int) DimensionUnit.DP.toPixels(1f), (int) DimensionUnit.DP.toPixels(20f));
suffixTextView.setCompoundDrawablesRelative(pipe, null, null, null);
ViewUtil.setLeftMargin(suffixTextView, (int) DimensionUnit.DP.toPixels(16f));
binding.usernameTextWrapper.getSuffixTextView().setCompoundDrawablePadding((int) DimensionUnit.DP.toPixels(16f));
suffixTextView.setOnClickListener(this::onLearnMore);
}
@Override
public void onDestroyView() {
super.onDestroyView();
@@ -162,7 +156,7 @@ public class UsernameEditFragment extends LoggingFragment {
private void onUiStateChanged(@NonNull UsernameEditViewModel.State state) {
TextInputLayout usernameInputWrapper = binding.usernameTextWrapper;
presentSuffix(state.username);
presentProgressState(state.username);
presentButtonState(state.buttonState);
presentSummary(state.username);
@@ -174,7 +168,7 @@ public class UsernameEditFragment extends LoggingFragment {
break;
case TOO_SHORT:
case TOO_LONG:
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_usernames_must_be_between_a_and_b_characters, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH));
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_usernames_must_be_between_a_and_b_characters, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
@@ -197,6 +191,22 @@ public class UsernameEditFragment extends LoggingFragment {
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_this_username_is_taken));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
case DISCRIMINATOR_HAS_INVALID_CHARACTERS:
case DISCRIMINATOR_NOT_AVAILABLE:
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment__this_username_is_not_available_try_another_number));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
case DISCRIMINATOR_TOO_LONG:
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment__invalid_username_enter_a_maximum_of_d_digits, UsernameUtil.MAX_DISCRIMINATOR_LENGTH));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
case DISCRIMINATOR_TOO_SHORT:
usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment__invalid_username_enter_a_minimum_of_d_digits, UsernameUtil.MIN_DISCRIMINATOR_LENGTH));
usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_colorError)));
break;
}
@@ -302,9 +312,25 @@ public class UsernameEditFragment extends LoggingFragment {
}
}
private void presentSuffix(@NonNull UsernameState usernameState) {
binding.usernameTextWrapper.setSuffixText(usernameState.getDiscriminator());
private void presentUsernameInputState(@NonNull UsernameEditStateMachine.State state) {
binding.usernameText.setTag(IGNORE_TEXT_CHANGE_EVENT);
String nickname = state.getNickname();
if (!binding.usernameText.getText().toString().equals(nickname)) {
binding.usernameText.setText(state.getNickname());
binding.usernameText.setSelection(binding.usernameText.length());
}
binding.usernameText.setTag(null);
binding.discriminatorText.setTag(IGNORE_TEXT_CHANGE_EVENT);
String discriminator = state.getDiscriminator();
if (!binding.discriminatorText.getText().toString().equals(discriminator)) {
binding.discriminatorText.setText(state.getDiscriminator());
binding.discriminatorText.setSelection(binding.discriminatorText.length());
}
binding.discriminatorText.setTag(null);
}
private void presentProgressState(@NonNull UsernameState usernameState) {
boolean isInProgress = usernameState.isInProgress();
if (isInProgress) {

View File

@@ -0,0 +1,216 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.profiles.manage
object UsernameEditStateMachine {
enum class StateModifier {
USER,
SYSTEM
}
sealed class State {
abstract val nickname: String
abstract val discriminator: String
abstract val stateModifier: StateModifier
abstract fun onUserChangedNickname(nickname: String): State
abstract fun onUserChangedDiscriminator(discriminator: String): State
abstract fun onSystemChangedNickname(nickname: String): State
abstract fun onSystemChangedDiscriminator(discriminator: String): State
}
/**
* This state is representative of when the user has not manually changed either field in
* the form, and it is assumed that both values are either blank or system provided.
*
* This can be thought of as our "initial state" and can be pre-populated with username information
* for the local user.
*/
data class NoUserEntry(
override val nickname: String,
override val discriminator: String,
override val stateModifier: StateModifier
) : State() {
override fun onUserChangedNickname(nickname: String): State {
return if (nickname.isBlank()) {
NoUserEntry(
nickname = "",
discriminator = discriminator,
stateModifier = StateModifier.USER
)
} else {
UserEnteredNickname(
nickname = nickname,
discriminator = discriminator,
stateModifier = StateModifier.USER
)
}
}
override fun onUserChangedDiscriminator(discriminator: String): State {
return if (discriminator.isBlank()) {
NoUserEntry(
nickname = nickname,
discriminator = "",
stateModifier = StateModifier.USER
)
} else {
UserEnteredDiscriminator(
nickname = nickname,
discriminator = discriminator,
stateModifier = StateModifier.USER
)
}
}
override fun onSystemChangedNickname(nickname: String): State {
return copy(nickname = nickname, stateModifier = StateModifier.SYSTEM)
}
override fun onSystemChangedDiscriminator(discriminator: String): State {
return copy(discriminator = discriminator, stateModifier = StateModifier.SYSTEM)
}
}
/**
* The user has altered the nickname field with something that is non-empty.
* The user has not altered the discriminator field.
*/
data class UserEnteredNickname(
override val nickname: String,
override val discriminator: String,
override val stateModifier: StateModifier
) : State() {
override fun onUserChangedNickname(nickname: String): State {
return if (nickname.isBlank()) {
NoUserEntry(
nickname = "",
discriminator = discriminator,
stateModifier = StateModifier.USER
)
} else {
copy(nickname = nickname, stateModifier = StateModifier.USER)
}
}
override fun onUserChangedDiscriminator(discriminator: String): State {
return if (discriminator.isBlank()) {
copy(discriminator = "", stateModifier = StateModifier.USER)
} else {
UserEnteredNicknameAndDiscriminator(
nickname = nickname,
discriminator = discriminator,
stateModifier = StateModifier.USER
)
}
}
override fun onSystemChangedNickname(nickname: String): State {
return NoUserEntry(
nickname = nickname,
discriminator = discriminator,
stateModifier = StateModifier.SYSTEM
)
}
override fun onSystemChangedDiscriminator(discriminator: String): State {
return copy(discriminator = discriminator, stateModifier = StateModifier.SYSTEM)
}
}
/**
* The user has altered the discriminator field with something that is non-empty.
* The user has not altered the nickname field.
*/
data class UserEnteredDiscriminator(
override val nickname: String,
override val discriminator: String,
override val stateModifier: StateModifier
) : State() {
override fun onUserChangedNickname(nickname: String): State {
return if (nickname.isBlank()) {
copy(nickname = nickname, stateModifier = StateModifier.USER)
} else if (discriminator.isBlank()) {
UserEnteredNickname(
nickname = nickname,
discriminator = "",
stateModifier = StateModifier.USER
)
} else {
UserEnteredNicknameAndDiscriminator(
nickname = nickname,
discriminator = discriminator,
stateModifier = StateModifier.USER
)
}
}
override fun onUserChangedDiscriminator(discriminator: String): State {
return copy(discriminator = discriminator, stateModifier = StateModifier.USER)
}
override fun onSystemChangedNickname(nickname: String): State {
return copy(nickname = nickname, stateModifier = StateModifier.SYSTEM)
}
override fun onSystemChangedDiscriminator(discriminator: String): State {
return NoUserEntry(
nickname = nickname,
discriminator = discriminator,
stateModifier = StateModifier.SYSTEM
)
}
}
/**
* The user has altered the nickname field with something that is non-empty.
* The user has altered the discriminator field with something that is non-empty.
*/
data class UserEnteredNicknameAndDiscriminator(
override val nickname: String,
override val discriminator: String,
override val stateModifier: StateModifier
) : State() {
override fun onUserChangedNickname(nickname: String): State {
return if (nickname.isBlank()) {
UserEnteredDiscriminator(
nickname = "",
discriminator = discriminator,
stateModifier = StateModifier.USER
)
} else if (discriminator.isBlank()) {
UserEnteredNickname(
nickname = nickname,
discriminator = "",
stateModifier = StateModifier.USER
)
} else {
copy(nickname = nickname, stateModifier = StateModifier.USER)
}
}
override fun onUserChangedDiscriminator(discriminator: String): State {
return copy(discriminator = discriminator, stateModifier = StateModifier.USER)
}
override fun onSystemChangedNickname(nickname: String): State {
return UserEnteredDiscriminator(
nickname = nickname,
discriminator = discriminator,
stateModifier = StateModifier.SYSTEM
)
}
override fun onSystemChangedDiscriminator(discriminator: String): State {
return UserEnteredNickname(
nickname = nickname,
discriminator = discriminator,
stateModifier = StateModifier.SYSTEM
)
}
}
}

View File

@@ -5,9 +5,10 @@ import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.Result
@@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameDeleteResult
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameSetResult
import org.thoughtcrime.securesms.util.UsernameUtil.InvalidReason
import org.thoughtcrime.securesms.util.UsernameUtil.checkDiscriminator
import org.thoughtcrime.securesms.util.UsernameUtil.checkUsername
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.concurrent.TimeUnit
@@ -32,7 +34,6 @@ import java.util.concurrent.TimeUnit
*/
internal class UsernameEditViewModel private constructor(private val isInRegistration: Boolean) : ViewModel() {
private val events: PublishSubject<Event> = PublishSubject.create()
private val nicknamePublisher: PublishProcessor<String> = PublishProcessor.create()
private val disposables: CompositeDisposable = CompositeDisposable()
private val uiState: RxStore<State> = RxStore(
@@ -44,10 +45,23 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr
scheduler = Schedulers.computation()
)
private val stateMachineStore = RxStore<UsernameEditStateMachine.State>(
defaultValue = UsernameEditStateMachine.NoUserEntry(
nickname = SignalStore.account().username?.split(UsernameState.DELIMITER)?.first() ?: "",
discriminator = SignalStore.account().username?.split(UsernameState.DELIMITER)?.last() ?: "",
stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM
),
scheduler = Schedulers.computation()
)
val usernameInputState: Flowable<UsernameEditStateMachine.State> = stateMachineStore.stateFlowable.observeOn(AndroidSchedulers.mainThread())
init {
disposables += nicknamePublisher
disposables += stateMachineStore
.stateFlowable
.filter { it.stateModifier == UsernameEditStateMachine.StateModifier.USER }
.debounce(NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.subscribe { nickname: String -> onNicknameUpdatedDebounced(nickname) }
.subscribeBy(onNext = this::onUsernameStateUpdateDebounced)
}
override fun onCleared() {
@@ -85,7 +99,31 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr
}
}
nicknamePublisher.onNext(nickname)
stateMachineStore.update {
it.onUserChangedNickname(nickname)
}
}
fun onDiscriminatorUpdated(discriminator: String) {
uiState.update { state: State ->
if (discriminator.isBlank() && SignalStore.account().username != null) {
return@update State(
buttonState = if (isInRegistration) ButtonState.SUBMIT_DISABLED else ButtonState.DELETE,
usernameStatus = UsernameStatus.NONE,
username = UsernameState.NoUsername
)
}
State(
buttonState = ButtonState.SUBMIT_DISABLED,
usernameStatus = UsernameStatus.NONE,
username = state.username
)
}
stateMachineStore.update {
it.onUserChangedDiscriminator(discriminator)
}
}
fun onUsernameSkipped() {
@@ -94,26 +132,38 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr
}
fun onUsernameSubmitted() {
val state = stateMachineStore.state
if (isCaseChange(state)) {
handleUserConfirmation(UsernameRepository::updateUsername)
} else {
handleUserConfirmation(UsernameRepository::confirmUsername)
}
}
private inline fun <reified T : UsernameState> handleUserConfirmation(
repositoryWorkerMethod: (T) -> Single<UsernameSetResult>
) {
val usernameState = uiState.state.username
if (usernameState !is UsernameState.Reserved) {
if (usernameState !is T) {
uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, it.username) }
return
}
if (usernameState.username == SignalStore.account().username) {
if (usernameState.requireUsername() == SignalStore.account().username) {
uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, it.username) }
return
}
val invalidReason = checkUsername(usernameState.getNickname())
if (invalidReason != null) {
uiState.update { State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason), it.username) }
uiState.update { State(ButtonState.SUBMIT_DISABLED, mapNicknameError(invalidReason), it.username) }
return
}
uiState.update { State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE, it.username) }
disposables += UsernameRepository.confirmUsername(usernameState).subscribe { result: UsernameSetResult ->
disposables += repositoryWorkerMethod(usernameState).subscribe { result: UsernameSetResult ->
val nickname = usernameState.getNickname()
when (result) {
@@ -169,19 +219,49 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr
return events.observeOn(AndroidSchedulers.mainThread())
}
private fun isCaseChange(state: UsernameEditStateMachine.State): Boolean {
if (state is UsernameEditStateMachine.UserEnteredDiscriminator || state is UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator) {
return false
}
val newLower = state.nickname.lowercase()
val oldLower = SignalStore.account().username?.split(UsernameState.DELIMITER)?.firstOrNull()?.lowercase()
return newLower == oldLower
}
/** Triggered when the debounced nickname event stream fires. */
private fun onNicknameUpdatedDebounced(nickname: String) {
private fun onUsernameStateUpdateDebounced(state: UsernameEditStateMachine.State) {
val nickname = state.nickname
if (nickname.isBlank()) {
return
}
if (state is UsernameEditStateMachine.NoUserEntry || state.stateModifier == UsernameEditStateMachine.StateModifier.SYSTEM) {
return
}
if (isCaseChange(state)) {
val discriminator = SignalStore.account().username?.split(UsernameState.DELIMITER)?.lastOrNull() ?: error("Unexpected case change, no discriminator!")
uiState.update {
State(
buttonState = ButtonState.SUBMIT,
usernameStatus = UsernameStatus.NONE,
username = UsernameState.CaseChange("${state.nickname}${UsernameState.DELIMITER}$discriminator")
)
}
stateMachineStore.update { s -> s.onSystemChangedDiscriminator(discriminator) }
return
}
val invalidReason: InvalidReason? = checkUsername(nickname)
if (invalidReason != null) {
uiState.update { state ->
uiState.update { uiState ->
State(
buttonState = ButtonState.SUBMIT_DISABLED,
usernameStatus = mapUsernameError(invalidReason),
username = state.username
usernameStatus = mapNicknameError(invalidReason),
username = uiState.username
)
}
return
@@ -189,26 +269,60 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr
uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, UsernameState.Loading) }
disposables += UsernameRepository.reserveUsername(nickname).subscribe { result: Result<UsernameState.Reserved, UsernameSetResult> ->
val isDiscriminatorSetByUser = state is UsernameEditStateMachine.UserEnteredDiscriminator || state is UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator
val discriminator = if (isDiscriminatorSetByUser) {
state.discriminator
} else {
null
}
val discriminatorInvalidReason = checkDiscriminator(discriminator)
if (isDiscriminatorSetByUser && discriminatorInvalidReason != null) {
uiState.update { s ->
State(
buttonState = ButtonState.SUBMIT_DISABLED,
usernameStatus = mapDiscriminatorError(discriminatorInvalidReason),
username = s.username
)
}
return
}
disposables += UsernameRepository.reserveUsername(nickname, discriminator).subscribe { result: Result<UsernameState.Reserved, UsernameSetResult> ->
result.either(
onSuccess = { reserved: UsernameState.Reserved ->
uiState.update { State(ButtonState.SUBMIT, UsernameStatus.NONE, reserved) }
val d = reserved.getDiscriminator()
if (!isDiscriminatorSetByUser && d != null) {
stateMachineStore.update { s -> s.onSystemChangedDiscriminator(d) }
}
},
onFailure = { failure: UsernameSetResult ->
when (failure) {
UsernameSetResult.SUCCESS -> {
throw AssertionError()
}
UsernameSetResult.USERNAME_INVALID -> {
uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC, UsernameState.NoUsername) }
}
UsernameSetResult.USERNAME_UNAVAILABLE -> {
uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, UsernameState.NoUsername) }
val status = if (isDiscriminatorSetByUser) {
UsernameStatus.DISCRIMINATOR_NOT_AVAILABLE
} else {
UsernameStatus.TAKEN
}
uiState.update { State(ButtonState.SUBMIT_DISABLED, status, UsernameState.NoUsername) }
}
UsernameSetResult.NETWORK_ERROR -> {
uiState.update { State(ButtonState.SUBMIT, UsernameStatus.NONE, UsernameState.NoUsername) }
events.onNext(Event.NETWORK_FAILURE)
}
UsernameSetResult.CANDIDATE_GENERATION_ERROR -> {
// TODO -- Retry
uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, UsernameState.NoUsername) }
@@ -226,7 +340,17 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr
)
enum class UsernameStatus {
NONE, TAKEN, TOO_SHORT, TOO_LONG, CANNOT_START_WITH_NUMBER, INVALID_CHARACTERS, INVALID_GENERIC
NONE,
TAKEN,
TOO_SHORT,
TOO_LONG,
CANNOT_START_WITH_NUMBER,
INVALID_CHARACTERS,
INVALID_GENERIC,
DISCRIMINATOR_NOT_AVAILABLE,
DISCRIMINATOR_TOO_SHORT,
DISCRIMINATOR_TOO_LONG,
DISCRIMINATOR_HAS_INVALID_CHARACTERS
}
enum class ButtonState {
@@ -246,7 +370,7 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr
companion object {
private const val NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS: Long = 1000
private fun mapUsernameError(invalidReason: InvalidReason): UsernameStatus {
private fun mapNicknameError(invalidReason: InvalidReason): UsernameStatus {
return when (invalidReason) {
InvalidReason.TOO_SHORT -> UsernameStatus.TOO_SHORT
InvalidReason.TOO_LONG -> UsernameStatus.TOO_LONG
@@ -255,5 +379,14 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr
else -> UsernameStatus.INVALID_GENERIC
}
}
private fun mapDiscriminatorError(invalidReason: InvalidReason): UsernameStatus {
return when (invalidReason) {
InvalidReason.TOO_SHORT -> UsernameStatus.DISCRIMINATOR_TOO_SHORT
InvalidReason.TOO_LONG -> UsernameStatus.DISCRIMINATOR_TOO_LONG
InvalidReason.INVALID_CHARACTERS -> UsernameStatus.DISCRIMINATOR_HAS_INVALID_CHARACTERS
else -> UsernameStatus.INVALID_GENERIC
}
}
}
}

View File

@@ -88,12 +88,21 @@ object UsernameRepository {
private val accountManager: SignalServiceAccountManager get() = ApplicationDependencies.getSignalServiceAccountManager()
/**
* Given a username, update the username link, given that all was changed was the casing of the nickname.
*/
fun updateUsername(caseChange: UsernameState.CaseChange): Single<UsernameSetResult> {
return Single
.fromCallable { updateUsernameInternal(caseChange) }
.subscribeOn(Schedulers.io())
}
/**
* Given a nickname, this will temporarily reserve a matching discriminator that can later be confirmed via [confirmUsername].
*/
fun reserveUsername(nickname: String): Single<Result<UsernameState.Reserved, UsernameSetResult>> {
fun reserveUsername(nickname: String, discriminator: String?): Single<Result<UsernameState.Reserved, UsernameSetResult>> {
return Single
.fromCallable { reserveUsernameInternal(nickname) }
.fromCallable { reserveUsernameInternal(nickname, discriminator) }
.subscribeOn(Schedulers.io())
}
@@ -295,9 +304,13 @@ object UsernameRepository {
}
@WorkerThread
private fun reserveUsernameInternal(nickname: String): Result<UsernameState.Reserved, UsernameSetResult> {
private fun reserveUsernameInternal(nickname: String, discriminator: String?): Result<UsernameState.Reserved, UsernameSetResult> {
return try {
val candidates: List<Username> = Username.candidatesFrom(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH)
val candidates: List<Username> = if (discriminator == null) {
Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH)
} else {
listOf(Username("$nickname${UsernameState.DELIMITER}$discriminator"))
}
val hashes: List<String> = candidates
.map { Base64.encodeUrlSafeWithoutPadding(it.hash) }
@@ -327,6 +340,31 @@ object UsernameRepository {
}
}
@WorkerThread
private fun updateUsernameInternal(caseChange: UsernameState.CaseChange): UsernameSetResult {
return try {
val oldUsernameLink = SignalStore.account().usernameLink ?: return UsernameSetResult.USERNAME_INVALID
val username = Username(caseChange.username)
val newUsernameLink = username.generateLink(oldUsernameLink.entropy)
val usernameLinkComponents = accountManager.updateUsernameLink(newUsernameLink)
SignalStore.account().username = username.username
SignalStore.account().usernameLink = usernameLinkComponents
SignalDatabase.recipients.setUsername(Recipient.self().id, username.username)
SignalStore.account().usernameSyncState = AccountValues.UsernameSyncState.IN_SYNC
SignalStore.account().usernameSyncErrorCount = 0
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
Log.i(TAG, "[updateUsername] Successfully updated username.")
UsernameSetResult.SUCCESS
} catch (e: IOException) {
Log.w(TAG, "[updateUsername] Generic network exception.", e)
UsernameSetResult.NETWORK_ERROR
}
}
@WorkerThread
private fun confirmUsernameInternal(reserved: UsernameState.Reserved): UsernameSetResult {
return try {

View File

@@ -10,6 +10,8 @@ sealed class UsernameState {
protected open val username: String? = null
open val isInProgress: Boolean = false
fun requireUsername(): String = username!!
object Loading : UsernameState() {
override val isInProgress: Boolean = true
}
@@ -21,6 +23,10 @@ sealed class UsernameState {
val reserveUsernameResponse: ReserveUsernameResponse
) : UsernameState()
data class CaseChange(
public override val username: String
) : UsernameState()
data class Set(
override val username: String
) : UsernameState()

View File

@@ -6,9 +6,11 @@ import java.util.regex.Pattern
object UsernameUtil {
private val TAG = Log.tag(UsernameUtil::class.java)
const val MIN_LENGTH = 3
const val MAX_LENGTH = 32
private val FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_LENGTH - 1, MAX_LENGTH - 1), Pattern.CASE_INSENSITIVE)
const val MIN_NICKNAME_LENGTH = 3
const val MAX_NICKNAME_LENGTH = 32
const val MIN_DISCRIMINATOR_LENGTH = 2
const val MAX_DISCRIMINATOR_LENGTH = 10
private val FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_NICKNAME_LENGTH - 1, MAX_NICKNAME_LENGTH - 1), Pattern.CASE_INSENSITIVE)
private val DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$")
private const val BASE_URL_SCHEMELESS = "signal.me/#eu/"
private const val BASE_URL = "https://$BASE_URL_SCHEMELESS"
@@ -17,8 +19,8 @@ object UsernameUtil {
String.format(
Locale.US,
"^@?[a-zA-Z_][a-zA-Z0-9_]{%d,%d}(.[0-9]+)?$",
MIN_LENGTH - 1,
MAX_LENGTH - 1,
MIN_NICKNAME_LENGTH - 1,
MAX_NICKNAME_LENGTH - 1,
Pattern.CASE_INSENSITIVE
)
)
@@ -39,10 +41,10 @@ object UsernameUtil {
value == null -> {
InvalidReason.TOO_SHORT
}
value.length < MIN_LENGTH -> {
value.length < MIN_NICKNAME_LENGTH -> {
InvalidReason.TOO_SHORT
}
value.length > MAX_LENGTH -> {
value.length > MAX_NICKNAME_LENGTH -> {
InvalidReason.TOO_LONG
}
DIGIT_START_PATTERN.matcher(value).matches() -> {
@@ -57,6 +59,26 @@ object UsernameUtil {
}
}
fun checkDiscriminator(value: String?): InvalidReason? {
return when {
value == null -> {
null
}
value.length < MIN_DISCRIMINATOR_LENGTH -> {
InvalidReason.TOO_SHORT
}
value.length > MAX_DISCRIMINATOR_LENGTH -> {
InvalidReason.TOO_LONG
}
value.toIntOrNull() == null -> {
InvalidReason.INVALID_CHARACTERS
}
else -> {
null
}
}
}
enum class InvalidReason {
TOO_SHORT,
TOO_LONG,

View File

@@ -35,24 +35,34 @@
app:srcCompat="@drawable/symbol_at_24"
app:tint="@color/signal_colorOnSurface" />
<View
android:id="@+id/username_box_fill"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/signal_colorSurfaceVariant"
app:layout_constraintBottom_toBottomOf="@id/username_text_wrapper"
app:layout_constraintEnd_toEndOf="@id/discriminator_text"
app:layout_constraintStart_toStartOf="@id/username_text_wrapper"
app:layout_constraintTop_toTopOf="@id/username_text_wrapper" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/username_text_wrapper"
style="@style/Widget.Signal.TextInputLayout"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="24dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:layout_marginEnd="16dp"
app:boxStrokeColor="@color/signal_colorPrimary"
app:boxStrokeWidthFocused="2dp"
app:boxStrokeWidth="0dp"
app:boxStrokeWidthFocused="0dp"
app:errorTextAppearance="@style/Signal.Text.Zero"
app:expandedHintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@id/suffix_progress"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/summary"
app:suffixTextColor="@color/signal_colorOnSurface"
tools:errorEnabled="true"
tools:suffixText="| .1234">
app:suffixTextColor="@color/signal_colorOnSurface">
<EditText
android:id="@+id/username_text"
@@ -60,7 +70,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/UsernameEditFragment_username"
android:imeOptions="actionDone"
android:imeOptions="actionNext"
android:importantForAutofill="no"
android:inputType="text"
android:maxLines="1"
@@ -71,18 +81,54 @@
</com.google.android.material.textfield.TextInputLayout>
<EditText
android:id="@+id/discriminator_text"
style="@style/Signal.Text.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@null"
android:hint="@string/UsernameEditFragment__00"
android:imeOptions="actionDone"
android:importantForAutofill="no"
android:inputType="number"
android:maxLines="1"
android:paddingHorizontal="16dp"
android:minHeight="48dp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="@id/username_text_wrapper"
app:layout_constraintEnd_toEndOf="parent"
tools:text="21" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/suffix_progress"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="10dp"
app:layout_constraintEnd_toEndOf="@id/username_text_wrapper"
app:layout_constraintBottom_toBottomOf="@id/username_text_wrapper"
app:indicatorColor="@color/signal_colorOnSurfaceVariant"
app:indicatorSize="16dp"
app:trackThickness="1dp"
app:layout_constraintBottom_toBottomOf="@id/username_text_wrapper"
app:layout_constraintEnd_toStartOf="@id/discriminator_text"
app:trackColor="@color/transparent"
app:indicatorColor="@color/signal_colorOnSurfaceVariant"/>
app:trackThickness="1dp" />
<View
android:id="@+id/divider"
android:layout_width="1dp"
android:layout_height="20dp"
android:background="@color/signal_colorOutline"
app:layout_constraintBottom_toBottomOf="@id/discriminator_text"
app:layout_constraintStart_toStartOf="@id/discriminator_text"
app:layout_constraintTop_toTopOf="@id/discriminator_text" />
<View
android:id="@+id/username_text_focused_stroke"
android:layout_width="0dp"
android:layout_height="2dp"
android:background="@color/signal_colorPrimary"
app:layout_constraintBottom_toBottomOf="@id/username_text_wrapper"
app:layout_constraintEnd_toEndOf="@id/discriminator_text"
app:layout_constraintStart_toStartOf="@id/username_text_wrapper" />
<TextView
android:id="@+id/username_error"
@@ -92,8 +138,8 @@
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_colorError"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/username_description"
app:layout_constraintEnd_toEndOf="@id/username_description"
app:layout_constraintStart_toStartOf="@id/username_description"
app:layout_constraintTop_toBottomOf="@id/username_text_wrapper"
tools:text="Error something bad happened. Very super long error message that wraps"
tools:visibility="visible" />

View File

@@ -2202,6 +2202,8 @@
<string name="UnverifiedSendDialog_send">Send</string>
<!-- UsernameEditFragment -->
<!-- Placeholder text for custom discriminator -->
<string name="UsernameEditFragment__00">00</string>
<!-- Toolbar title when entering from registration -->
<string name="UsernameEditFragment__add_a_username">Add a username</string>
<!-- Instructional text at the top of the username edit screen -->
@@ -2224,6 +2226,12 @@
<string name="UsernameEditFragment__skip">Skip</string>
<!-- Content description for done button -->
<string name="UsernameEditFragment__done">Done</string>
<!-- Displayed when the chosen discriminator is not available for the given nickname -->
<string name="UsernameEditFragment__this_username_is_not_available_try_another_number">This username is not available, try another number.</string>
<!-- Displayed when the chosen discriminator is too short -->'
<string name="UsernameEditFragment__invalid_username_enter_a_minimum_of_d_digits">Invalid username, enter a minimum of %1$d digits.</string>
<!-- Displayed when the chosen discriminator is too long -->'
<string name="UsernameEditFragment__invalid_username_enter_a_maximum_of_d_digits">Invalid username, enter a maximum of %1$d digits.</string>
<plurals name="UserNotificationMigrationJob_d_contacts_are_on_signal">
<item quantity="one">%d contact is on Signal!</item>

View File

@@ -0,0 +1,376 @@
package org.thoughtcrime.securesms.profiles.manage
import org.junit.Test
import org.thoughtcrime.securesms.assertIs
class UsernameEditStateMachineTest {
@Test
fun `Given NoUserEntry, when user clears the username field, then I expect NoUserEntry with empty username and copied discriminator`() {
val given = UsernameEditStateMachine.NoUserEntry(nickname = "MilesMorales", discriminator = "07", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = "", discriminator = "07", stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname("")
actual assertIs expected
}
@Test
fun `Given NoUserEntry, when user enters text into the username field, then I expect UserEnteredNickname with given username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.NoUserEntry(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given NoUserEntry, when user clears the discriminator field, then I expect NoUserEntry with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedDiscriminator("")
actual assertIs expected
}
@Test
fun `Given NoUserEntry, when user enters text into the discriminator field, then I expect UserEnteredDiscriminator with given discriminator and copied nickname`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedDiscriminator(discriminator)
actual assertIs expected
}
@Test
fun `Given NoUserEntry, when system clears the username field, then I expect NoUserEntry with empty username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = "", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedNickname("")
actual assertIs expected
}
@Test
fun `Given NoUserEntry, when system enters text into the username field, then I expect NoUserEntry with given username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.NoUserEntry(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given NoUserEntry, when system clears the discriminator field, then I expect NoUserEntry with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedDiscriminator("")
actual assertIs expected
}
@Test
fun `Given NoUserEntry, when system enters text into the discriminator field, then I expect UserEnteredDiscriminator with given discriminator and copied nickname`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedDiscriminator(discriminator)
actual assertIs expected
}
@Test
fun `Given UserEnteredNickname, when user clears the username field, then I expect NoUserEntry with empty username and copied discriminator`() {
val given = UsernameEditStateMachine.UserEnteredNickname(nickname = "MilesMorales", discriminator = "07", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = "", discriminator = "07", stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname("")
actual assertIs expected
}
@Test
fun `Given UserEnteredNickname, when user enters text into the username field, then I expect UserEnteredNickname with given username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNickname(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given UserEnteredNickname, when user clears the discriminator field, then I expect UserEnteredNickname with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedDiscriminator("")
actual assertIs expected
}
@Test
fun `Given UserEnteredNickname, when user enters text into the discriminator field, then I expect UserEnteredNicknameAndDiscriminator with given discriminator and copied nickname`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedDiscriminator(discriminator)
actual assertIs expected
}
@Test
fun `Given UserEnteredNickname, when system clears the username field, then I expect NoUserEntry with empty username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = "", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedNickname("")
actual assertIs expected
}
@Test
fun `Given UserEnteredNickname, when system enters text into the username field, then I expect NoUserEntry with given username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNickname(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given UserEnteredNickname, when system clears the discriminator field, then I expect UserEnteredNickname with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedDiscriminator("")
actual assertIs expected
}
@Test
fun `Given UserEnteredNickname, when system enters text into the discriminator field, then I expect UserEnteredDiscriminator with given discriminator and copied nickname`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedDiscriminator(discriminator)
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator, when user clears the username field, then I expect NoUserEntry with empty username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = "", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname("")
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator, when user enters text into the username field, then I expect UserEnteredNicknameAndDiscriminator with given username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator with an empty discriminator, when user enters text into the username field, then I expect UserEnteredNickname with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = ""
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator, when user clears the discriminator field, then I expect UserEnteredDiscriminator with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedDiscriminator("")
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator, when user enters text into the discriminator field, then I expect UserEnteredDiscriminator with given discriminator and copied nickname`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedDiscriminator(discriminator)
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator, when system clears the username field, then I expect UserEnteredDiscriminator with empty username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = "", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedNickname("")
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator, when system enters text into the username field, then I expect UserEnteredDiscriminator with given username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator, when system clears the discriminator field, then I expect NoUserEntry with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedDiscriminator("")
actual assertIs expected
}
@Test
fun `Given UserEnteredDiscriminator, when system enters text into the discriminator field, then I expect NoUserEntry with given discriminator and copied nickname`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.NoUserEntry(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedDiscriminator(discriminator)
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator, when user clears the username field, then I expect UserEnteredDiscriminator with empty username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = "", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname("")
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator, when user enters text into the username field, then I expect UserEnteredNicknameAndDiscriminator with given username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator with empty discriminator, when user enters text into the username field, then I expect UserEnteredNickname with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = ""
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator, when user clears the discriminator field, then I expect UserEnteredNicknameAndDiscriminator with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedDiscriminator("")
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator, when user enters text into the discriminator field, then I expect UserEnteredNicknameAndDiscriminator with given discriminator and copied nickname`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val expected = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val actual = given.onUserChangedDiscriminator(discriminator)
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator, when system clears the username field, then I expect UserEnteredDiscriminator with empty username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = "", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedNickname("")
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator, when system enters text into the username field, then I expect NoUserEntry with given username and copied discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = "MilesMorales", discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.UserEnteredDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedNickname(nickname)
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator, when system clears the discriminator field, then I expect UserEnteredNickname with given username and empty discriminator`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = "", stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedDiscriminator("")
actual assertIs expected
}
@Test
fun `Given UserEnteredNicknameAndDiscriminator, when system enters text into the discriminator field, then I expect NoUserEntry with given discriminator and copied nickname`() {
val nickname = "Nick"
val discriminator = "07"
val given = UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.USER)
val expected = UsernameEditStateMachine.UserEnteredNickname(nickname = nickname, discriminator = discriminator, stateModifier = UsernameEditStateMachine.StateModifier.SYSTEM)
val actual = given.onSystemChangedDiscriminator(discriminator)
actual assertIs expected
}
}

View File

@@ -787,6 +787,12 @@ public class SignalServiceAccountManager {
this.pushServiceSocket.confirmUsername(username, reserveUsernameResponse);
}
public UsernameLinkComponents updateUsernameLink(UsernameLink newUsernameLink) throws IOException {
UUID serverId = this.pushServiceSocket.createUsernameLink(Base64.encodeUrlSafeWithoutPadding(newUsernameLink.getEncryptedUsername()), true);
return new UsernameLinkComponents(newUsernameLink.getEntropy(), serverId);
}
public void deleteUsername() throws IOException {
this.pushServiceSocket.deleteUsername();
}
@@ -794,7 +800,7 @@ public class SignalServiceAccountManager {
public UsernameLinkComponents createUsernameLink(Username username) throws IOException {
try {
UsernameLink link = username.generateLink();
UUID serverId = this.pushServiceSocket.createUsernameLink(Base64.encodeUrlSafeWithPadding(link.getEncryptedUsername()));
UUID serverId = this.pushServiceSocket.createUsernameLink(Base64.encodeUrlSafeWithPadding(link.getEncryptedUsername()), false);
return new UsernameLinkComponents(link.getEntropy(), serverId);
} catch (BaseUsernameException e) {

View File

@@ -1147,8 +1147,8 @@ public class PushServiceSocket {
* @param encryptedUsername URL-safe base64-encoded encrypted username
* @return The serverId for the generated link.
*/
public UUID createUsernameLink(String encryptedUsername) throws IOException {
String response = makeServiceRequest(USERNAME_LINK_PATH, "PUT", JsonUtil.toJson(new SetUsernameLinkRequestBody(encryptedUsername)));
public UUID createUsernameLink(String encryptedUsername, boolean keepLinkHandle) throws IOException {
String response = makeServiceRequest(USERNAME_LINK_PATH, "PUT", JsonUtil.toJson(new SetUsernameLinkRequestBody(encryptedUsername, keepLinkHandle)));
SetUsernameLinkResponseBody parsed = JsonUtil.fromJson(response, SetUsernameLinkResponseBody.class);
return parsed.getUsernameLinkHandle();

View File

@@ -3,4 +3,4 @@ package org.whispersystems.signalservice.internal.push
import com.fasterxml.jackson.annotation.JsonProperty
/** Request body for setting a username link on the service. */
data class SetUsernameLinkRequestBody(@JsonProperty val usernameLinkEncryptedValue: String)
data class SetUsernameLinkRequestBody(@JsonProperty val usernameLinkEncryptedValue: String, @JsonProperty val keepLinkHandle: Boolean)