Update to the new username link spec.

This commit is contained in:
Greyson Parrelli
2023-08-25 09:33:57 -04:00
parent a6dd4345ab
commit 8a93814bac
47 changed files with 1283 additions and 463 deletions

View File

@@ -24,8 +24,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.thoughtcrime.securesms.AvatarPreviewActivity;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
@@ -47,7 +45,6 @@ import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.util.Base64UrlSafe;
import java.util.Arrays;
import java.util.Optional;
@@ -247,7 +244,6 @@ public class ManageProfileFragment extends LoggingFragment {
binding.manageProfileUsernameShare.setVisibility(View.GONE);
} else {
binding.manageProfileUsername.setText(username);
binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username));
binding.manageProfileUsernameShare.setVisibility(View.VISIBLE);
}
}
@@ -318,7 +314,7 @@ public class ManageProfileFragment extends LoggingFragment {
disposables.add(disposable);
}
private void handleUsernameDeletionResult(@NonNull UsernameEditRepository.UsernameDeleteResult usernameDeleteResult) {
private void handleUsernameDeletionResult(@NonNull UsernameRepository.UsernameDeleteResult usernameDeleteResult) {
switch (usernameDeleteResult) {
case SUCCESS:
Snackbar.make(requireView(), R.string.ManageProfileFragment__username_deleted, Snackbar.LENGTH_SHORT).show();

View File

@@ -50,7 +50,7 @@ class ManageProfileViewModel extends ViewModel {
private final SingleLiveEvent<Event> events;
private final RecipientForeverObserver observer;
private final ManageProfileRepository repository;
private final UsernameEditRepository usernameEditRepository;
private final UsernameRepository usernameEditRepository;
private final MutableLiveData<Optional<Badge>> badge;
private byte[] previousAvatar;
@@ -63,7 +63,7 @@ class ManageProfileViewModel extends ViewModel {
this.aboutEmoji = new MutableLiveData<>();
this.events = new SingleLiveEvent<>();
this.repository = new ManageProfileRepository();
this.usernameEditRepository = new UsernameEditRepository();
this.usernameEditRepository = new UsernameRepository();
this.badge = new DefaultValueLiveData<>(Optional.empty());
this.observer = this::onRecipientChanged;
this.avatarState = LiveDataUtil.combineLatest(Recipient.self().live().getLiveData(), internalAvatarState, (self, state) -> new AvatarState(state, self));
@@ -104,7 +104,7 @@ class ManageProfileViewModel extends ViewModel {
return events;
}
public Single<UsernameEditRepository.UsernameDeleteResult> deleteUsername() {
public Single<UsernameRepository.UsernameDeleteResult> deleteUsername() {
return usernameEditRepository.deleteUsername().observeOn(AndroidSchedulers.mainThread());
}

View File

@@ -1,131 +0,0 @@
package org.thoughtcrime.securesms.profiles.manage;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.signal.core.util.Result;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.UsernameUtil;
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 org.whispersystems.util.Base64UrlSafe;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
class UsernameEditRepository {
private static final String TAG = Log.tag(UsernameEditRepository.class);
private final SignalServiceAccountManager accountManager;
UsernameEditRepository() {
this.accountManager = ApplicationDependencies.getSignalServiceAccountManager();
}
@NonNull Single<Result<UsernameState.Reserved, UsernameSetResult>> reserveUsername(@NonNull String nickname) {
return Single.fromCallable(() -> reserveUsernameInternal(nickname)).subscribeOn(Schedulers.io());
}
@NonNull Single<UsernameSetResult> confirmUsername(@NonNull UsernameState.Reserved reserved) {
return Single.fromCallable(() -> confirmUsernameInternal(reserved)).subscribeOn(Schedulers.io());
}
@NonNull Single<UsernameDeleteResult> deleteUsername() {
return Single.fromCallable(this::deleteUsernameInternal).subscribeOn(Schedulers.io());
}
@WorkerThread
private @NonNull Result<UsernameState.Reserved, UsernameSetResult> reserveUsernameInternal(@NonNull String nickname) {
try {
List<String> candidates = Username.generateCandidates(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH);
List<String> hashes = new ArrayList<>();
for (String candidate : candidates) {
byte[] hash = Username.hash(candidate);
hashes.add(Base64UrlSafe.encodeBytesWithoutPadding(hash));
}
ReserveUsernameResponse response = accountManager.reserveUsername(hashes);
int hashIndex = hashes.indexOf(response.getUsernameHash());
if (hashIndex == -1) {
Log.w(TAG, "[reserveUsername] The response hash could not be found in our set of hashes.");
return Result.failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR);
}
Log.i(TAG, "[reserveUsername] Successfully reserved username.");
return Result.success(new UsernameState.Reserved(candidates.get(hashIndex), response));
} catch (BaseUsernameException e) {
Log.w(TAG, "[reserveUsername] An error occurred while generating candidates.");
return Result.failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR);
} 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 UsernameState.Reserved reserved) {
try {
accountManager.confirmUsername(reserved.getUsername(), reserved.getReserveUsernameResponse());
SignalDatabase.recipients().setUsername(Recipient.self().getId(), reserved.getUsername());
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync();
Log.i(TAG, "[confirmUsername] Successfully reserved username.");
return UsernameSetResult.SUCCESS;
} catch (UsernameTakenException e) {
Log.w(TAG, "[confirmUsername] Username gone.");
return UsernameSetResult.USERNAME_UNAVAILABLE;
} catch (UsernameIsNotReservedException e) {
Log.w(TAG, "[confirmUsername] Username was not reserved.");
return UsernameSetResult.USERNAME_INVALID;
} catch (IOException e) {
Log.w(TAG, "[confirmUsername] Generic network exception.", e);
return UsernameSetResult.NETWORK_ERROR;
}
}
@WorkerThread
private @NonNull UsernameDeleteResult deleteUsernameInternal() {
try {
accountManager.deleteUsername();
SignalDatabase.recipients().setUsername(Recipient.self().getId(), null);
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync();
Log.i(TAG, "[deleteUsername] Successfully deleted the username.");
return UsernameDeleteResult.SUCCESS;
} catch (IOException e) {
Log.w(TAG, "[deleteUsername] Generic network exception.", e);
return UsernameDeleteResult.NETWORK_ERROR;
}
}
enum UsernameSetResult {
SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR, CANDIDATE_GENERATION_ERROR
}
enum UsernameDeleteResult {
SUCCESS, NETWORK_ERROR
}
interface Callback<E> {
void onComplete(E result);
}
}

View File

@@ -40,15 +40,15 @@ class UsernameEditViewModel extends ViewModel {
private static final long NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS = 500;
private final PublishSubject<Event> events;
private final UsernameEditRepository repo;
private final RxStore<State> uiState;
private final PublishSubject<Event> events;
private final UsernameRepository repo;
private final RxStore<State> uiState;
private final PublishProcessor<String> nicknamePublisher;
private final CompositeDisposable disposables;
private final boolean isInRegistration;
private UsernameEditViewModel(boolean isInRegistration) {
this.repo = new UsernameEditRepository();
this.repo = new UsernameRepository();
this.uiState = new RxStore<>(new State(ButtonState.SUBMIT_DISABLED, UsernameStatus.NONE, Recipient.self().getUsername().<UsernameState>map(UsernameState.Set::new)
.orElse(UsernameState.NoUsername.INSTANCE)), Schedulers.computation());
this.events = PublishSubject.create();

View File

@@ -0,0 +1,273 @@
package org.thoughtcrime.securesms.profiles.manage
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.Result
import org.signal.core.util.Result.Companion.failure
import org.signal.core.util.Result.Companion.success
import org.signal.core.util.logging.Log
import org.signal.libsignal.usernames.BaseUsernameException
import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkResetResult
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.NetworkUtil
import org.thoughtcrime.securesms.util.UsernameUtil
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
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.util.Base64UrlSafe
import java.io.IOException
/**
* Performs various actions around usernames and username links.
*/
class UsernameRepository {
private val accountManager: SignalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager()
/**
* 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>> {
return Single
.fromCallable { reserveUsernameInternal(nickname) }
.subscribeOn(Schedulers.io())
}
/**
* Given a reserved username (obtained via [reserveUsername]), this will confirm that reservation, assigning the user that username.
*/
fun confirmUsername(reserved: UsernameState.Reserved): Single<UsernameSetResult> {
return Single
.fromCallable { confirmUsernameInternal(reserved) }
.subscribeOn(Schedulers.io())
}
/**
* Deletes the username from the local user's account
*/
fun deleteUsername(): Single<UsernameDeleteResult> {
return Single
.fromCallable { deleteUsernameInternal() }
.subscribeOn(Schedulers.io())
}
/**
* Creates or rotates the username link for the local user. If successful, the [UsernameLinkComponents] will be returned.
* If it fails for any reason, the optional will be empty.
*
* The assumption here is that when the user clicks this button, they will either have a new link, or no link at all.
* This is to prevent indeterminate states where the network call fails but may have actually succeeded, that kind of thing.
* As such, it's recommended to block calling this method on a network check.
*/
fun createOrResetUsernameLink(): Single<UsernameLinkResetResult> {
if (!NetworkUtil.isConnected(ApplicationDependencies.getApplication())) {
Log.w(TAG, "[createOrRotateUsernameLink] No network! Not making any changes.")
return Single.just(UsernameLinkResetResult.NetworkUnavailable)
}
val usernameString = SignalStore.account().username
if (usernameString.isNullOrBlank()) {
Log.w(TAG, "[createOrRotateUsernameLink] No username set! Cannot rotate the link!")
return Single.just(UsernameLinkResetResult.UnexpectedError)
}
val username = try {
Username(usernameString)
} catch (e: BaseUsernameException) {
Log.w(TAG, "[createOrRotateUsernameLink] Failed to parse our own username! Cannot rotate the link!")
return Single.just(UsernameLinkResetResult.UnexpectedError)
}
return Single
.fromCallable {
try {
SignalStore.account().usernameLink = null
Log.d(TAG, "[createOrRotateUsernameLink] Creating username link...")
val components = accountManager.createUsernameLink(username)
SignalStore.account().usernameLink = components
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
Log.d(TAG, "[createOrRotateUsernameLink] Username link created.")
UsernameLinkResetResult.Success(components)
} catch (e: IOException) {
Log.w(TAG, "[createOrRotateUsernameLink] Failed to rotate the username!")
UsernameLinkResetResult.NetworkError
}
}
.subscribeOn(Schedulers.io())
}
/**
* Given a full username link, this will do the necessary parsing and network lookups to resolve it to a (username, ACI) pair.
*/
fun convertLinkToUsernameAndAci(url: String): Single<UsernameLinkConversionResult> {
val components: UsernameLinkComponents = UsernameUtil.parseLink(url) ?: return Single.just(UsernameLinkConversionResult.Invalid)
return Single
.fromCallable {
var username: Username? = null
try {
val encryptedUsername: ByteArray = accountManager.getEncryptedUsernameFromLinkServerId(components.serverId)
val link = Username.UsernameLink(components.entropy, encryptedUsername)
username = Username.fromLink(link)
val aci = accountManager.getAciByUsernameHash(UsernameUtil.hashUsernameToBase64(username.toString()))
UsernameLinkConversionResult.Success(username, aci)
} catch (e: IOException) {
Log.w(TAG, "[convertLinkToUsername] Failed to lookup user.", e)
if (e is NonSuccessfulResponseCodeException) {
when (e.code) {
404 -> UsernameLinkConversionResult.NotFound(username)
422 -> UsernameLinkConversionResult.Invalid
else -> UsernameLinkConversionResult.NetworkError
}
} else {
UsernameLinkConversionResult.NetworkError
}
} catch (e: BaseUsernameException) {
Log.w(TAG, "[convertLinkToUsername] Bad username conversion.", e)
UsernameLinkConversionResult.Invalid
}
}
.subscribeOn(Schedulers.io())
}
@WorkerThread
private fun reserveUsernameInternal(nickname: String): Result<UsernameState.Reserved, UsernameSetResult> {
return try {
val candidates: List<Username> = Username.candidatesFrom(nickname, UsernameUtil.MIN_LENGTH, UsernameUtil.MAX_LENGTH)
val hashes: List<String> = candidates
.map { Base64UrlSafe.encodeBytesWithoutPadding(it.hash) }
val response = accountManager.reserveUsername(hashes)
val hashIndex = hashes.indexOf(response.usernameHash)
if (hashIndex == -1) {
Log.w(TAG, "[reserveUsername] The response hash could not be found in our set of hashes.")
return failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR)
}
Log.i(TAG, "[reserveUsername] Successfully reserved username.")
success(UsernameState.Reserved(candidates[hashIndex].username, response))
} catch (e: BaseUsernameException) {
Log.w(TAG, "[reserveUsername] An error occurred while generating candidates.")
failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR)
} catch (e: UsernameTakenException) {
Log.w(TAG, "[reserveUsername] Username taken.")
failure(UsernameSetResult.USERNAME_UNAVAILABLE)
} catch (e: UsernameMalformedException) {
Log.w(TAG, "[reserveUsername] Username malformed.")
failure(UsernameSetResult.USERNAME_INVALID)
} catch (e: IOException) {
Log.w(TAG, "[reserveUsername] Generic network exception.", e)
failure(UsernameSetResult.NETWORK_ERROR)
}
}
@WorkerThread
private fun confirmUsernameInternal(reserved: UsernameState.Reserved): UsernameSetResult {
return try {
val username = Username(reserved.username)
accountManager.confirmUsername(reserved.username, reserved.reserveUsernameResponse)
SignalStore.account().username = username.username
SignalStore.account().usernameLink = null
SignalDatabase.recipients.setUsername(Recipient.self().id, reserved.username)
SignalStore.account().usernameOutOfSync = false
Log.i(TAG, "[confirmUsername] Successfully confirmed username.")
if (tryToSetUsernameLink(username)) {
Log.i(TAG, "[confirmUsername] Successfully confirmed username link.")
} else {
Log.w(TAG, "[confirmUsername] Failed to confirm a username link. We'll try again when the user goes to view their link.")
}
UsernameSetResult.SUCCESS
} catch (e: UsernameTakenException) {
Log.w(TAG, "[confirmUsername] Username gone.")
UsernameSetResult.USERNAME_UNAVAILABLE
} catch (e: UsernameIsNotReservedException) {
Log.w(TAG, "[confirmUsername] Username was not reserved.")
UsernameSetResult.USERNAME_INVALID
} catch (e: BaseUsernameException) {
Log.w(TAG, "[confirmUsername] Username was not reserved.")
UsernameSetResult.USERNAME_INVALID
} catch (e: IOException) {
Log.w(TAG, "[confirmUsername] Generic network exception.", e)
UsernameSetResult.NETWORK_ERROR
}
}
private fun tryToSetUsernameLink(username: Username): Boolean {
for (i in 0..2) {
try {
val linkComponents = accountManager.createUsernameLink(username)
SignalStore.account().usernameLink = linkComponents
return true
} catch (e: IOException) {
Log.w(TAG, "[tryToSetUsernameLink] Failed with IOException on attempt " + (i + 1) + "/3", e)
}
}
return false
}
@WorkerThread
private fun deleteUsernameInternal(): UsernameDeleteResult {
return try {
accountManager.deleteUsername()
SignalDatabase.recipients.setUsername(Recipient.self().id, null)
SignalStore.account().usernameOutOfSync = false
Log.i(TAG, "[deleteUsername] Successfully deleted the username.")
UsernameDeleteResult.SUCCESS
} catch (e: IOException) {
Log.w(TAG, "[deleteUsername] Generic network exception.", e)
UsernameDeleteResult.NETWORK_ERROR
}
}
enum class UsernameSetResult {
SUCCESS, USERNAME_UNAVAILABLE, USERNAME_INVALID, NETWORK_ERROR, CANDIDATE_GENERATION_ERROR
}
enum class UsernameDeleteResult {
SUCCESS, NETWORK_ERROR
}
internal interface Callback<E> {
fun onComplete(result: E)
}
sealed class UsernameLinkConversionResult {
/** Successfully converted. Contains the username. */
data class Success(val username: Username, val aci: ACI) : UsernameLinkConversionResult()
/** Failed to convert due to a network error. */
object NetworkError : UsernameLinkConversionResult()
/** Failed to convert because the link or contents were invalid. */
object Invalid : UsernameLinkConversionResult()
/** No user exists for the given link. */
data class NotFound(val username: Username?) : UsernameLinkConversionResult()
}
companion object {
private val TAG = Log.tag(UsernameRepository::class.java)
}
}

View File

@@ -17,7 +17,7 @@ sealed class UsernameState {
object NoUsername : UsernameState()
data class Reserved(
override val username: String,
public override val username: String,
val reserveUsernameResponse: ReserveUsernameResponse
) : UsernameState()