diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java index 368f0eff2..2f7ac554a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; @@ -341,11 +342,11 @@ public class RetrieveProfileJob extends BaseJob { SignalServiceProfile profile = profileAndCredential.getProfile(); ProfileKey recipientProfileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey()); - setProfileName(recipient, profile.getName()); + boolean wroteNewProfileName = setProfileName(recipient, profile.getName()); + setProfileAbout(recipient, profile.getAbout(), profile.getAboutEmoji()); setProfileAvatar(recipient, profile.getAvatar()); setProfileBadges(recipient, profile.getBadges()); - clearUsername(recipient); setProfileCapabilities(recipient, profile.getCapabilities()); setUnidentifiedAccessMode(recipient, profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess()); @@ -353,6 +354,10 @@ public class RetrieveProfileJob extends BaseJob { profileAndCredential.getExpiringProfileKeyCredential() .ifPresent(profileKeyCredential -> setExpiringProfileKeyCredential(recipient, recipientProfileKey, profileKeyCredential)); } + + if (recipient.hasNonUsernameDisplayName(context) || wroteNewProfileName) { + clearUsername(recipient); + } } private void setProfileBadges(@NonNull Recipient recipient, @Nullable List serviceBadges) { @@ -436,16 +441,16 @@ public class RetrieveProfileJob extends BaseJob { } } - private void setProfileName(Recipient recipient, String profileName) { + private boolean setProfileName(Recipient recipient, String profileName) { try { ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey()); - if (profileKey == null) return; + if (profileKey == null) return false; String plaintextProfileName = Util.emptyIfNull(ProfileUtil.decryptString(profileKey, profileName)); if (TextUtils.isEmpty(plaintextProfileName)) { Log.w(TAG, "No name set on the profile for " + recipient.getId() + " -- Leaving it alone"); - return; + return false; } ProfileName remoteProfileName = ProfileName.fromSerialized(plaintextProfileName); @@ -470,12 +475,16 @@ public class RetrieveProfileJob extends BaseJob { Log.i(TAG, String.format(Locale.US, "Name changed, but wasn't relevant to write an event. blocked: %s, group: %s, self: %s, firstSet: %s, displayChange: %s", recipient.isBlocked(), recipient.isGroup(), recipient.isSelf(), localDisplayName.isEmpty(), !remoteDisplayName.equals(localDisplayName))); } + + return true; } } catch (InvalidCiphertextException e) { Log.w(TAG, "Bad profile key for " + recipient.getId()); } catch (IOException e) { Log.w(TAG, e); } + + return false; } private void setProfileAbout(@NonNull Recipient recipient, @Nullable String encryptedAbout, @Nullable String encryptedEmoji) { 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 68a4010b1..bb36abf75 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 @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.profiles.manage; import android.content.res.ColorStateList; +import android.graphics.Color; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.view.LayoutInflater; @@ -15,16 +16,15 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentManager; -import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.Navigation; import androidx.navigation.fragment.NavHostFragment; -import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.progressindicator.CircularProgressIndicatorSpec; +import com.google.android.material.progressindicator.IndeterminateDrawable; import com.google.android.material.textfield.TextInputLayout; import org.signal.core.util.DimensionUnit; @@ -38,10 +38,8 @@ import org.thoughtcrime.securesms.util.LifecycleDisposable; import org.thoughtcrime.securesms.util.UsernameUtil; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton; -import org.thoughtcrime.securesms.util.views.LearnMoreTextView; import java.util.Objects; -import java.util.function.Consumer; public class UsernameEditFragment extends LoggingFragment { @@ -76,19 +74,20 @@ public class UsernameEditFragment extends LoggingFragment { lifecycleDisposable.add(viewModel.getUiState().subscribe(this::onUiStateChanged)); viewModel.getEvents().observe(getViewLifecycleOwner(), this::onEvent); - binding.usernameSubmitButton.setOnClickListener(v -> viewModel.onUsernameSubmitted(binding.usernameText.getText().toString())); + binding.usernameSubmitButton.setOnClickListener(v -> viewModel.onUsernameSubmitted()); binding.usernameDeleteButton.setOnClickListener(v -> viewModel.onUsernameDeleted()); - binding.usernameText.setText(Recipient.self().getUsername().orElse(null)); + UsernameState usernameState = Recipient.self().getUsername().map(UsernameState.Set::new).orElse(UsernameState.NoUsername.INSTANCE); + binding.usernameText.setText(usernameState.getNickname()); binding.usernameText.addTextChangedListener(new SimpleTextWatcher() { @Override - public void onTextChanged(String text) { - viewModel.onUsernameUpdated(text); + public void onTextChanged(@NonNull String text) { + viewModel.onNicknameUpdated(text); } }); binding.usernameText.setOnEditorActionListener((v, actionId, event) -> { if (actionId == EditorInfo.IME_ACTION_DONE) { - viewModel.onUsernameSubmitted(binding.usernameText.getText().toString()); + viewModel.onUsernameSubmitted(); return true; } return false; @@ -118,9 +117,10 @@ public class UsernameEditFragment extends LoggingFragment { layoutParams.topMargin = suffixTextView.getPaddingTop(); layoutParams.bottomMargin = suffixTextView.getPaddingBottom(); + layoutParams.setMarginEnd(suffixTextView.getPaddingEnd()); suffixProgress = new ImageView(requireContext()); - suffixProgress.setImageDrawable(UsernameSuffix.getInProgressDrawable(requireContext())); + suffixProgress.setImageDrawable(getInProgressDrawable()); suffixParent.addView(suffixProgress, 0, layoutParams); suffixTextView.setOnClickListener(this::onLearnMore); @@ -148,7 +148,7 @@ public class UsernameEditFragment extends LoggingFragment { TextInputLayout usernameInputWrapper = binding.usernameTextWrapper; usernameInput.setEnabled(true); - presentSuffix(state.getUsernameSuffix()); + presentSuffix(state.getUsername()); switch (state.getButtonState()) { case SUBMIT: @@ -224,18 +224,14 @@ 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 AVAILABLE: - usernameInputWrapper.setError(getResources().getString(R.string.UsernameEditFragment_this_username_is_available)); - usernameInputWrapper.setErrorTextColor(ColorStateList.valueOf(getResources().getColor(R.color.signal_accent_green))); break; } } - private void presentSuffix(@NonNull UsernameSuffix usernameSuffix) { - binding.usernameTextWrapper.setSuffixText(usernameSuffix.getCharSequence()); + private void presentSuffix(@NonNull UsernameState usernameState) { + binding.usernameTextWrapper.setSuffixText(usernameState.getDiscriminator()); - boolean isInProgress = usernameSuffix.isInProgress(); + boolean isInProgress = usernameState.isInProgress(); if (isInProgress) { suffixProgress.setVisibility(View.VISIBLE); @@ -244,11 +240,23 @@ public class UsernameEditFragment extends LoggingFragment { } } + private IndeterminateDrawable getInProgressDrawable() { + CircularProgressIndicatorSpec spec = new CircularProgressIndicatorSpec(requireContext(), null); + spec.indicatorInset = 0; + spec.indicatorSize = (int) DimensionUnit.DP.toPixels(16f); + spec.trackColor = ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant); + spec.trackThickness = (int) DimensionUnit.DP.toPixels(1f); + + IndeterminateDrawable drawable = IndeterminateDrawable.createCircularDrawable(requireContext(), spec); + drawable.setBounds(0, 0, spec.indicatorSize, spec.indicatorSize); + + return drawable; + } + private void onEvent(@NonNull UsernameEditViewModel.Event event) { switch (event) { case SUBMIT_SUCCESS: ResultContract.setUsernameCreated(getParentFragmentManager()); - Toast.makeText(requireContext(), R.string.UsernameEditFragment_successfully_set_username, Toast.LENGTH_SHORT).show(); NavHostFragment.findNavController(this).popBackStack(); break; case SUBMIT_FAIL_TAKEN: diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java index 69390ceb4..f2a71590c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditRepository.java @@ -1,18 +1,19 @@ package org.thoughtcrime.securesms.profiles.manage; -import android.app.Application; - import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; +import org.signal.core.util.Result; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException; import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException; import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException; +import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse; import java.io.IOException; import java.util.concurrent.Executor; @@ -21,18 +22,20 @@ class UsernameEditRepository { private static final String TAG = Log.tag(UsernameEditRepository.class); - private final Application application; private final SignalServiceAccountManager accountManager; private final Executor executor; UsernameEditRepository() { - this.application = ApplicationDependencies.getApplication(); this.accountManager = ApplicationDependencies.getSignalServiceAccountManager(); this.executor = SignalExecutors.UNBOUNDED; } - void setUsername(@NonNull String username, @NonNull Callback callback) { - executor.execute(() -> callback.onComplete(setUsernameInternal(username))); + void reserveUsername(@NonNull String nickname, @NonNull Callback> callback) { + executor.execute(() -> callback.onComplete(reserveUsernameInternal(nickname))); + } + + void confirmUsername(@NonNull ReserveUsernameResponse reserveUsernameResponse, @NonNull Callback callback) { + executor.execute(() -> callback.onComplete(confirmUsernameInternal(reserveUsernameResponse))); } void deleteUsername(@NonNull Callback callback) { @@ -40,20 +43,38 @@ class UsernameEditRepository { } @WorkerThread - private @NonNull UsernameSetResult setUsernameInternal(@NonNull String username) { + private @NonNull Result reserveUsernameInternal(@NonNull String nickname) { try { - accountManager.setUsername(username); - SignalDatabase.recipients().setUsername(Recipient.self().getId(), username); - Log.i(TAG, "[setUsername] Successfully set username."); + ReserveUsernameResponse username = accountManager.reserveUsername(nickname); + Log.i(TAG, "[reserveUsername] Successfully reserved username."); + return Result.success(username); + } catch (UsernameTakenException e) { + Log.w(TAG, "[reserveUsername] Username taken."); + return Result.failure(UsernameSetResult.USERNAME_UNAVAILABLE); + } catch (UsernameMalformedException e) { + Log.w(TAG, "[reserveUsername] Username malformed."); + return Result.failure(UsernameSetResult.USERNAME_INVALID); + } catch (IOException e) { + Log.w(TAG, "[reserveUsername] Generic network exception.", e); + return Result.failure(UsernameSetResult.NETWORK_ERROR); + } + } + + @WorkerThread + private @NonNull UsernameSetResult confirmUsernameInternal(@NonNull ReserveUsernameResponse reserveUsernameResponse) { + try { + accountManager.confirmUsername(reserveUsernameResponse); + SignalDatabase.recipients().setUsername(Recipient.self().getId(), reserveUsernameResponse.getUsername()); + Log.i(TAG, "[confirmUsername] Successfully reserved username."); return UsernameSetResult.SUCCESS; } catch (UsernameTakenException e) { - Log.w(TAG, "[setUsername] Username taken."); + Log.w(TAG, "[confirmUsername] Username gone."); return UsernameSetResult.USERNAME_UNAVAILABLE; - } catch (UsernameMalformedException e) { - Log.w(TAG, "[setUsername] Username malformed."); + } catch (UsernameIsNotReservedException e) { + Log.w(TAG, "[confirmUsername] Username was not reserved."); return UsernameSetResult.USERNAME_INVALID; } catch (IOException e) { - Log.w(TAG, "[setUsername] Generic network exception.", e); + Log.w(TAG, "[confirmUsername] Generic network exception.", e); return UsernameSetResult.NETWORK_ERROR; } } @@ -79,10 +100,6 @@ class UsernameEditRepository { SUCCESS, NETWORK_ERROR } - enum UsernameAvailableResult { - TRUE, FALSE, NETWORK_ERROR - } - interface Callback { void onComplete(E result); } 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 index 69e2aebc0..5aa103b7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.profiles.manage; -import android.app.Application; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -9,86 +8,126 @@ import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; import org.signal.core.util.ThreadUtil; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.SingleLiveEvent; 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.disposables.CompositeDisposable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.processors.PublishProcessor; import io.reactivex.rxjava3.schedulers.Schedulers; - +/** + * 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 String TAG = Log.tag(UsernameEditViewModel.class); + private static final long NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS = 500; - private final Application application; - private final SingleLiveEvent events; - private final UsernameEditRepository repo; - private final RxStore uiState; + private final SingleLiveEvent events; + private final UsernameEditRepository repo; + private final RxStore uiState; + private final PublishProcessor nicknamePublisher; + private final CompositeDisposable disposables; private UsernameEditViewModel() { - this.application = ApplicationDependencies.getApplication(); - this.repo = new UsernameEditRepository(); - this.uiState = new RxStore<>(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, UsernameSuffix.NONE), Schedulers.computation()); - this.events = new SingleLiveEvent<>(); + this.repo = new UsernameEditRepository(); + 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 = new SingleLiveEvent<>(); + this.nicknamePublisher = PublishProcessor.create(); + this.disposables = new CompositeDisposable(); + + Disposable disposable = nicknamePublisher.debounce(NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .subscribe(this::onNicknameChanged); + disposables.add(disposable); } - void onUsernameUpdated(@NonNull String username) { + @Override + protected void onCleared() { + super.onCleared(); + disposables.clear(); + } + + void onNicknameUpdated(@NonNull String nickname) { uiState.update(state -> { - if (TextUtils.isEmpty(username) && Recipient.self().getUsername().isPresent()) { - return new State(ButtonState.DELETE, UsernameStatus.NONE, state.usernameSuffix); + if (TextUtils.isEmpty(nickname) && Recipient.self().getUsername().isPresent()) { + return new State(ButtonState.DELETE, UsernameStatus.NONE, UsernameState.NoUsername.INSTANCE); } - if (username.equals(Recipient.self().getUsername().orElse(null))) { - return new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameSuffix); - } + Optional invalidReason = UsernameUtil.checkUsername(nickname); - Optional invalidReason = UsernameUtil.checkUsername(username); - - return invalidReason.map(reason -> new State(ButtonState.SUBMIT_DISABLED, mapUsernameError(reason), state.usernameSuffix)) - .orElseGet(() -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, state.usernameSuffix)); + 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 onUsernameSubmitted(@NonNull String username) { - if (username.equals(Recipient.self().getUsername().orElse(null))) { - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameSuffix)); + 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; } - Optional invalidReason = UsernameUtil.checkUsername(username); + 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.usernameSuffix)); + 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.usernameSuffix)); + uiState.update(state -> new State(ButtonState.SUBMIT_LOADING, UsernameStatus.NONE, state.usernameState)); - repo.setUsername(username, (result) -> { + repo.confirmUsername(((UsernameState.Reserved) usernameState).getReserveUsernameResponse(), (result) -> { ThreadUtil.runOnMain(() -> { + String nickname = usernameState.getNickname(); + switch (result) { case SUCCESS: - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameSuffix)); + uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, state.usernameState)); events.postValue(Event.SUBMIT_SUCCESS); break; case USERNAME_INVALID: - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC, state.usernameSuffix)); + uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.INVALID_GENERIC, state.usernameState)); events.postValue(Event.SUBMIT_FAIL_INVALID); + + if (nickname != null) { + onNicknameUpdated(nickname); + } break; case USERNAME_UNAVAILABLE: - uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, state.usernameSuffix)); + uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.TAKEN, state.usernameState)); events.postValue(Event.SUBMIT_FAIL_TAKEN); + + if (nickname != null) { + onNicknameUpdated(nickname); + } break; case NETWORK_ERROR: - uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, state.usernameSuffix)); + uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, state.usernameState)); events.postValue(Event.NETWORK_FAILURE); break; } @@ -97,17 +136,17 @@ class UsernameEditViewModel extends ViewModel { } void onUsernameDeleted() { - uiState.update(state -> new State(ButtonState.DELETE_LOADING, UsernameStatus.NONE, state.usernameSuffix)); + uiState.update(state -> new State(ButtonState.DELETE_LOADING, UsernameStatus.NONE, state.usernameState)); repo.deleteUsername((result) -> { ThreadUtil.runOnMain(() -> { switch (result) { case SUCCESS: - uiState.update(state -> new State(ButtonState.DELETE_DISABLED, UsernameStatus.NONE, state.usernameSuffix)); + uiState.update(state -> new State(ButtonState.DELETE_DISABLED, UsernameStatus.NONE, state.usernameState)); events.postValue(Event.DELETE_SUCCESS); break; case NETWORK_ERROR: - uiState.update(state -> new State(ButtonState.DELETE, UsernameStatus.NONE, state.usernameSuffix)); + uiState.update(state -> new State(ButtonState.DELETE, UsernameStatus.NONE, state.usernameState)); events.postValue(Event.NETWORK_FAILURE); break; } @@ -123,28 +162,68 @@ class UsernameEditViewModel extends ViewModel { return events; } + private void onNicknameChanged(@NonNull String nickname) { + if (TextUtils.isEmpty(nickname)) { + return; + } + + uiState.update(state -> new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, UsernameState.Loading.INSTANCE)); + repo.reserveUsername(nickname, result -> { + ThreadUtil.runOnMain(() -> { + result.either( + reserveUsernameJsonResponse -> { + uiState.update(state -> new State(ButtonState.SUBMIT, UsernameStatus.NONE, new UsernameState.Reserved(reserveUsernameJsonResponse))); + 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.postValue(Event.NETWORK_FAILURE); + break; + } + + return null; + }); + }); + }); + } + 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; + 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 UsernameSuffix usernameSuffix; + private final UsernameState usernameState; private State(@NonNull ButtonState buttonState, @NonNull UsernameStatus usernameStatus, - @NonNull UsernameSuffix usernameSuffix) + @NonNull UsernameState usernameState) { this.buttonState = buttonState; this.usernameStatus = usernameStatus; - this.usernameSuffix = usernameSuffix; + this.usernameState = usernameState; } @NonNull ButtonState getButtonState() { @@ -155,13 +234,13 @@ class UsernameEditViewModel extends ViewModel { return usernameStatus; } - @NonNull UsernameSuffix getUsernameSuffix() { - return usernameSuffix; + @NonNull UsernameState getUsername() { + return usernameState; } } enum UsernameStatus { - NONE, AVAILABLE, 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 } enum ButtonState { @@ -174,7 +253,7 @@ class UsernameEditViewModel extends ViewModel { static class Factory extends ViewModelProvider.NewInstanceFactory { @Override - public @NonNull T create(@NonNull Class modelClass) { + public @NonNull T create(@NonNull Class modelClass) { //noinspection ConstantConditions return modelClass.cast(new UsernameEditViewModel()); } 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 new file mode 100644 index 000000000..2fe50608c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameState.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.profiles.manage + +import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse + +/** + * Describes the state of the username suffix, which is a spanned CharSequence. + */ +sealed class UsernameState { + + protected open val username: String? = null + open val isInProgress: Boolean = false + + object Loading : UsernameState() { + override val isInProgress: Boolean = true + } + + object NoUsername : UsernameState() + + data class Reserved( + val reserveUsernameResponse: ReserveUsernameResponse + ) : UsernameState() { + override val username: String? = reserveUsernameResponse.username + } + + data class Set( + override val username: String + ) : UsernameState() + + fun getNickname(): String? { + return username?.split('#')?.firstOrNull() + } + + fun getDiscriminator(): String? { + return username?.split('#')?.lastOrNull() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameSuffix.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameSuffix.kt deleted file mode 100644 index d53a6b457..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameSuffix.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.thoughtcrime.securesms.profiles.manage - -import android.content.Context -import androidx.core.content.ContextCompat -import com.google.android.material.progressindicator.CircularProgressIndicatorSpec -import com.google.android.material.progressindicator.IndeterminateDrawable -import org.signal.core.util.DimensionUnit -import org.thoughtcrime.securesms.R - -/** - * Describes the state of the username suffix, which is a spanned CharSequence. - */ -data class UsernameSuffix( - val charSequence: CharSequence? -) { - - val isInProgress = charSequence == null - - companion object { - @JvmField - val LOADING = UsernameSuffix(null) - - @JvmField - val NONE = UsernameSuffix("") - - @JvmStatic - fun fromCode(code: Int) = UsernameSuffix("#$code") - - @JvmStatic - fun getInProgressDrawable(context: Context): IndeterminateDrawable { - val progressIndicatorSpec = CircularProgressIndicatorSpec(context, null).apply { - indicatorInset = 0 - indicatorSize = DimensionUnit.DP.toPixels(16f).toInt() - trackColor = ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant) - trackThickness = DimensionUnit.DP.toPixels(1f).toInt() - } - - return IndeterminateDrawable.createCircularDrawable(context, progressIndicatorSpec).apply { - setBounds(0, 0, DimensionUnit.DP.toPixels(16f).toInt(), DimensionUnit.DP.toPixels(16f).toInt()) - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index b43135566..3c7c7601c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -570,6 +570,37 @@ public class Recipient { } public @NonNull String getDisplayName(@NonNull Context context) { + String name = getNameFromLocalData(context); + + if (Util.isEmpty(name)) { + name = context.getString(R.string.Recipient_unknown); + } + + return StringUtil.isolateBidi(name); + } + + public @NonNull String getDisplayNameOrUsername(@NonNull Context context) { + String name = getNameFromLocalData(context); + + if (Util.isEmpty(name)) { + name = StringUtil.isolateBidi(username); + } + + if (Util.isEmpty(name)) { + name = StringUtil.isolateBidi(context.getString(R.string.Recipient_unknown)); + } + + return StringUtil.isolateBidi(name); + } + + public boolean hasNonUsernameDisplayName(@NonNull Context context) { + return getNameFromLocalData(context) != null; + } + + /** + * @return local name for user ignoring the username. + */ + private @Nullable String getNameFromLocalData(@NonNull Context context) { String name = getGroupName(context); if (Util.isEmpty(name)) { @@ -588,40 +619,6 @@ public class Recipient { name = email; } - if (Util.isEmpty(name)) { - name = context.getString(R.string.Recipient_unknown); - } - - return StringUtil.isolateBidi(name); - } - - public @NonNull String getDisplayNameOrUsername(@NonNull Context context) { - String name = getGroupName(context); - - if (Util.isEmpty(name)) { - name = systemContactName; - } - - if (Util.isEmpty(name)) { - name = StringUtil.isolateBidi(getProfileName().toString()); - } - - if (Util.isEmpty(name) && !Util.isEmpty(e164)) { - name = PhoneNumberFormatter.prettyPrint(e164); - } - - if (Util.isEmpty(name)) { - name = StringUtil.isolateBidi(email); - } - - if (Util.isEmpty(name)) { - name = StringUtil.isolateBidi(username); - } - - if (Util.isEmpty(name)) { - name = StringUtil.isolateBidi(context.getString(R.string.Recipient_unknown)); - } - return name; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java index a3d6d93a9..71f38ca50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; +import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.push.ServiceId; import java.io.IOException; @@ -67,8 +68,8 @@ public class UsernameUtil { try { Log.d(TAG, "No local user with this username. Searching remotely."); - SignalServiceProfile profile = ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfileByUsername(username, Optional.empty(), Locale.getDefault()); - return Optional.ofNullable(profile.getServiceId()); + ACI aci = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsername(username); + return Optional.ofNullable(aci); } catch (IOException e) { return Optional.empty(); } diff --git a/core-util/src/main/java/org/signal/core/util/Result.kt b/core-util/src/main/java/org/signal/core/util/Result.kt index 77fb41d50..3b26e7ce2 100644 --- a/core-util/src/main/java/org/signal/core/util/Result.kt +++ b/core-util/src/main/java/org/signal/core/util/Result.kt @@ -9,7 +9,10 @@ sealed class Result { data class Success(val success: S) : Result() companion object { + @JvmStatic fun success(value: S) = Success(value) + + @JvmStatic fun failure(value: F) = Failure(value) } 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 f814dd204..474cf68c9 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 @@ -70,6 +70,7 @@ import org.whispersystems.signalservice.internal.push.PushServiceSocket; import org.whispersystems.signalservice.internal.push.RemoteAttestationUtil; import org.whispersystems.signalservice.internal.push.RemoteConfigResponse; import org.whispersystems.signalservice.internal.push.RequestVerificationCodeResponse; +import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.push.VerifyAccountResponse; import org.whispersystems.signalservice.internal.push.WhoAmIResponse; @@ -866,8 +867,20 @@ public class SignalServiceAccountManager { } } - public void setUsername(String username) throws IOException { - this.pushServiceSocket.setUsername(username); + public ACI getAciByUsername(String username) throws IOException { + return this.pushServiceSocket.getAciByUsername(username); + } + + public void setUsername(String nickname, String existingUsername) throws IOException { + this.pushServiceSocket.setUsername(nickname, existingUsername); + } + + public ReserveUsernameResponse reserveUsername(String nickname) throws IOException { + return this.pushServiceSocket.reserveUsername(nickname); + } + + public void confirmUsername(ReserveUsernameResponse reserveUsernameResponse) throws IOException { + this.pushServiceSocket.confirmUsername(reserveUsernameResponse); } public void deleteUsername() throws IOException { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java index 837416501..2e7f51e27 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java @@ -118,12 +118,6 @@ public class SignalServiceMessageReceiver { } } - public SignalServiceProfile retrieveProfileByUsername(String username, Optional unidentifiedAccess, Locale locale) - throws IOException - { - return socket.retrieveProfileByUsername(username, unidentifiedAccess, locale); - } - public InputStream retrieveProfileAvatar(String path, File destination, ProfileKey profileKey, long maxSizeBytes) throws IOException { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameIsNotAssociatedWithAnAccountException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameIsNotAssociatedWithAnAccountException.java new file mode 100644 index 000000000..92e678fd2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameIsNotAssociatedWithAnAccountException.java @@ -0,0 +1,7 @@ +package org.whispersystems.signalservice.api.push.exceptions; + +public class UsernameIsNotAssociatedWithAnAccountException extends NotFoundException { + public UsernameIsNotAssociatedWithAnAccountException() { + super("The given username is not associated with an account."); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameIsNotReservedException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameIsNotReservedException.java new file mode 100644 index 000000000..7885e6a2b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/UsernameIsNotReservedException.java @@ -0,0 +1,7 @@ +package org.whispersystems.signalservice.api.push.exceptions; + +public class UsernameIsNotReservedException extends NonSuccessfulResponseCodeException { + public UsernameIsNotReservedException() { + super(409, "The given username is not associated with an account."); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ConfirmUsernameRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ConfirmUsernameRequest.java new file mode 100644 index 000000000..9e2c2612b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ConfirmUsernameRequest.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +class ConfirmUsernameRequest { + @JsonProperty + private String usernameToConfirm; + + @JsonProperty + private String reservationToken; + + ConfirmUsernameRequest(String usernameToConfirm, String reservationToken) { + this.usernameToConfirm = usernameToConfirm; + this.reservationToken = reservationToken; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/GetAciByUsernameResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/GetAciByUsernameResponse.java new file mode 100644 index 000000000..396e8d1b1 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/GetAciByUsernameResponse.java @@ -0,0 +1,18 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * JSON POJO that represents the returned ACI from a call to + * /v1/account/username/[username] + */ +class GetAciByUsernameResponse { + @JsonProperty + private String uuid; + + GetAciByUsernameResponse() {} + + String getUuid() { + return uuid; + } +} 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 22d33f804..5670ea80c 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 @@ -53,6 +53,7 @@ import org.whispersystems.signalservice.api.payments.CurrencyConversions; import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite; +import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -79,6 +80,8 @@ import org.whispersystems.signalservice.api.push.exceptions.RemoteAttestationRes import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; +import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotAssociatedWithAnAccountException; +import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException; import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException; import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException; import org.whispersystems.signalservice.api.storage.StorageAuthResponse; @@ -139,6 +142,8 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; @@ -156,10 +161,12 @@ import java.util.concurrent.TimeUnit; import java.util.function.Function; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; +import io.reactivex.rxjava3.annotations.NonNull; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; import okhttp3.Call; @@ -195,8 +202,10 @@ public class PushServiceSocket { private static final String REGISTRATION_LOCK_PATH = "/v1/accounts/registration_lock"; private static final String REQUEST_PUSH_CHALLENGE = "/v1/accounts/fcm/preauth/%s/%s"; private static final String WHO_AM_I = "/v1/accounts/whoami"; - private static final String SET_USERNAME_PATH = "/v1/accounts/username/%s"; - private static final String DELETE_USERNAME_PATH = "/v1/accounts/username"; + private static final String GET_USERNAME_PATH = "/v1/accounts/username/%s"; + private static final String MODIFY_USERNAME_PATH = "/v1/accounts/username"; + private static final String RESERVE_USERNAME_PATH = "/v1/accounts/username/reserved"; + private static final String CONFIRM_USERNAME_PATH = "/v1/accounts/username/confirm"; private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me"; private static final String CHANGE_NUMBER_PATH = "/v1/accounts/number"; private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s"; @@ -221,7 +230,6 @@ public class PushServiceSocket { private static final String PAYMENTS_AUTH_PATH = "/v1/payments/auth"; private static final String PROFILE_PATH = "/v1/profile/%s"; - private static final String PROFILE_USERNAME_PATH = "/v1/profile/username/%s"; private static final String PROFILE_BATCH_CHECK_PATH = "/v1/profile/identity_check/batch"; private static final String SENDER_CERTIFICATE_PATH = "/v1/certificate/delivery"; @@ -770,19 +778,6 @@ public class PushServiceSocket { }); } - public SignalServiceProfile retrieveProfileByUsername(String username, Optional unidentifiedAccess, Locale locale) - throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException - { - String response = makeServiceRequest(String.format(PROFILE_USERNAME_PATH, username), "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), unidentifiedAccess); - - try { - return JsonUtil.fromJson(response, SignalServiceProfile.class); - } catch (IOException e) { - Log.w(TAG, e); - throw new MalformedResponseException("Unable to parse entity", e); - } - } - public ListenableFuture retrieveVersionedProfileAndCredential(UUID target, ProfileKey profileKey, Optional unidentifiedAccess, Locale locale) { ProfileKeyVersion profileKeyIdentifier = profileKey.getProfileKeyVersion(target); ProfileKeyCredentialRequestContext requestContext = clientZkProfileOperations.createProfileKeyCredentialRequestContext(random, target, profileKey); @@ -899,17 +894,102 @@ public class PushServiceSocket { .onErrorReturn(ServiceResponse::forUnknownError); } - public void setUsername(String username) throws IOException { - makeServiceRequest(String.format(SET_USERNAME_PATH, username), "PUT", "", NO_HEADERS, (responseCode, body) -> { + /** + * Gets the ACI for the given username, if it exists. This is an unauthenticated request. + * + * This network request can have the following error responses: + *
    + *
  • 404 - The username given is not associated with an account
  • + *
  • 428 - Rate-limited, retry is available in the Retry-After header
  • + *
  • 400 - Bad Request. The request included authentication.
  • + *
+ * + * @param username The username to look up. + * @return The ACI for the given username if it exists. + * @throws IOException if a network exception occurs. + */ + public @NonNull ACI getAciByUsername(String username) throws IOException { + String response = makeServiceRequestWithoutAuthentication( + String.format(GET_USERNAME_PATH, URLEncoder.encode(username, StandardCharsets.UTF_8.toString())), + "GET", + null, + (responseCode, body) -> { + if (responseCode == 404) { + throw new UsernameIsNotAssociatedWithAnAccountException(); + } + } + ); + + GetAciByUsernameResponse getAciByUsernameResponse = JsonUtil.fromJsonResponse(response, GetAciByUsernameResponse.class); + return ACI.from(UUID.fromString(getAciByUsernameResponse.getUuid())); + } + + /** + * Set the username for the account without seeing the discriminator first. + * + * @param nickname The user-supplied nickname, which must meet the requirements for usernames. + * @param existingUsername (Optional) If the account has a current username, indicates what the client thinks the current username is. Allows the server to + * deduplicate repeated requests. + * @return The username as set by the server, which includes both the nickname and discriminator. + * @throws IOException Thrown when the username is invalid or taken, or when another network error occurs. + */ + public @NonNull String setUsername(@NonNull String nickname, @Nullable String existingUsername) throws IOException { + SetUsernameRequest setUsernameRequest = new SetUsernameRequest(nickname, existingUsername); + + String responseString = makeServiceRequest(MODIFY_USERNAME_PATH, "PUT", JsonUtil.toJson(setUsernameRequest), NO_HEADERS, (responseCode, body) -> { switch (responseCode) { - case 400: throw new UsernameMalformedException(); + case 422: throw new UsernameMalformedException(); case 409: throw new UsernameTakenException(); } }, Optional.empty()); + + SetUsernameResponse response = JsonUtil.fromJsonResponse(responseString, SetUsernameResponse.class); + return response.getUsername(); + } + + /** + * Reserve a username for the account. This replaces an existing reservation if one exists. The username is guaranteed to be available for 5 minutes and can + * be confirmed with confirmUsername. + * + * @param nickname The user-supplied nickname, which must meet the requirements for usernames. + * @return The reserved username. It is available for confirmation for 5 minutes. + * @throws IOException Thrown when the username is invalid or taken, or when another network error occurs. + */ + public @NonNull ReserveUsernameResponse reserveUsername(@NonNull String nickname) throws IOException { + ReserveUsernameRequest reserveUsernameRequest = new ReserveUsernameRequest(nickname); + + String responseString = makeServiceRequest(RESERVE_USERNAME_PATH, "PUT", JsonUtil.toJson(reserveUsernameRequest), NO_HEADERS, (responseCode, body) -> { + switch (responseCode) { + case 422: throw new UsernameMalformedException(); + case 409: throw new UsernameTakenException(); + } + }, Optional.empty()); + + return JsonUtil.fromJsonResponse(responseString, ReserveUsernameResponse.class); + } + + /** + * Set a previously reserved username for the account. + * + * @param reserveUsernameResponse The response object from the reservation + * @throws IOException Thrown when the username is invalid or taken, or when another network error occurs. + */ + public void confirmUsername(ReserveUsernameResponse reserveUsernameResponse) throws IOException { + ConfirmUsernameRequest confirmUsernameRequest = new ConfirmUsernameRequest(reserveUsernameResponse.getUsername(), reserveUsernameResponse.getReservationToken()); + + makeServiceRequest(CONFIRM_USERNAME_PATH, "PUT", JsonUtil.toJson(confirmUsernameRequest), NO_HEADERS, (responseCode, body) -> { + switch (responseCode) { + case 409: throw new UsernameIsNotReservedException(); + case 410: throw new UsernameTakenException(); + } + }, Optional.empty()); } + /** + * Remove the username associated with the account. + */ public void deleteUsername() throws IOException { - makeServiceRequest(DELETE_USERNAME_PATH, "DELETE", null); + makeServiceRequest(MODIFY_USERNAME_PATH, "DELETE", null); } public void deleteAccount() throws IOException { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java new file mode 100644 index 000000000..8435c8ced --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameRequest.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +class ReserveUsernameRequest { + @JsonProperty + private String nickname; + + ReserveUsernameRequest(String nickname) { + this.nickname = nickname; + } + + String getNickname() { + return nickname; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java new file mode 100644 index 000000000..24f4d326a --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReserveUsernameResponse.java @@ -0,0 +1,21 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ReserveUsernameResponse { + @JsonProperty + private String username; + + @JsonProperty + private String reservationToken; + + ReserveUsernameResponse() {} + + public String getUsername() { + return username; + } + + String getReservationToken() { + return reservationToken; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameRequest.java new file mode 100644 index 000000000..b7a93d073 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameRequest.java @@ -0,0 +1,24 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +class SetUsernameRequest { + @JsonProperty + private String nickname; + + @JsonProperty + private String existingUsername; + + SetUsernameRequest(String nickname, String existingUsername) { + this.nickname = nickname; + this.existingUsername = existingUsername; + } + + String getNickname() { + return nickname; + } + + String getExistingUsername() { + return existingUsername; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameResponse.java new file mode 100644 index 000000000..974c0057d --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/SetUsernameResponse.java @@ -0,0 +1,14 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +class SetUsernameResponse { + @JsonProperty + private String username; + + SetUsernameResponse() {} + + String getUsername() { + return username; + } +}