From 84b4d69913a58a2acbebdbea473436fff0d4d856 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 25 Aug 2023 11:17:26 -0400 Subject: [PATCH] Fix error display when entering invalid username characters. Also convert UsernameEditViewModel to kotlin. --- .../profiles/manage/UsernameEditFragment.java | 10 +- .../manage/UsernameEditViewModel.java | 284 ------------------ .../profiles/manage/UsernameEditViewModel.kt | 243 +++++++++++++++ .../securesms/util/UsernameUtil.kt | 33 +- .../securesms/util/UsernameUtilTest.java | 48 --- .../securesms/util/UsernameUtilTest.kt | 48 +++ 6 files changed, 316 insertions(+), 350 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.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 1b30790fc5..ca705f673e 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 @@ -166,11 +166,11 @@ public class UsernameEditFragment extends LoggingFragment { private void onUiStateChanged(@NonNull UsernameEditViewModel.State state) { TextInputLayout usernameInputWrapper = binding.usernameTextWrapper; - presentSuffix(state.getUsername()); - presentButtonState(state.getButtonState()); - presentSummary(state.getUsername()); + presentSuffix(state.username); + presentButtonState(state.buttonState); + presentSummary(state.username); - switch (state.getUsernameStatus()) { + switch (state.usernameStatus) { case NONE: usernameInputWrapper.setError(null); break; @@ -216,7 +216,7 @@ public class UsernameEditFragment extends LoggingFragment { } private void presentSummary(@NonNull UsernameState usernameState) { - if (usernameState.getUsername() != null) { + if (usernameState.getUsername() != null || usernameState instanceof UsernameState.Loading) { binding.summary.setText(usernameState.getUsername()); } else { binding.summary.setText(R.string.UsernameEditFragment__choose_your_username); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java deleted file mode 100644 index 307feddc81..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java +++ /dev/null @@ -1,284 +0,0 @@ -package org.thoughtcrime.securesms.profiles.manage; - -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.UsernameUtil; -import org.thoughtcrime.securesms.util.UsernameUtil.InvalidReason; -import org.thoughtcrime.securesms.util.rx.RxStore; - -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.processors.PublishProcessor; -import io.reactivex.rxjava3.schedulers.Schedulers; -import io.reactivex.rxjava3.subjects.PublishSubject; - -/** - * Manages the state around username updates. - *

- * A note on naming conventions: - *

- * Usernames are made up of two discrete components, a nickname and a discriminator. They are formatted thusly: - *

- * [nickname]#[discriminator] - *

- * The nickname is user-controlled, whereas the discriminator is controlled by the server. - */ -class UsernameEditViewModel extends ViewModel { - - private static final long NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS = 500; - - private final PublishSubject events; - private final UsernameRepository repo; - private final RxStore uiState; - private final PublishProcessor nicknamePublisher; - private final CompositeDisposable disposables; - private final boolean isInRegistration; - - private UsernameEditViewModel(boolean isInRegistration) { - this.repo = new UsernameRepository(); - this.uiState = new RxStore<>(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, Recipient.self().getUsername().map(UsernameState.Set::new) - .orElse(UsernameState.NoUsername.INSTANCE)), Schedulers.computation()); - this.events = PublishSubject.create(); - this.nicknamePublisher = PublishProcessor.create(); - this.disposables = new CompositeDisposable(); - this.isInRegistration = isInRegistration; - - Disposable disposable = nicknamePublisher.debounce(NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) - .subscribe(this::onNicknameChanged); - disposables.add(disposable); - } - - @Override - protected void onCleared() { - super.onCleared(); - disposables.clear(); - uiState.dispose(); - } - - void onNicknameUpdated(@NonNull String nickname) { - uiState.update(state -> { - if (TextUtils.isEmpty(nickname) && Recipient.self().getUsername().isPresent()) { - return new State(isInRegistration ? ButtonState.SUBMIT_DISABLED : ButtonState.DELETE, UsernameStatus.NONE, UsernameState.NoUsername.INSTANCE); - } - - Optional invalidReason = UsernameUtil.checkUsername(nickname); - - return invalidReason.map(reason -> new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(reason), state.usernameState)) - .orElseGet(() -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameState)); - }); - - nicknamePublisher.onNext(nickname); - } - - void onUsernameSkipped() { - SignalStore.uiHints().markHasSetOrSkippedUsernameCreation(); - events.onNext(Event.SKIPPED); - } - - void onUsernameSubmitted() { - UsernameState usernameState = uiState.getState().getUsername(); - - if (!(usernameState instanceof UsernameState.Reserved)) { - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameState)); - return; - } - - if (Objects.equals(usernameState.getUsername(), Recipient.self().getUsername().orElse(null))) { - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameState)); - return; - } - - Optional invalidReason = UsernameUtil.checkUsername(usernameState.getNickname()); - - if (invalidReason.isPresent()) { - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason.get()), state.usernameState)); - return; - } - - uiState.update(state -> new State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE, state.usernameState)); - - Disposable confirmUsernameDisposable = repo.confirmUsername((UsernameState.Reserved) usernameState) - .subscribe(result -> { - String nickname = usernameState.getNickname(); - - switch (result) { - case SUCCESS: - SignalStore.uiHints().markHasSetOrSkippedUsernameCreation(); - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameState)); - events.onNext(Event.SUBMIT_SUCCESS); - break; - case USERNAME_INVALID: - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC, state.usernameState)); - events.onNext(Event.SUBMIT_FAIL_INVALID); - - if (nickname != null) { - onNicknameUpdated(nickname); - } - break; - case CANDIDATE_GENERATION_ERROR: - case USERNAME_UNAVAILABLE: - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, state.usernameState)); - events.onNext(Event.SUBMIT_FAIL_TAKEN); - - if (nickname != null) { - onNicknameUpdated(nickname); - } - break; - case NETWORK_ERROR: - uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, state.usernameState)); - events.onNext(Event.NETWORK_FAILURE); - break; - } - }); - - disposables.add(confirmUsernameDisposable); - } - - void onUsernameDeleted() { - uiState.update(state -> new State(ButtonState.DELETE_LOADING, UsernameStatus.NONE, state.usernameState)); - - Disposable deletionDisposable = repo.deleteUsername().subscribe(result -> { - switch (result) { - case SUCCESS: - uiState.update(state -> new State(ButtonState.DELETE_DISABLED, UsernameStatus.NONE, state.usernameState)); - events.onNext(Event.DELETE_SUCCESS); - break; - case NETWORK_ERROR: - uiState.update(state -> new State(ButtonState.DELETE, UsernameStatus.NONE, state.usernameState)); - events.onNext(Event.NETWORK_FAILURE); - break; - } - }); - - disposables.add(deletionDisposable); - } - - @NonNull Flowable getUiState() { - return uiState.getStateFlowable().observeOn(AndroidSchedulers.mainThread()); - } - - @NonNull Observable getEvents() { - return events.observeOn(AndroidSchedulers.mainThread()); - } - - private void onNicknameChanged(@NonNull String nickname) { - if (TextUtils.isEmpty(nickname)) { - return; - } - - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, UsernameState.Loading.INSTANCE)); - Disposable reserveDisposable = repo.reserveUsername(nickname).subscribe(result -> { - result.either( - reserved -> { - uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, reserved)); - return null; - }, - failure -> { - switch (failure) { - case SUCCESS: - throw new AssertionError(); - case USERNAME_INVALID: - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC, UsernameState.NoUsername.INSTANCE)); - break; - case USERNAME_UNAVAILABLE: - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, UsernameState.NoUsername.INSTANCE)); - break; - case NETWORK_ERROR: - uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, UsernameState.NoUsername.INSTANCE)); - events.onNext(Event.NETWORK_FAILURE); - break; - case CANDIDATE_GENERATION_ERROR: - // TODO -- Retry - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, UsernameState.NoUsername.INSTANCE)); - break; - } - - return null; - }); - }); - - disposables.add(reserveDisposable); - } - - private static UsernameStatus mapUsernameError(@NonNull InvalidReason invalidReason) { - switch (invalidReason) { - case TOO_SHORT: - return UsernameStatus.TOO_SHORT; - case TOO_LONG: - return UsernameStatus.TOO_LONG; - case STARTS_WITH_NUMBER: - return UsernameStatus.CANNOT_START_WITH_NUMBER; - case INVALID_CHARACTERS: - return UsernameStatus.INVALID_CHARACTERS; - default: - return UsernameStatus.INVALID_GENERIC; - } - } - - static class State { - private final ButtonState buttonState; - private final UsernameStatus usernameStatus; - private final UsernameState usernameState; - - private State(@NonNull ButtonState buttonState, - @NonNull UsernameStatus usernameStatus, - @NonNull UsernameState usernameState) - { - this.buttonState = buttonState; - this.usernameStatus = usernameStatus; - this.usernameState = usernameState; - } - - @NonNull ButtonState getButtonState() { - return buttonState; - } - - @NonNull UsernameStatus getUsernameStatus() { - return usernameStatus; - } - - @NonNull UsernameState getUsername() { - return usernameState; - } - } - - enum UsernameStatus { - NONE, TAKEN, TOO_SHORT, TOO_LONG, CANNOT_START_WITH_NUMBER, INVALID_CHARACTERS, INVALID_GENERIC - } - - enum ButtonState { - SUBMIT, SUBMIT_DISABLED, SUBMIT_LOADING, DELETE, DELETE_LOADING, DELETE_DISABLED - } - - enum Event { - NETWORK_FAILURE, SUBMIT_SUCCESS, DELETE_SUCCESS, SUBMIT_FAIL_INVALID, SUBMIT_FAIL_TAKEN, SKIPPED - } - - static class Factory implements ViewModelProvider.Factory { - - private final boolean isInRegistration; - - Factory(boolean isInRegistration) { - this.isInRegistration = isInRegistration; - } - - @Override - public @NonNull T create(@NonNull Class modelClass) { - //noinspection ConstantConditions - return modelClass.cast(new UsernameEditViewModel(isInRegistration)); - } - } -} 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 new file mode 100644 index 0000000000..c1166cfe7e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt @@ -0,0 +1,243 @@ +package org.thoughtcrime.securesms.profiles.manage + +import androidx.lifecycle.ViewModel +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.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.processors.PublishProcessor +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import org.signal.core.util.Result +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.recipients.Recipient +import org.thoughtcrime.securesms.util.UsernameUtil.InvalidReason +import org.thoughtcrime.securesms.util.UsernameUtil.checkUsername +import org.thoughtcrime.securesms.util.rx.RxStore +import java.util.concurrent.TimeUnit + +/** + * Manages the state around username updates. + * + * + * A note on naming conventions: + * Usernames are made up of two discrete components, a nickname and a discriminator. They are formatted thusly: + * + * [nickname].[discriminator] + * + * The nickname is user-controlled, whereas the discriminator is controlled by the server. + */ +internal class UsernameEditViewModel private constructor(private val isInRegistration: Boolean) : ViewModel() { + private val events: PublishSubject = PublishSubject.create() + private val repo: UsernameRepository = UsernameRepository() + private val nicknamePublisher: PublishProcessor = PublishProcessor.create() + private val disposables: CompositeDisposable = CompositeDisposable() + + private val uiState: RxStore = RxStore( + defaultValue = State( + buttonState = ButtonState.SUBMIT_DISABLED, + usernameStatus = UsernameStatus.NONE, + username = Recipient.self().username.map { UsernameState.Set(it) }.orElse(UsernameState.NoUsername) + ), + scheduler = Schedulers.computation() + ) + + init { + disposables += nicknamePublisher + .debounce(NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .subscribe { nickname: String -> onNicknameUpdatedDebounced(nickname) } + } + + override fun onCleared() { + super.onCleared() + disposables.clear() + uiState.dispose() + } + + fun onNicknameUpdated(nickname: String) { + uiState.update { state: State -> + if (nickname.isBlank() && Recipient.self().username.isPresent) { + return@update State( + buttonState = if (isInRegistration) ButtonState.SUBMIT_DISABLED else ButtonState.DELETE, + usernameStatus = UsernameStatus.NONE, + username = UsernameState.NoUsername + ) + } + + val invalidReason: InvalidReason? = checkUsername(nickname) + + if (invalidReason != null) { + State(ButtonState.SUBMIT_DISABLED, mapUsernameError(invalidReason), state.username) + } else { + State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.username) + } + } + + nicknamePublisher.onNext(nickname) + } + + fun onUsernameSkipped() { + SignalStore.uiHints().markHasSetOrSkippedUsernameCreation() + events.onNext(Event.SKIPPED) + } + + fun onUsernameSubmitted() { + val usernameState = uiState.state.username + if (usernameState !is UsernameState.Reserved) { + uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, it.username) } + return + } + + if (usernameState.username == Recipient.self().username.orElse(null)) { + 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) } + return + } + + uiState.update { State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE, it.username) } + + disposables += repo.confirmUsername(usernameState).subscribe { result: UsernameSetResult -> + val nickname = usernameState.getNickname() + + when (result) { + UsernameSetResult.SUCCESS -> { + SignalStore.uiHints().markHasSetOrSkippedUsernameCreation() + uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, it.username) } + events.onNext(Event.SUBMIT_SUCCESS) + } + + UsernameSetResult.USERNAME_INVALID -> { + uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC, it.username) } + events.onNext(Event.SUBMIT_FAIL_INVALID) + nickname?.let { onNicknameUpdated(it) } + } + + UsernameSetResult.CANDIDATE_GENERATION_ERROR, UsernameSetResult.USERNAME_UNAVAILABLE -> { + uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, it.username) } + events.onNext(Event.SUBMIT_FAIL_TAKEN) + nickname?.let { onNicknameUpdated(it) } + } + + UsernameSetResult.NETWORK_ERROR -> { + uiState.update { State(ButtonState.SUBMIT, UsernameStatus.NONE, it.username) } + events.onNext(Event.NETWORK_FAILURE) + } + } + } + } + + fun onUsernameDeleted() { + uiState.update { state: State -> State(ButtonState.DELETE_LOADING, UsernameStatus.NONE, state.username) } + + disposables += repo.deleteUsername().subscribe { result: UsernameDeleteResult -> + when (result) { + UsernameDeleteResult.SUCCESS -> { + uiState.update { state: State -> State(ButtonState.DELETE_DISABLED, UsernameStatus.NONE, state.username) } + events.onNext(Event.DELETE_SUCCESS) + } + + UsernameDeleteResult.NETWORK_ERROR -> { + uiState.update { state: State -> State(ButtonState.DELETE, UsernameStatus.NONE, state.username) } + events.onNext(Event.NETWORK_FAILURE) + } + } + } + } + + fun getUiState(): Flowable { + return uiState.stateFlowable.observeOn(AndroidSchedulers.mainThread()) + } + + fun getEvents(): Observable { + return events.observeOn(AndroidSchedulers.mainThread()) + } + + /** Triggered when the debounced nickname event stream fires. */ + private fun onNicknameUpdatedDebounced(nickname: String) { + if (nickname.isBlank()) { + return + } + + val invalidReason: InvalidReason? = checkUsername(nickname) + if (invalidReason != null) { + return + } + + uiState.update { State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, UsernameState.Loading) } + + disposables += repo.reserveUsername(nickname).subscribe { result: Result -> + result.either( + onSuccess = { reserved: UsernameState.Reserved -> + uiState.update { State(ButtonState.SUBMIT, UsernameStatus.NONE, reserved) } + }, + 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) } + } + 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) } + } + } + } + ) + } + } + + class State( + @JvmField val buttonState: ButtonState, + @JvmField val usernameStatus: UsernameStatus, + @JvmField val username: UsernameState + ) + + enum class UsernameStatus { + NONE, TAKEN, TOO_SHORT, TOO_LONG, CANNOT_START_WITH_NUMBER, INVALID_CHARACTERS, INVALID_GENERIC + } + + enum class ButtonState { + SUBMIT, SUBMIT_DISABLED, SUBMIT_LOADING, DELETE, DELETE_LOADING, DELETE_DISABLED + } + + enum class Event { + NETWORK_FAILURE, SUBMIT_SUCCESS, DELETE_SUCCESS, SUBMIT_FAIL_INVALID, SUBMIT_FAIL_TAKEN, SKIPPED + } + + class Factory(private val isInRegistration: Boolean) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(UsernameEditViewModel(isInRegistration))!! + } + } + + companion object { + private const val NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS: Long = 500 + private fun mapUsernameError(invalidReason: InvalidReason): UsernameStatus { + return when (invalidReason) { + InvalidReason.TOO_SHORT -> UsernameStatus.TOO_SHORT + InvalidReason.TOO_LONG -> UsernameStatus.TOO_LONG + InvalidReason.STARTS_WITH_NUMBER -> UsernameStatus.CANNOT_START_WITH_NUMBER + InvalidReason.INVALID_CHARACTERS -> UsernameStatus.INVALID_CHARACTERS + else -> UsernameStatus.INVALID_GENERIC + } + } + } +} 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 11c3bf22de..3680c42b63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt @@ -33,19 +33,26 @@ object UsernameUtil { } @JvmStatic - fun checkUsername(value: String?): Optional { - return if (value == null) { - Optional.of(InvalidReason.TOO_SHORT) - } else if (value.length < MIN_LENGTH) { - Optional.of(InvalidReason.TOO_SHORT) - } else if (value.length > MAX_LENGTH) { - Optional.of(InvalidReason.TOO_LONG) - } else if (DIGIT_START_PATTERN.matcher(value).matches()) { - Optional.of(InvalidReason.STARTS_WITH_NUMBER) - } else if (!FULL_PATTERN.matcher(value).matches()) { - Optional.of(InvalidReason.INVALID_CHARACTERS) - } else { - Optional.empty() + fun checkUsername(value: String?): InvalidReason? { + return when { + value == null -> { + InvalidReason.TOO_SHORT + } + value.length < MIN_LENGTH -> { + InvalidReason.TOO_SHORT + } + value.length > MAX_LENGTH -> { + InvalidReason.TOO_LONG + } + DIGIT_START_PATTERN.matcher(value).matches() -> { + InvalidReason.STARTS_WITH_NUMBER + } + !FULL_PATTERN.matcher(value).matches() -> { + InvalidReason.INVALID_CHARACTERS + } + else -> { + null + } } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java deleted file mode 100644 index 00f0cce462..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; - -public class UsernameUtilTest { - - @Test - public void checkUsername_tooShort() { - assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername(null).get()); - assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername("").get()); - assertEquals(UsernameUtil.InvalidReason.TOO_SHORT, UsernameUtil.checkUsername("ab").get()); - } - - @Test - public void checkUsername_tooLong() { - assertEquals(UsernameUtil.InvalidReason.TOO_LONG, UsernameUtil.checkUsername("abcdefghijklmnopqrstuvwxyz1234567").get()); - } - - @Test - public void checkUsername_startsWithNumber() { - assertEquals(UsernameUtil.InvalidReason.STARTS_WITH_NUMBER, UsernameUtil.checkUsername("0abcdefg").get()); - assertEquals(UsernameUtil.InvalidReason.STARTS_WITH_NUMBER, UsernameUtil.checkUsername("9abcdefg").get()); - assertEquals(UsernameUtil.InvalidReason.STARTS_WITH_NUMBER, UsernameUtil.checkUsername("8675309").get()); - } - - @Test - public void checkUsername_invalidCharacters() { - assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("$abcd").get()); - assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername(" abcd").get()); - assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("ab cde").get()); - assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("%%%%%").get()); - assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("-----").get()); - assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("asĸ_me").get()); - assertEquals(UsernameUtil.InvalidReason.INVALID_CHARACTERS, UsernameUtil.checkUsername("+18675309").get()); - } - - @Test - public void checkUsername_validUsernames() { - assertFalse(UsernameUtil.checkUsername("abcd").isPresent()); - assertFalse(UsernameUtil.checkUsername("abcdefghijklmnopqrstuvwxyz").isPresent()); - assertFalse(UsernameUtil.checkUsername("ABCDEFGHIJKLMNOPQRSTUVWXYZ").isPresent()); - assertFalse(UsernameUtil.checkUsername("web_head").isPresent()); - assertFalse(UsernameUtil.checkUsername("Spider_Fan_1991").isPresent()); - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.kt new file mode 100644 index 0000000000..2ef519ba3f --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.kt @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.util + +import org.junit.Test +import org.thoughtcrime.securesms.assertIs +import org.thoughtcrime.securesms.assertIsNotNull +import org.thoughtcrime.securesms.assertIsNull +import org.thoughtcrime.securesms.util.UsernameUtil.checkUsername + +class UsernameUtilTest { + @Test + fun checkUsername_tooShort() { + checkUsername(null) assertIs UsernameUtil.InvalidReason.TOO_SHORT + checkUsername("") assertIs UsernameUtil.InvalidReason.TOO_SHORT + checkUsername("ab") assertIs UsernameUtil.InvalidReason.TOO_SHORT + } + + @Test + fun checkUsername_tooLong() { + checkUsername("abcdefghijklmnopqrstuvwxyz1234567") assertIs UsernameUtil.InvalidReason.TOO_LONG + } + + @Test + fun checkUsername_startsWithNumber() { + checkUsername("0abcdefg") assertIs UsernameUtil.InvalidReason.STARTS_WITH_NUMBER + checkUsername("9abcdefg") assertIs UsernameUtil.InvalidReason.STARTS_WITH_NUMBER + checkUsername("8675309") assertIs UsernameUtil.InvalidReason.STARTS_WITH_NUMBER + } + + @Test + fun checkUsername_invalidCharacters() { + checkUsername("\$abcd") assertIs UsernameUtil.InvalidReason.INVALID_CHARACTERS + checkUsername(" abcd") assertIs UsernameUtil.InvalidReason.INVALID_CHARACTERS + checkUsername("ab cde") assertIs UsernameUtil.InvalidReason.INVALID_CHARACTERS + checkUsername("%%%%%") assertIs UsernameUtil.InvalidReason.INVALID_CHARACTERS + checkUsername("-----") assertIs UsernameUtil.InvalidReason.INVALID_CHARACTERS + checkUsername("asĸ_me") assertIs UsernameUtil.InvalidReason.INVALID_CHARACTERS + checkUsername("+18675309") assertIs UsernameUtil.InvalidReason.INVALID_CHARACTERS + } + + @Test + fun checkUsername_validUsernames() { + checkUsername("abcd").assertIsNull() + checkUsername("abcdefghijklmnopqrstuvwxyz").assertIsNull() + checkUsername("ABCDEFGHIJKLMNOPQRSTUVWXYZ").assertIsNull() + checkUsername("web_head").assertIsNull() + checkUsername("Spider_Fan_1991").assertIsNull() + } +}