From 17a6fcafa18ca7e6a467c8eb08a0a99420cd436c Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 9 Jan 2024 11:37:39 -0400 Subject: [PATCH] Add ability to set custom username discriminators. --- .../profiles/manage/UsernameEditFragment.java | 82 ++-- .../manage/UsernameEditStateMachine.kt | 216 ++++++++++ .../profiles/manage/UsernameEditViewModel.kt | 167 +++++++- .../profiles/manage/UsernameRepository.kt | 46 ++- .../profiles/manage/UsernameState.kt | 6 + .../securesms/util/UsernameUtil.kt | 36 +- .../res/layout/username_edit_fragment.xml | 76 +++- app/src/main/res/values/strings.xml | 8 + .../manage/UsernameEditStateMachineTest.kt | 376 ++++++++++++++++++ .../api/SignalServiceAccountManager.java | 8 +- .../internal/push/PushServiceSocket.java | 4 +- .../push/SetUsernameLinkRequestBody.kt | 2 +- 12 files changed, 952 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditStateMachine.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditStateMachineTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java index 8557a2953d..13f576c0c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java @@ -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; + 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) { - viewModel.onNicknameUpdated(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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditStateMachine.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditStateMachine.kt new file mode 100644 index 0000000000..459a5fcc0b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditStateMachine.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt index ac7fffbd69..c7e628c098 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt @@ -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 = PublishSubject.create() - private val nicknamePublisher: PublishProcessor = PublishProcessor.create() private val disposables: CompositeDisposable = CompositeDisposable() private val uiState: RxStore = RxStore( @@ -44,10 +45,23 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr scheduler = Schedulers.computation() ) + private val stateMachineStore = RxStore( + 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 = 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 handleUserConfirmation( + repositoryWorkerMethod: (T) -> Single + ) { 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 -> + 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 -> 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 + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt index 913a2a0d86..503a14e810 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt @@ -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 { + 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> { + fun reserveUsername(nickname: String, discriminator: String?): Single> { 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 { + private fun reserveUsernameInternal(nickname: String, discriminator: String?): Result { return try { - val candidates: List = Username.candidatesFrom(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH) + val candidates: List = if (discriminator == null) { + Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH) + } else { + listOf(Username("$nickname${UsernameState.DELIMITER}$discriminator")) + } val hashes: List = 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt index e6e225e420..57d40222bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt index 6defc731c5..0777040305 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt @@ -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, diff --git a/app/src/main/res/layout/username_edit_fragment.xml b/app/src/main/res/layout/username_edit_fragment.xml index dee44d61ec..fd596cbc0b 100644 --- a/app/src/main/res/layout/username_edit_fragment.xml +++ b/app/src/main/res/layout/username_edit_fragment.xml @@ -35,24 +35,34 @@ app:srcCompat="@drawable/symbol_at_24" app:tint="@color/signal_colorOnSurface" /> + + + app:suffixTextColor="@color/signal_colorOnSurface"> - + + + + app:trackThickness="1dp" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 853e163540..8f95bc1dcb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2202,6 +2202,8 @@ Send + + 00 Add a username @@ -2224,6 +2226,12 @@ Skip Done + + This username is not available, try another number. + ' + Invalid username, enter a minimum of %1$d digits. + ' + Invalid username, enter a maximum of %1$d digits. %d contact is on Signal! diff --git a/app/src/test/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditStateMachineTest.kt b/app/src/test/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditStateMachineTest.kt new file mode 100644 index 0000000000..fc1d2ca778 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditStateMachineTest.kt @@ -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 + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index b06ae36eb0..133c35fd21 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -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) { diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 9ebf168378..afc829b7fb 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -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(); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt index 3b89076ff2..67778d2383 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameLinkRequestBody.kt @@ -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)