mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-25 12:17:22 +00:00
Add ability to set custom username discriminators.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user