mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Allow setting username in registration module.
This commit is contained in:
+6
@@ -8,10 +8,12 @@ import org.signal.core.util.UptimeSleepTimer
|
||||
import org.signal.core.util.billing.BillingApi
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
|
||||
import org.signal.network.api.AccountApiV2
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.signal.network.api.ArchiveApiV2
|
||||
import org.signal.network.config.SignalServiceConfiguration
|
||||
import org.signal.network.service.ArchiveService
|
||||
import org.signal.network.service.UsernameService
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
|
||||
@@ -49,6 +51,10 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
|
||||
override fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi = accountApi
|
||||
|
||||
override fun provideAccountApiV2(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApiV2 = mockk()
|
||||
|
||||
override fun provideUsernameService(accountApi: AccountApiV2): UsernameService = mockk()
|
||||
|
||||
override fun provideRecipientCache(): LiveRecipientCache {
|
||||
return recipientCache
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.signal.core.ui.logging.LoggingFragment;
|
||||
import org.signal.core.ui.permissions.Permissions;
|
||||
import org.signal.core.util.UsernameUtil;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -81,7 +82,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.search.SearchRepository;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
|
||||
|
||||
+1
-1
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.UsernameUtil
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactRepository
|
||||
@@ -22,7 +23,6 @@ import org.thoughtcrime.securesms.search.MessageResult
|
||||
import org.thoughtcrime.securesms.search.MessageSearchResult
|
||||
import org.thoughtcrime.securesms.search.SearchRepository
|
||||
import org.thoughtcrime.securesms.search.ThreadSearchResult
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.signal.libsignal.net.Network
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
|
||||
import org.signal.mediasend.MediaSendDependencies
|
||||
import org.signal.network.api.AccountApiV2
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.signal.network.api.ArchiveApiV2
|
||||
import org.signal.network.api.AttachmentApi
|
||||
@@ -42,6 +43,7 @@ import org.signal.network.config.SignalServiceConfiguration
|
||||
import org.signal.network.rest.SignalRestClient
|
||||
import org.signal.network.service.ArchiveService
|
||||
import org.signal.network.service.MessageService
|
||||
import org.signal.network.service.UsernameService
|
||||
import org.signal.video.exo.ExoPlayerPool
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.components.TypingStatusRepository
|
||||
@@ -404,6 +406,12 @@ object AppDependencies {
|
||||
val accountApi: AccountApi
|
||||
get() = networkModule.accountApi
|
||||
|
||||
val accountApiV2: AccountApiV2
|
||||
get() = networkModule.accountApiV2
|
||||
|
||||
val usernameService: UsernameService
|
||||
get() = networkModule.usernameService
|
||||
|
||||
val usernameApi: UsernameApi
|
||||
get() = networkModule.usernameApi
|
||||
|
||||
@@ -544,6 +552,8 @@ object AppDependencies {
|
||||
fun provideAuthWebSocket(signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>, libSignalNetworkSupplier: Supplier<Network>): SignalWebSocket.AuthenticatedWebSocket
|
||||
fun provideUnauthWebSocket(signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>, libSignalNetworkSupplier: Supplier<Network>): SignalWebSocket.UnauthenticatedWebSocket
|
||||
fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi
|
||||
fun provideAccountApiV2(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApiV2
|
||||
fun provideUsernameService(accountApi: AccountApiV2): UsernameService
|
||||
fun provideUsernameApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): UsernameApi
|
||||
fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi
|
||||
fun providePaymentsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): PaymentsApi
|
||||
|
||||
+12
@@ -30,6 +30,7 @@ import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.ServerPublicParams;
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||
import org.signal.network.api.AccountApiV2;
|
||||
import org.signal.network.api.ArchiveApi;
|
||||
import org.signal.network.api.ArchiveApiV2;
|
||||
import org.signal.network.api.AttachmentApi;
|
||||
@@ -49,6 +50,7 @@ import org.signal.network.api.UsernameApi;
|
||||
import org.signal.network.rest.SignalRestClient;
|
||||
import org.signal.network.service.ArchiveService;
|
||||
import org.signal.network.service.MessageService;
|
||||
import org.signal.network.service.UsernameService;
|
||||
import org.signal.video.exo.ExoPlayerPool;
|
||||
import org.thoughtcrime.securesms.backup.v2.SignalStoreArchiveCacheStore;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
@@ -613,6 +615,16 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
return new AccountApi(authWebSocket);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull AccountApiV2 provideAccountApiV2(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket) {
|
||||
return new AccountApiV2(authWebSocket);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull UsernameService provideUsernameService(@NonNull AccountApiV2 accountApi) {
|
||||
return new UsernameService(accountApi);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull UsernameApi provideUsernameApi(@NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket) {
|
||||
return new UsernameApi(unauthWebSocket);
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.signal.core.util.orNull
|
||||
import org.signal.core.util.resettableLazy
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
|
||||
import org.signal.network.api.AccountApiV2
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.signal.network.api.ArchiveApiV2
|
||||
import org.signal.network.api.AttachmentApi
|
||||
@@ -33,6 +34,7 @@ import org.signal.network.config.TrustStore
|
||||
import org.signal.network.rest.SignalRestClient
|
||||
import org.signal.network.service.ArchiveService
|
||||
import org.signal.network.service.MessageService
|
||||
import org.signal.network.service.UsernameService
|
||||
import org.signal.network.util.Tls12SocketFactory
|
||||
import org.signal.network.util.TlsProxySocketFactory
|
||||
import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl
|
||||
@@ -182,6 +184,12 @@ class NetworkDependenciesModule(
|
||||
provider.provideAccountApi(authWebSocket)
|
||||
}
|
||||
|
||||
val accountApiV2: AccountApiV2 by lazy {
|
||||
provider.provideAccountApiV2(authWebSocket)
|
||||
}
|
||||
|
||||
val usernameService: UsernameService by lazy { provider.provideUsernameService(accountApiV2) }
|
||||
|
||||
val usernameApi: UsernameApi by lazy {
|
||||
provider.provideUsernameApi(unauthWebSocket)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.net
|
||||
|
||||
import org.signal.network.api.AccountApiV2
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.signal.network.api.ArchiveApiV2
|
||||
import org.signal.network.api.AttachmentApi
|
||||
@@ -35,6 +36,9 @@ object SignalNetwork {
|
||||
val account: AccountApi
|
||||
get() = AppDependencies.accountApi
|
||||
|
||||
val accountV2: AccountApiV2
|
||||
get() = AppDependencies.accountApiV2
|
||||
|
||||
val archive: ArchiveApi
|
||||
get() = AppDependencies.archiveApi
|
||||
|
||||
|
||||
+1
-1
@@ -23,6 +23,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.textfield.TextInputLayout;
|
||||
|
||||
import org.signal.core.util.EditTextUtil;
|
||||
import org.signal.core.util.UsernameUtil;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.ui.logging.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -31,7 +32,6 @@ import org.thoughtcrime.securesms.databinding.UsernameEditFragmentBinding;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.FragmentResultContract;
|
||||
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton;
|
||||
|
||||
|
||||
+3
-3
@@ -12,6 +12,9 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.Result
|
||||
import org.signal.core.util.UsernameUtil.InvalidReason
|
||||
import org.signal.core.util.UsernameUtil.checkDiscriminator
|
||||
import org.signal.core.util.UsernameUtil.checkNickname
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.usernames.Username
|
||||
@@ -20,9 +23,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameDeleteResult
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameSetResult
|
||||
import org.thoughtcrime.securesms.util.NetworkUtil
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil.InvalidReason
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil.checkDiscriminator
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil.checkNickname
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.util.Usernames
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
+49
-101
@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.profiles.manage
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Result
|
||||
@@ -12,12 +14,13 @@ import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.RetryLaterException
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.usernames.UsernameLinkInvalidEntropyDataLength
|
||||
import org.signal.libsignal.usernames.UsernameLinkInvalidLinkData
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkResetResult
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -31,11 +34,9 @@ import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.updateUsern
|
||||
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.getCause
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.util.Usernames
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -98,9 +99,7 @@ object UsernameRepository {
|
||||
* Given a nickname, this will temporarily reserve a matching discriminator that can later be confirmed via [confirmUsernameAndCreateNewLink].
|
||||
*/
|
||||
fun reserveUsername(nickname: String, discriminator: String?): Single<Result<UsernameState.Reserved, UsernameSetResult>> {
|
||||
return Single
|
||||
.fromCallable { reserveUsernameInternal(nickname, discriminator) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
return rxSingle(Dispatchers.IO) { reserveUsernameInternal(nickname, discriminator) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,9 +119,7 @@ object UsernameRepository {
|
||||
* casing, and you want to keep the link the same.
|
||||
*/
|
||||
fun confirmUsernameAndCreateNewLink(username: Username): Single<UsernameSetResult> {
|
||||
return Single
|
||||
.fromCallable { confirmUsernameAndCreateNewLinkInternal(username) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
return rxSingle(Dispatchers.IO) { confirmUsernameAndCreateNewLinkInternal(username) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,44 +375,38 @@ object UsernameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a username (and the components of its shareable link) that the service has confirmed as the
|
||||
* account's current username, and arranges for it to be synced to linked devices and storage service.
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun reserveUsernameInternal(nickname: String, discriminator: String?): Result<UsernameState.Reserved, UsernameSetResult> {
|
||||
val candidates: List<Username> = try {
|
||||
if (discriminator == null) {
|
||||
Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH)
|
||||
} else {
|
||||
listOf(Username("$nickname${Usernames.DELIMITER}$discriminator"))
|
||||
}
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "[reserveUsername] An error occurred while generating candidates.")
|
||||
return failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR)
|
||||
fun persistUsernameAndLink(username: String, link: UsernameLinkComponents) {
|
||||
SignalStore.account.username = username
|
||||
SignalStore.account.usernameLink = link
|
||||
SignalDatabase.recipients.setUsername(Recipient.self().id, username)
|
||||
|
||||
SignalStore.account.usernameSyncState = AccountValues.UsernameSyncState.IN_SYNC
|
||||
SignalStore.account.usernameSyncErrorCount = 0
|
||||
SignalStore.misc.needsUsernameRestore = false
|
||||
|
||||
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
|
||||
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
|
||||
}
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
val hashes: List<ByteArray> = candidates.map { it.hash }
|
||||
|
||||
return when (val result = SignalNetwork.account.reserveUsername(hashes)) {
|
||||
is RequestResult.Success -> {
|
||||
val hashIndex = hashes.indexOfFirst { it.contentEquals(result.result) }
|
||||
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]))
|
||||
}
|
||||
is RequestResult.NonSuccess -> {
|
||||
Log.w(TAG, "[reserveUsername] Username taken.")
|
||||
failure(UsernameSetResult.USERNAME_UNAVAILABLE)
|
||||
private suspend fun reserveUsernameInternal(nickname: String, discriminator: String?): Result<UsernameState.Reserved, UsernameSetResult> {
|
||||
return when (val result = AppDependencies.usernameService.reserveUsername(nickname, discriminator)) {
|
||||
is RequestResult.Success -> success(UsernameState.Reserved(result.result))
|
||||
is RequestResult.NonSuccess -> when (result.error) {
|
||||
is ReserveUsernameError.NicknameInvalid -> failure(UsernameSetResult.CANDIDATE_GENERATION_ERROR)
|
||||
is ReserveUsernameError.NotAvailable -> failure(UsernameSetResult.USERNAME_UNAVAILABLE)
|
||||
is ReserveUsernameError.RateLimited -> failure(UsernameSetResult.RATE_LIMIT_ERROR)
|
||||
}
|
||||
is RequestResult.RetryableNetworkError -> {
|
||||
if (result.networkError is RetryLaterException) {
|
||||
Log.w(TAG, "[reserveUsername] Rate limit exceeded.")
|
||||
failure(UsernameSetResult.RATE_LIMIT_ERROR)
|
||||
} else {
|
||||
Log.w(TAG, "[reserveUsername] Generic network exception.", result.networkError)
|
||||
failure(UsernameSetResult.NETWORK_ERROR)
|
||||
}
|
||||
Log.w(TAG, "[reserveUsername] Generic network exception.", result.networkError)
|
||||
failure(UsernameSetResult.NETWORK_ERROR)
|
||||
}
|
||||
is RequestResult.ApplicationError -> throw result.cause
|
||||
}
|
||||
@@ -435,18 +426,7 @@ object UsernameRepository {
|
||||
|
||||
return when (val result = SignalNetwork.account.updateUsernameLink(newUsernameLink)) {
|
||||
is RequestResult.Success -> {
|
||||
SignalStore.account.username = updatedUsername.username
|
||||
SignalStore.account.usernameLink = result.result
|
||||
SignalDatabase.recipients.setUsername(Recipient.self().id, updatedUsername.username)
|
||||
SignalStore.account.usernameSyncState = AccountValues.UsernameSyncState.IN_SYNC
|
||||
SignalStore.account.usernameSyncErrorCount = 0
|
||||
SignalStore.misc.needsUsernameRestore = false
|
||||
|
||||
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
|
||||
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
|
||||
}
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
persistUsernameAndLink(updatedUsername.username, result.result)
|
||||
Log.i(TAG, "[updateUsernameDisplayForCurrentLink] Successfully updated username.")
|
||||
|
||||
UsernameSetResult.SUCCESS
|
||||
@@ -458,8 +438,7 @@ object UsernameRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun confirmUsernameAndCreateNewLinkInternal(username: Username): UsernameSetResult {
|
||||
private suspend fun confirmUsernameAndCreateNewLinkInternal(username: Username): UsernameSetResult {
|
||||
Log.i(TAG, "[confirmUsernameAndCreateNewLink] Beginning username confirmation...")
|
||||
|
||||
if (!NetworkUtil.isConnected(AppDependencies.application)) {
|
||||
@@ -467,59 +446,28 @@ object UsernameRepository {
|
||||
return UsernameSetResult.NETWORK_ERROR
|
||||
}
|
||||
|
||||
val link = username.generateLink()
|
||||
|
||||
return when (val result = SignalNetwork.account.confirmUsername(username, link)) {
|
||||
is NetworkResult.Success -> {
|
||||
SignalStore.account.username = username.username
|
||||
SignalStore.account.usernameLink = UsernameLinkComponents(link.entropy, result.result)
|
||||
SignalDatabase.recipients.setUsername(Recipient.self().id, username.username)
|
||||
SignalStore.account.usernameSyncState = AccountValues.UsernameSyncState.IN_SYNC
|
||||
SignalStore.account.usernameSyncErrorCount = 0
|
||||
SignalStore.misc.needsUsernameRestore = false
|
||||
|
||||
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
|
||||
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
|
||||
}
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
return when (val result = AppDependencies.usernameService.confirmUsername(username)) {
|
||||
is RequestResult.Success -> {
|
||||
persistUsernameAndLink(result.result.username.username, result.result.link)
|
||||
Log.i(TAG, "[confirmUsernameAndCreateNewLink] Successfully confirmed username.")
|
||||
|
||||
UsernameSetResult.SUCCESS
|
||||
}
|
||||
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
when (result.code) {
|
||||
409 -> {
|
||||
Log.w(TAG, "[confirmUsernameAndCreateNewLink] Username was not reserved.")
|
||||
UsernameSetResult.USERNAME_INVALID
|
||||
}
|
||||
|
||||
410 -> {
|
||||
Log.w(TAG, "[confirmUsernameAndCreateNewLink] Username gone.")
|
||||
UsernameSetResult.USERNAME_UNAVAILABLE
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "[confirmUsernameAndCreateNewLink] Generic network exception.", result.exception)
|
||||
UsernameSetResult.NETWORK_ERROR
|
||||
}
|
||||
is RequestResult.NonSuccess -> when (result.error) {
|
||||
is ConfirmUsernameError.ReservationInvalid -> UsernameSetResult.USERNAME_INVALID
|
||||
is ConfirmUsernameError.NotAvailable -> UsernameSetResult.USERNAME_UNAVAILABLE
|
||||
is ConfirmUsernameError.RateLimited -> UsernameSetResult.RATE_LIMIT_ERROR
|
||||
is ConfirmUsernameError.GenerationFailed -> UsernameSetResult.USERNAME_INVALID
|
||||
is ConfirmUsernameError.BadRequest -> {
|
||||
Log.w(TAG, "[confirmUsernameAndCreateNewLink] The service could not parse the request.")
|
||||
UsernameSetResult.NETWORK_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "[confirmUsernameAndCreateNewLink] Generic network exception.", result.exception)
|
||||
is RequestResult.RetryableNetworkError -> {
|
||||
Log.w(TAG, "[confirmUsernameAndCreateNewLink] Generic network exception.", result.networkError)
|
||||
UsernameSetResult.NETWORK_ERROR
|
||||
}
|
||||
|
||||
is NetworkResult.ApplicationError -> {
|
||||
if (result.throwable is BaseUsernameException) {
|
||||
Log.w(TAG, "[confirmUsernameAndCreateNewLink] Username was not reserved.")
|
||||
UsernameSetResult.USERNAME_INVALID
|
||||
} else {
|
||||
throw result.throwable
|
||||
}
|
||||
}
|
||||
is RequestResult.ApplicationError -> throw result.cause
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.core.ui.fonts.SignalSymbols
|
||||
import org.signal.core.util.BidiUtil
|
||||
import org.signal.core.util.UsernameUtil.isValidUsernameForSearch
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
@@ -54,7 +55,6 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil.isValidUsernameForSearch
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
|
||||
+1
-1
@@ -12,12 +12,12 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import org.signal.core.util.UsernameUtil
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.recipients.PhoneNumber
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientRepository
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
|
||||
class FindByViewModel(
|
||||
mode: FindByMode
|
||||
|
||||
+12
@@ -24,6 +24,7 @@ import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest
|
||||
import org.signal.network.NetworkResult
|
||||
@@ -53,6 +54,9 @@ import org.signal.network.api.RegistrationApiV2.SvrCredentials
|
||||
import org.signal.network.api.RegistrationApiV2.UpdateSessionError
|
||||
import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport
|
||||
import org.signal.network.service.ArchiveError
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ConfirmedUsername
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.signal.registration.LinkAndSyncWaitResult
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NetworkController.BackupMasterKeyError
|
||||
@@ -452,6 +456,14 @@ class AppRegistrationNetworkController(
|
||||
RequestResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
|
||||
return AppDependencies.usernameService.reserveUsername(nickname)
|
||||
}
|
||||
|
||||
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
|
||||
return AppDependencies.usernameService.confirmUsername(username)
|
||||
}
|
||||
|
||||
override suspend fun restoreAccountRecord(
|
||||
timeout: Duration
|
||||
): RequestResult<Unit, RestoreAccountRecordError> = withContext(Dispatchers.IO) {
|
||||
|
||||
+7
@@ -91,6 +91,7 @@ import org.thoughtcrime.securesms.keyvalue.isDecisionPending
|
||||
import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
import org.thoughtcrime.securesms.pin.SvrRepository
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
@@ -102,6 +103,7 @@ import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.time.LocalDateTime
|
||||
@@ -309,6 +311,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveUsername(username: String, usernameLink: UsernameLinkComponents) = withContext(Dispatchers.IO) {
|
||||
UsernameRepository.persistUsernameAndLink(username, usernameLink)
|
||||
SignalStore.uiHints.markHasSetOrSkippedUsernameCreation()
|
||||
}
|
||||
|
||||
override suspend fun setRestoreDecision(decision: RestoreDecision) = withContext(Dispatchers.Default) {
|
||||
if (!SignalStore.registration.restoreDecisionState.isDecisionPending) {
|
||||
return@withContext
|
||||
|
||||
+10
@@ -11,6 +11,7 @@ import org.signal.donations.permits.DonationPermitsRepository
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
|
||||
import org.signal.network.api.AccountApiV2
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.signal.network.api.AttachmentApi
|
||||
import org.signal.network.api.CallingApi
|
||||
@@ -26,6 +27,7 @@ import org.signal.network.api.SvrBApi
|
||||
import org.signal.network.api.UsernameApi
|
||||
import org.signal.network.config.SignalServiceConfiguration
|
||||
import org.signal.network.rest.SignalRestClient
|
||||
import org.signal.network.service.UsernameService
|
||||
import org.signal.video.exo.ExoPlayerPool
|
||||
import org.thoughtcrime.securesms.components.TypingStatusRepository
|
||||
import org.thoughtcrime.securesms.components.TypingStatusSender
|
||||
@@ -317,6 +319,14 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
override fun provideAccountApiV2(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApiV2 {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
override fun provideUsernameService(accountApi: AccountApiV2): UsernameService {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
override fun provideUsernameApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): UsernameApi {
|
||||
return mockk(relaxed = true)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
package org.signal.core.util
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.usernames.BadDiscriminatorCharacterException
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
package org.signal.core.util
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Test
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil.checkDiscriminator
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil.checkNickname
|
||||
import org.signal.core.util.UsernameUtil.checkDiscriminator
|
||||
import org.signal.core.util.UsernameUtil.checkNickname
|
||||
|
||||
class UsernameUtilTest {
|
||||
@Test
|
||||
+20
@@ -11,6 +11,7 @@ import org.signal.core.models.MasterKey
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest
|
||||
import org.signal.network.api.RegistrationApiV2.AccountAttributes
|
||||
@@ -36,6 +37,9 @@ import org.signal.network.api.RegistrationApiV2.SubmitVerificationCodeError
|
||||
import org.signal.network.api.RegistrationApiV2.SvrCredentials
|
||||
import org.signal.network.api.RegistrationApiV2.UpdateSessionError
|
||||
import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ConfirmedUsername
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.signal.registration.LinkAndSyncWaitResult
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NetworkController.BackupMasterKeyError
|
||||
@@ -256,6 +260,22 @@ class DebugNetworkController(
|
||||
return delegate.setProfile(givenName, familyName, avatar, discoverableByPhoneNumber)
|
||||
}
|
||||
|
||||
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
|
||||
NetworkDebugState.getOverride<RequestResult<Username, ReserveUsernameError>>("reserveUsername")?.let {
|
||||
Log.d(TAG, "[reserveUsername] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.reserveUsername(nickname)
|
||||
}
|
||||
|
||||
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
|
||||
NetworkDebugState.getOverride<RequestResult<ConfirmedUsername, ConfirmUsernameError>>("confirmUsername")?.let {
|
||||
Log.d(TAG, "[confirmUsername] Returning debug override")
|
||||
return it
|
||||
}
|
||||
return delegate.confirmUsername(username)
|
||||
}
|
||||
|
||||
override suspend fun restoreAccountRecord(timeout: kotlin.time.Duration): RequestResult<Unit, RestoreAccountRecordError> {
|
||||
NetworkDebugState.getOverride<RequestResult<Unit, RestoreAccountRecordError>>("restoreAccountRecord")?.let {
|
||||
Log.d(TAG, "[restoreAccountRecord] Returning debug override")
|
||||
|
||||
+163
-4
@@ -29,6 +29,7 @@ import org.signal.core.models.backup.MessageBackupKey
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.SleepTimer
|
||||
import org.signal.core.util.UsernameUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.devicetransfer.DeviceToDeviceTransferService
|
||||
import org.signal.libsignal.net.Network
|
||||
@@ -36,6 +37,8 @@ import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.zkgroup.GenericServerPublicParams
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
@@ -70,6 +73,9 @@ import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport
|
||||
import org.signal.network.config.SignalServiceConfiguration
|
||||
import org.signal.network.rest.SignalRestClient
|
||||
import org.signal.network.service.StorageServiceService
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ConfirmedUsername
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.signal.registration.LinkAndSyncWaitResult
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NetworkController.ProvisioningEvent
|
||||
@@ -81,6 +87,7 @@ import org.signal.registration.sample.fcm.PushChallengeReceiver
|
||||
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
|
||||
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceApi
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
|
||||
@@ -950,6 +957,136 @@ class DemoNetworkController(
|
||||
RequestResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
|
||||
val aci = RegistrationPreferences.aci
|
||||
val password = RegistrationPreferences.servicePassword
|
||||
|
||||
if (aci == null || password == null) {
|
||||
Log.w(TAG, "[reserveUsername] Credentials not available")
|
||||
return@withContext RequestResult.ApplicationError(IllegalStateException("Not registered"))
|
||||
}
|
||||
|
||||
val candidates: List<Username> = try {
|
||||
Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH)
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "[reserveUsername] Failed to generate candidates.", e)
|
||||
return@withContext RequestResult.NonSuccess(ReserveUsernameError.NicknameInvalid)
|
||||
}
|
||||
|
||||
val hashes: List<String> = candidates.map { Base64.encodeUrlSafeWithoutPadding(it.hash) }
|
||||
|
||||
try {
|
||||
val credentials = okhttp3.Credentials.basic(authUsername(aci), password)
|
||||
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
|
||||
val requestBody = json.encodeToString(ReserveUsernameRequestJson.serializer(), ReserveUsernameRequestJson(hashes))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url("$baseUrl/v1/accounts/username_hash/reserve")
|
||||
.put(requestBody)
|
||||
.header("Authorization", credentials)
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val reservedHash = json.decodeFromString<ReserveUsernameResponseJson>(response.body.string()).usernameHash
|
||||
val reserved = candidates.firstOrNull { Base64.encodeUrlSafeWithoutPadding(it.hash) == reservedHash }
|
||||
if (reserved == null) {
|
||||
Log.w(TAG, "[reserveUsername] The reserved hash was not one of our candidates.")
|
||||
RequestResult.NonSuccess(ReserveUsernameError.NicknameInvalid)
|
||||
} else {
|
||||
Log.i(TAG, "[reserveUsername] Successfully reserved a username.")
|
||||
RequestResult.Success(reserved)
|
||||
}
|
||||
}
|
||||
409 -> {
|
||||
RequestResult.NonSuccess(ReserveUsernameError.NotAvailable)
|
||||
}
|
||||
422 -> {
|
||||
RequestResult.NonSuccess(ReserveUsernameError.NicknameInvalid)
|
||||
}
|
||||
429 -> {
|
||||
RequestResult.NonSuccess(ReserveUsernameError.RateLimited(response.retryAfter()))
|
||||
}
|
||||
else -> {
|
||||
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[reserveUsername] IOException", e)
|
||||
RequestResult.RetryableNetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[reserveUsername] Exception", e)
|
||||
RequestResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> = withContext(Dispatchers.IO) {
|
||||
val aci = RegistrationPreferences.aci
|
||||
val password = RegistrationPreferences.servicePassword
|
||||
|
||||
if (aci == null || password == null) {
|
||||
Log.w(TAG, "[confirmUsername] Credentials not available")
|
||||
return@withContext RequestResult.ApplicationError(IllegalStateException("Not registered"))
|
||||
}
|
||||
|
||||
try {
|
||||
val link = username.generateLink()
|
||||
|
||||
val credentials = okhttp3.Credentials.basic(authUsername(aci), password)
|
||||
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
|
||||
val requestJson = ConfirmUsernameRequestJson(
|
||||
usernameHash = Base64.encodeUrlSafeWithoutPadding(username.hash),
|
||||
zkProof = Base64.encodeUrlSafeWithoutPadding(username.generateProof()),
|
||||
encryptedUsername = Base64.encodeUrlSafeWithoutPadding(link.encryptedUsername)
|
||||
)
|
||||
val requestBody = json.encodeToString(ConfirmUsernameRequestJson.serializer(), requestJson)
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url("$baseUrl/v1/accounts/username_hash/confirm")
|
||||
.put(requestBody)
|
||||
.header("Authorization", credentials)
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val linkHandle = UUID.fromString(json.decodeFromString<ConfirmUsernameResponseJson>(response.body.string()).usernameLinkHandle)
|
||||
Log.i(TAG, "[confirmUsername] Successfully confirmed the username.")
|
||||
RequestResult.Success(ConfirmedUsername(username, UsernameLinkComponents(link.entropy, linkHandle)))
|
||||
}
|
||||
409 -> {
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.ReservationInvalid)
|
||||
}
|
||||
410 -> {
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.NotAvailable)
|
||||
}
|
||||
422 -> {
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.BadRequest)
|
||||
}
|
||||
429 -> {
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.RateLimited(response.retryAfter()))
|
||||
}
|
||||
else -> {
|
||||
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "[confirmUsername] Failed to generate the username link.", e)
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.GenerationFailed)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "[confirmUsername] IOException", e)
|
||||
RequestResult.RetryableNetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[confirmUsername] Exception", e)
|
||||
RequestResult.ApplicationError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun restoreAccountRecord(
|
||||
timeout: kotlin.time.Duration
|
||||
): RequestResult<Unit, NetworkController.RestoreAccountRecordError> = withContext(Dispatchers.IO) {
|
||||
@@ -1131,7 +1268,7 @@ class DemoNetworkController(
|
||||
401 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.BadAuthCredential(response.body.string()))
|
||||
403 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.Forbidden(response.body.string()))
|
||||
404 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup)
|
||||
429 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(response.retryAfter()))
|
||||
429 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(response.retryAfter() ?: 0.seconds))
|
||||
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body?.string()}"))
|
||||
}
|
||||
}
|
||||
@@ -1176,7 +1313,7 @@ class DemoNetworkController(
|
||||
200, 204 -> RequestResult.Success(Unit)
|
||||
400 -> RequestResult.NonSuccess(NetworkController.ReserveBackupIdError.InvalidCredential)
|
||||
401 -> RequestResult.NonSuccess(NetworkController.ReserveBackupIdError.Unauthorized)
|
||||
429 -> RequestResult.NonSuccess(NetworkController.ReserveBackupIdError.RateLimited(response.retryAfter()))
|
||||
429 -> RequestResult.NonSuccess(NetworkController.ReserveBackupIdError.RateLimited(response.retryAfter() ?: 0.seconds))
|
||||
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}"))
|
||||
}
|
||||
}
|
||||
@@ -1349,6 +1486,28 @@ class DemoNetworkController(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ReserveUsernameRequestJson(
|
||||
val usernameHashes: List<String>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ReserveUsernameResponseJson(
|
||||
val usernameHash: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ConfirmUsernameRequestJson(
|
||||
val usernameHash: String,
|
||||
val zkProof: String,
|
||||
val encryptedUsername: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ConfirmUsernameResponseJson(
|
||||
val usernameLinkHandle: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class CdnReadCredentialsResponse(
|
||||
val headers: Map<String, String>
|
||||
@@ -1412,7 +1571,7 @@ class DemoNetworkController(
|
||||
)
|
||||
}
|
||||
|
||||
private fun Response.retryAfter(): Duration {
|
||||
return this.header("Retry-After")?.toLongOrNull()?.seconds ?: 0.seconds
|
||||
private fun Response.retryAfter(): Duration? {
|
||||
return this.header("Retry-After")?.toLongOrNull()?.seconds
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -52,6 +52,7 @@ import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestorePr
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
|
||||
import java.io.File
|
||||
@@ -87,6 +88,12 @@ class DemoStorageController(private val context: Context) : StorageController {
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun saveUsername(username: String, usernameLink: UsernameLinkComponents) = withContext(Dispatchers.IO) {
|
||||
RegistrationPreferences.username = username
|
||||
RegistrationPreferences.usernameLinkEntropy = usernameLink.entropy
|
||||
RegistrationPreferences.usernameLinkServerId = usernameLink.serverId.toString()
|
||||
}
|
||||
|
||||
override suspend fun clearAllData() = withContext(Dispatchers.IO) {
|
||||
File(context.filesDir, TEMP_PROTO_FILENAME).takeIf { it.exists() }?.delete()
|
||||
RegistrationPreferences.clearAll()
|
||||
|
||||
+15
@@ -65,6 +65,9 @@ object RegistrationPreferences {
|
||||
private const val KEY_EPHEMERAL_BACKUP_KEY = "ephemeral_backup_key"
|
||||
private const val KEY_LINK_AND_SYNC_FRAME_COUNT = "link_and_sync_frame_count"
|
||||
private const val KEY_LINK_AND_SYNC_DOWNLOADED_BYTES = "link_and_sync_downloaded_bytes"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_USERNAME_LINK_ENTROPY = "username_link_entropy"
|
||||
private const val KEY_USERNAME_LINK_SERVER_ID = "username_link_server_id"
|
||||
|
||||
fun init(context: Application) {
|
||||
this.context = context
|
||||
@@ -170,6 +173,18 @@ object RegistrationPreferences {
|
||||
if (value == null) remove(KEY_PROFILE_DISCOVERABLE) else putBoolean(KEY_PROFILE_DISCOVERABLE, value)
|
||||
}
|
||||
|
||||
var username: String?
|
||||
get() = prefs.getString(KEY_USERNAME, null)
|
||||
set(value) = prefs.edit { putString(KEY_USERNAME, value) }
|
||||
|
||||
var usernameLinkEntropy: ByteArray?
|
||||
get() = prefs.getString(KEY_USERNAME_LINK_ENTROPY, null)?.let { Base64.decode(it) }
|
||||
set(value) = prefs.edit { putString(KEY_USERNAME_LINK_ENTROPY, value?.let { Base64.encodeWithPadding(it) }) }
|
||||
|
||||
var usernameLinkServerId: String?
|
||||
get() = prefs.getString(KEY_USERNAME_LINK_SERVER_ID, null)
|
||||
set(value) = prefs.edit { putString(KEY_USERNAME_LINK_SERVER_ID, value) }
|
||||
|
||||
var linkedDeviceId: Int
|
||||
get() = prefs.getInt(KEY_LINKED_DEVICE_ID, -1)
|
||||
set(value) = prefs.edit { putInt(KEY_LINKED_DEVICE_ID, value) }
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest
|
||||
import org.signal.network.api.RegistrationApiV2.AccountAttributes
|
||||
@@ -43,6 +44,9 @@ import org.signal.network.api.RegistrationApiV2.SubmitVerificationCodeError
|
||||
import org.signal.network.api.RegistrationApiV2.SvrCredentials
|
||||
import org.signal.network.api.RegistrationApiV2.UpdateSessionError
|
||||
import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ConfirmedUsername
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration
|
||||
@@ -411,6 +415,23 @@ interface NetworkController {
|
||||
*/
|
||||
suspend fun restoreAccountRecord(timeout: Duration): RequestResult<Unit, RestoreAccountRecordError>
|
||||
|
||||
/**
|
||||
* Reserves a username composed of [nickname] plus a server-assigned numeric discriminator. The service holds the
|
||||
* reservation for a short time (~5 minutes), during which it can be finalized via [confirmUsername].
|
||||
* Reserving again replaces any previous reservation.
|
||||
*
|
||||
* `PUT /v1/accounts/username_hash/reserve`
|
||||
*/
|
||||
suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError>
|
||||
|
||||
/**
|
||||
* Confirms a reservation previously made via [reserveUsername], assigning the username to the account and creating
|
||||
* a new username link for it. Nothing is persisted locally -- see [StorageController.saveUsername].
|
||||
*
|
||||
* `PUT /v1/accounts/username_hash/confirm`
|
||||
*/
|
||||
suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError>
|
||||
|
||||
/**
|
||||
* Persists the user's chosen profile name (and optional avatar) for the freshly-registered account
|
||||
* and arranges for it to be synced to the service. Implementations may save the data locally and
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.signal.libsignal.protocol.kem.KEMKeyPair
|
||||
import org.signal.libsignal.protocol.kem.KEMKeyType
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.network.api.RegistrationApiV2.AccountAttributes
|
||||
import org.signal.network.api.RegistrationApiV2.CheckSvrCredentialsError
|
||||
@@ -58,6 +59,8 @@ import org.signal.network.api.RegistrationApiV2.SubmitVerificationCodeError
|
||||
import org.signal.network.api.RegistrationApiV2.SvrCredentials
|
||||
import org.signal.network.api.RegistrationApiV2.UpdateSessionError
|
||||
import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.signal.registration.NetworkController.MasterKeyResponse
|
||||
import org.signal.registration.NetworkController.ProvisioningEvent
|
||||
import org.signal.registration.NetworkController.RestoreMasterKeyError
|
||||
@@ -755,6 +758,29 @@ class RegistrationRepository(
|
||||
networkController.setProfile(givenName, familyName, avatar, discoverableByPhoneNumber)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves a username made from [nickname] plus a server-assigned discriminator.
|
||||
* See [NetworkController.reserveUsername].
|
||||
*/
|
||||
suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> = withContext(Dispatchers.IO) {
|
||||
networkController.reserveUsername(nickname)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a previously-reserved username on the service and persists it locally as the account's username.
|
||||
* See [NetworkController.confirmUsername].
|
||||
*/
|
||||
suspend fun confirmUsername(username: Username): RequestResult<Unit, ConfirmUsernameError> = withContext(Dispatchers.IO) {
|
||||
val result = networkController.confirmUsername(username)
|
||||
|
||||
if (result is RequestResult.Success) {
|
||||
storageController.saveUsername(result.result.username.username, result.result.link)
|
||||
}
|
||||
|
||||
// Drop the success type
|
||||
result.map { }
|
||||
}
|
||||
|
||||
suspend fun setNewlyCreatedPin(
|
||||
pin: String,
|
||||
isAlphanumeric: Boolean,
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.signal.registration.util.IdentityKeyPairParceler
|
||||
import org.signal.registration.util.KyberPreKeyRecordParceler
|
||||
import org.signal.registration.util.PNIParceler
|
||||
import org.signal.registration.util.SignedPreKeyRecordParceler
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
|
||||
/**
|
||||
* The set of methods that the registration module needs to persist data to disk.
|
||||
@@ -168,6 +169,12 @@ interface StorageController {
|
||||
*/
|
||||
suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo>
|
||||
|
||||
/**
|
||||
* Persists a username that was confirmed on the service (see [NetworkController.confirmUsername]), along with the
|
||||
* components of its shareable link, and arranges for it to be synced wherever the app needs it to be.
|
||||
*/
|
||||
suspend fun saveUsername(username: String, usernameLink: UsernameLinkComponents)
|
||||
|
||||
/**
|
||||
* Reads any profile data already on disk for the locally-registered account. May return data when
|
||||
* the user is re-registering (the previous profile name/avatar are still on the device) or after a
|
||||
|
||||
+59
-2
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -55,14 +56,17 @@ import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.registration.R
|
||||
import org.signal.registration.screens.OnePaneRegistrationScaffold
|
||||
import org.signal.registration.screens.RegistrationScaffold
|
||||
import org.signal.registration.screens.TwoPaneRegistrationScaffold
|
||||
import org.signal.registration.screens.attachDebugLogHelper
|
||||
import org.signal.registration.test.TestTags
|
||||
import org.whispersystems.signalservice.api.util.discriminator
|
||||
|
||||
/** Size of the avatar artwork, whose sphere occupies the inner 72dp of its 80dp box. */
|
||||
private val AVATAR_SIZE = 80.dp
|
||||
@@ -82,6 +86,8 @@ fun AddUsernameScreen(
|
||||
val simpleError: Pair<String, AddUsernameScreenEvents>? = when {
|
||||
state.dialogs.networkError -> stringResource(R.string.VerificationCodeScreen__network_error) to AddUsernameScreenEvents.NetworkErrorDialogDismissed
|
||||
state.dialogs.usernameUnavailable -> stringResource(R.string.AddUsernameScreen__this_username_is_not_available) to AddUsernameScreenEvents.UsernameUnavailableDialogDismissed
|
||||
state.dialogs.reservationLapsed -> stringResource(R.string.AddUsernameScreen__your_username_reservation_expired) to AddUsernameScreenEvents.ReservationLapsedDialogDismissed
|
||||
state.dialogs.rateLimited -> stringResource(R.string.VerificationCodeScreen__too_many_attempts) to AddUsernameScreenEvents.RateLimitedDialogDismissed
|
||||
state.dialogs.unknownError -> stringResource(R.string.VerificationCodeScreen__an_unexpected_error_occurred) to AddUsernameScreenEvents.UnknownErrorDialogDismissed
|
||||
else -> null
|
||||
}
|
||||
@@ -203,7 +209,7 @@ private fun ColumnScope.UsernameEntry(
|
||||
UsernameAvatar(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.AddUsernameScreen__choose_your_username),
|
||||
text = state.reservation?.username ?: stringResource(R.string.AddUsernameScreen__choose_your_username),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -224,6 +230,7 @@ private fun ColumnScope.UsernameEntry(
|
||||
supportingText = state.validationError?.let { error ->
|
||||
{ Text(stringResource(error.messageId)) }
|
||||
},
|
||||
suffix = discriminatorSuffix(state),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
@@ -270,6 +277,44 @@ private fun ColumnScope.UsernameEntry(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing content of the username field: a spinner while a username is being reserved, and once one is
|
||||
* reserved, its discriminator behind a divider (per the design, the discriminator is server-assigned and not
|
||||
* directly editable).
|
||||
*/
|
||||
private fun discriminatorSuffix(state: AddUsernameState): (@Composable () -> Unit)? {
|
||||
val discriminator = state.reservation?.discriminator
|
||||
|
||||
return when {
|
||||
state.isReserving -> {
|
||||
{
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
discriminator != null -> {
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Dividers.Vertical(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.height(20.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Text(text = discriminator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The designed avatar sphere with the "@" glyph on top. The sphere art is shared, so the glyph is drawn separately
|
||||
* rather than baked into the drawable.
|
||||
@@ -344,6 +389,7 @@ private val AddUsernameState.ValidationError.messageId: Int
|
||||
AddUsernameState.ValidationError.TOO_LONG -> R.string.AddUsernameScreen__usernames_must_be_at_most_32_characters
|
||||
AddUsernameState.ValidationError.INVALID_CHARACTERS -> R.string.AddUsernameScreen__usernames_can_only_contain
|
||||
AddUsernameState.ValidationError.CANNOT_START_WITH_DIGIT -> R.string.AddUsernameScreen__usernames_cannot_begin_with_a_number
|
||||
AddUsernameState.ValidationError.NOT_AVAILABLE -> R.string.AddUsernameScreen__this_username_is_not_available
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@@ -362,7 +408,18 @@ private fun AddUsernameScreenPreview() {
|
||||
private fun AddUsernameScreenFilledPreview() {
|
||||
Previews.Preview {
|
||||
AddUsernameScreen(
|
||||
state = AddUsernameState(username = "alice"),
|
||||
state = AddUsernameState(username = "alice", isReserving = true),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun AddUsernameScreenReservedPreview() {
|
||||
Previews.Preview {
|
||||
AddUsernameScreen(
|
||||
state = AddUsernameState(username = "alice", reservation = Username("alice.45")),
|
||||
onEvent = {}
|
||||
)
|
||||
}
|
||||
|
||||
+19
@@ -6,6 +6,9 @@
|
||||
package org.signal.registration.screens.addusername
|
||||
|
||||
import org.signal.core.util.censor
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
|
||||
sealed class AddUsernameScreenEvents {
|
||||
/** The user edited the username field. */
|
||||
@@ -13,6 +16,16 @@ sealed class AddUsernameScreenEvents {
|
||||
override fun toString(): String = "UsernameChanged(value=${value.censor()})"
|
||||
}
|
||||
|
||||
/** Internal: the user paused typing long enough for the entered nickname to be validated and reserved. */
|
||||
data class NicknameSettled(val value: String) : AddUsernameScreenEvents() {
|
||||
override fun toString(): String = "NicknameSettled(value=${value.censor()})"
|
||||
}
|
||||
|
||||
/** Internal: a reservation attempt for [nickname] finished with [result]. */
|
||||
data class ReservationCompleted(val nickname: String, val result: RequestResult<Username, ReserveUsernameError>) : AddUsernameScreenEvents() {
|
||||
override fun toString(): String = "ReservationCompleted(nickname=${nickname.censor()}, result=${result.javaClass.simpleName})"
|
||||
}
|
||||
|
||||
/** The user tapped the "learn more" link under the username field. */
|
||||
data object LearnMoreClicked : AddUsernameScreenEvents()
|
||||
|
||||
@@ -30,4 +43,10 @@ sealed class AddUsernameScreenEvents {
|
||||
|
||||
/** The user dismissed the username-unavailable dialog. */
|
||||
data object UsernameUnavailableDialogDismissed : AddUsernameScreenEvents()
|
||||
|
||||
/** The user dismissed the rate-limited dialog. */
|
||||
data object RateLimitedDialogDismissed : AddUsernameScreenEvents()
|
||||
|
||||
/** The user dismissed the reservation-lapsed dialog. */
|
||||
data object ReservationLapsedDialogDismissed : AddUsernameScreenEvents()
|
||||
}
|
||||
|
||||
+17
-5
@@ -6,34 +6,46 @@
|
||||
package org.signal.registration.screens.addusername
|
||||
|
||||
import org.signal.core.util.censor
|
||||
import org.signal.libsignal.usernames.Username
|
||||
|
||||
/**
|
||||
* State for the optional username entry screen.
|
||||
*/
|
||||
data class AddUsernameState(
|
||||
/** The nickname (the part of the username before the discriminator) as typed by the user. */
|
||||
val username: String = "",
|
||||
/** Set when the entered nickname fails validation, describing why. */
|
||||
val validationError: ValidationError? = null,
|
||||
/** The reserved username (nickname + discriminator) for the entered nickname, once one has been reserved. */
|
||||
val reservation: Username? = null,
|
||||
/** True while a reservation request for the entered nickname is in flight. */
|
||||
val isReserving: Boolean = false,
|
||||
val showSpinner: Boolean = false,
|
||||
val dialogs: Dialogs = Dialogs()
|
||||
) {
|
||||
/** Whether the entered nickname is complete enough to submit. */
|
||||
val isSubmittable: Boolean
|
||||
get() = !showSpinner && username.isNotBlank() && validationError == null
|
||||
get() = !showSpinner && !isReserving && username.isNotBlank() && validationError == null && reservation != null
|
||||
|
||||
override fun toString(): String = "AddUsernameState(username=${username.censor()}, validationError=$validationError, showSpinner=$showSpinner, dialogs=$dialogs)"
|
||||
override fun toString(): String = "AddUsernameState(username=${username.censor()}, validationError=$validationError, reservation=${reservation?.username?.censor()}, isReserving=$isReserving, showSpinner=$showSpinner, dialogs=$dialogs)"
|
||||
|
||||
enum class ValidationError {
|
||||
TOO_SHORT,
|
||||
TOO_LONG,
|
||||
INVALID_CHARACTERS,
|
||||
CANNOT_START_WITH_DIGIT
|
||||
CANNOT_START_WITH_DIGIT,
|
||||
|
||||
/** The nickname is valid, but no username could be reserved for it. */
|
||||
NOT_AVAILABLE
|
||||
}
|
||||
|
||||
data class Dialogs(
|
||||
val networkError: Boolean = false,
|
||||
val unknownError: Boolean = false,
|
||||
/** The nickname is valid but no discriminator was available for it. */
|
||||
val usernameUnavailable: Boolean = false
|
||||
/** The reserved username was claimed by someone else before it could be confirmed. */
|
||||
val usernameUnavailable: Boolean = false,
|
||||
/** The reservation expired before the user submitted it. */
|
||||
val reservationLapsed: Boolean = false,
|
||||
val rateLimited: Boolean = false
|
||||
)
|
||||
}
|
||||
|
||||
+193
-20
@@ -9,26 +9,42 @@ import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.util.UsernameUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import org.signal.registration.screens.util.navigateTo
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* View model for [AddUsernameScreen].
|
||||
*
|
||||
* Username reservation and confirmation endpoints aren't wired into this flow yet, so nothing is validated or
|
||||
* submitted. Every event the screen can produce is routed here and handled explicitly so that filling in the business
|
||||
* logic is a matter of replacing the TODO branches.
|
||||
* As the user types a nickname, we debounce their input and then validate it locally. If it's valid, we reserve a
|
||||
* username for it on the service (the nickname plus a server-assigned numeric discriminator), which is what lets us
|
||||
* show the discriminator while they type and detect taken nicknames early. Tapping "next" confirms the reservation,
|
||||
* making it the account's actual username.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
class AddUsernameViewModel(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
@@ -36,6 +52,8 @@ class AddUsernameViewModel(
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(AddUsernameViewModel::class)
|
||||
|
||||
private val NICKNAME_DEBOUNCE = 500.milliseconds
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(AddUsernameState())
|
||||
@@ -44,10 +62,21 @@ class AddUsernameViewModel(
|
||||
private val _actions = Channel<AddUsernameScreenActions>(Channel.BUFFERED)
|
||||
val actions: Flow<AddUsernameScreenActions> = _actions.receiveAsFlow()
|
||||
|
||||
private val nicknameChanges = MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
/** The in-flight reservation request. Only one may be live at a time -- starting a new one cancels the old one. */
|
||||
private var reserveJob: Job? = null
|
||||
|
||||
init {
|
||||
_state
|
||||
.onEach { Log.d(TAG, "[State] $it") }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
nicknameChanges
|
||||
.distinctUntilChanged()
|
||||
.debounce(NICKNAME_DEBOUNCE)
|
||||
.onEach { onEvent(AddUsernameScreenEvents.NicknameSettled(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: AddUsernameScreenEvents) {
|
||||
@@ -62,39 +91,183 @@ class AddUsernameViewModel(
|
||||
stateEmitter: (AddUsernameState) -> Unit
|
||||
) {
|
||||
when (event) {
|
||||
is AddUsernameScreenEvents.UsernameChanged -> {
|
||||
// TODO [phonenumberless] Validate the nickname and populate AddUsernameState.validationError.
|
||||
stateEmitter(state.copy(username = event.value))
|
||||
is AddUsernameScreenEvents.UsernameChanged -> applyUsernameChanged(state, event.value, stateEmitter)
|
||||
is AddUsernameScreenEvents.NicknameSettled -> applyNicknameSettled(state, event.value, stateEmitter)
|
||||
is AddUsernameScreenEvents.ReservationCompleted -> applyReservationCompleted(state, event, stateEmitter)
|
||||
is AddUsernameScreenEvents.LearnMoreClicked -> _actions.trySend(AddUsernameScreenActions.OpenLearnMoreArticle)
|
||||
is AddUsernameScreenEvents.SkipClicked -> applySkipClicked(parentEventEmitter)
|
||||
is AddUsernameScreenEvents.NextClicked -> applyNextClicked(state, parentEventEmitter, stateEmitter)
|
||||
is AddUsernameScreenEvents.NetworkErrorDialogDismissed -> applyDialogDismissed(state, stateEmitter) { it.copy(networkError = false) }
|
||||
is AddUsernameScreenEvents.UnknownErrorDialogDismissed -> applyDialogDismissed(state, stateEmitter) { it.copy(unknownError = false) }
|
||||
is AddUsernameScreenEvents.UsernameUnavailableDialogDismissed -> applyDialogDismissed(state, stateEmitter) { it.copy(usernameUnavailable = false) }
|
||||
is AddUsernameScreenEvents.RateLimitedDialogDismissed -> applyDialogDismissed(state, stateEmitter) { it.copy(rateLimited = false) }
|
||||
is AddUsernameScreenEvents.ReservationLapsedDialogDismissed -> applyDialogDismissed(state, stateEmitter) { it.copy(reservationLapsed = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyUsernameChanged(state: AddUsernameState, username: String, stateEmitter: (AddUsernameState) -> Unit) {
|
||||
if (username == state.username) {
|
||||
return
|
||||
}
|
||||
|
||||
reserveJob?.cancel()
|
||||
|
||||
stateEmitter(
|
||||
state.copy(
|
||||
username = username,
|
||||
validationError = null,
|
||||
reservation = null,
|
||||
isReserving = false
|
||||
)
|
||||
)
|
||||
|
||||
if (username.isNotBlank()) {
|
||||
nicknameChanges.tryEmit(username)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyNicknameSettled(state: AddUsernameState, nickname: String, stateEmitter: (AddUsernameState) -> Unit) {
|
||||
if (nickname != state.username || nickname.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
val validationError = checkNickname(nickname)
|
||||
if (validationError != null) {
|
||||
stateEmitter(state.copy(validationError = validationError))
|
||||
return
|
||||
}
|
||||
|
||||
stateEmitter(state.copy(isReserving = true))
|
||||
|
||||
reserveJob?.cancel()
|
||||
reserveJob = viewModelScope.launch {
|
||||
val result = repository.reserveUsername(nickname)
|
||||
onEvent(AddUsernameScreenEvents.ReservationCompleted(nickname, result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyReservationCompleted(
|
||||
state: AddUsernameState,
|
||||
event: AddUsernameScreenEvents.ReservationCompleted,
|
||||
stateEmitter: (AddUsernameState) -> Unit
|
||||
) {
|
||||
if (event.nickname != state.username) {
|
||||
return
|
||||
}
|
||||
|
||||
when (val result = event.result) {
|
||||
is RequestResult.Success -> {
|
||||
Log.i(TAG, "Successfully reserved a username.")
|
||||
stateEmitter(state.copy(isReserving = false, reservation = result.result))
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.LearnMoreClicked -> {
|
||||
_actions.trySend(AddUsernameScreenActions.OpenLearnMoreArticle)
|
||||
is RequestResult.NonSuccess -> when (result.error) {
|
||||
is ReserveUsernameError.NicknameInvalid, is ReserveUsernameError.NotAvailable -> {
|
||||
Log.w(TAG, "Could not reserve a username: ${result.error}")
|
||||
stateEmitter(state.copy(isReserving = false, validationError = AddUsernameState.ValidationError.NOT_AVAILABLE))
|
||||
}
|
||||
|
||||
is ReserveUsernameError.RateLimited -> {
|
||||
Log.w(TAG, "Rate limited while reserving a username.")
|
||||
stateEmitter(state.copy(isReserving = false, dialogs = state.dialogs.copy(rateLimited = true)))
|
||||
}
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.SkipClicked -> {
|
||||
// TODO [phonenumberless] Advance the flow without reserving a username.
|
||||
Log.i(TAG, "Skip clicked, but the flow isn't implemented yet.")
|
||||
is RequestResult.RetryableNetworkError -> {
|
||||
Log.w(TAG, "Network error while reserving a username.", result.networkError)
|
||||
stateEmitter(state.copy(isReserving = false, dialogs = state.dialogs.copy(networkError = true)))
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.NextClicked -> {
|
||||
// TODO [phonenumberless] Reserve and confirm the username, then advance the flow.
|
||||
Log.i(TAG, "Next clicked, but the flow isn't implemented yet.")
|
||||
is RequestResult.ApplicationError -> {
|
||||
Log.w(TAG, "Application error while reserving a username.", result.cause)
|
||||
stateEmitter(state.copy(isReserving = false, dialogs = state.dialogs.copy(unknownError = true)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySkipClicked(parentEventEmitter: (RegistrationFlowEvent) -> Unit) {
|
||||
Log.i(TAG, "Skipping username creation.")
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.Profile, popCurrent = true)
|
||||
}
|
||||
|
||||
private suspend fun applyNextClicked(
|
||||
state: AddUsernameState,
|
||||
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
|
||||
stateEmitter: (AddUsernameState) -> Unit
|
||||
) {
|
||||
val reservation = state.reservation
|
||||
if (!state.isSubmittable || reservation == null) {
|
||||
return
|
||||
}
|
||||
|
||||
stateEmitter(state.copy(showSpinner = true))
|
||||
|
||||
when (val result = repository.confirmUsername(reservation)) {
|
||||
is RequestResult.Success -> {
|
||||
Log.i(TAG, "Username confirmed.")
|
||||
parentEventEmitter.navigateTo(RegistrationRoute.Profile, popCurrent = true)
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.NetworkErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(networkError = false)))
|
||||
is RequestResult.NonSuccess -> when (result.error) {
|
||||
is ConfirmUsernameError.ReservationInvalid -> {
|
||||
Log.w(TAG, "The reservation has lapsed or was never made.")
|
||||
stateEmitter(state.copy(showSpinner = false, reservation = null, dialogs = state.dialogs.copy(reservationLapsed = true)))
|
||||
}
|
||||
|
||||
is ConfirmUsernameError.NotAvailable -> {
|
||||
Log.w(TAG, "The reserved username is no longer available.")
|
||||
stateEmitter(state.copy(showSpinner = false, reservation = null, dialogs = state.dialogs.copy(usernameUnavailable = true)))
|
||||
}
|
||||
|
||||
is ConfirmUsernameError.BadRequest, is ConfirmUsernameError.GenerationFailed -> {
|
||||
Log.w(TAG, "Failed to confirm the username: ${result.error}")
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(unknownError = true)))
|
||||
}
|
||||
|
||||
is ConfirmUsernameError.RateLimited -> {
|
||||
Log.w(TAG, "Rate limited while confirming the username.")
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(rateLimited = true)))
|
||||
}
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.UnknownErrorDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(unknownError = false)))
|
||||
is RequestResult.RetryableNetworkError -> {
|
||||
Log.w(TAG, "Network error while confirming the username.", result.networkError)
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(networkError = true)))
|
||||
}
|
||||
|
||||
is AddUsernameScreenEvents.UsernameUnavailableDialogDismissed -> {
|
||||
stateEmitter(state.copy(dialogs = state.dialogs.copy(usernameUnavailable = false)))
|
||||
is RequestResult.ApplicationError -> {
|
||||
Log.w(TAG, "Application error while confirming the username.", result.cause)
|
||||
stateEmitter(state.copy(showSpinner = false, dialogs = state.dialogs.copy(unknownError = true)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the dismissed dialog, then restarts the reserve flow if an earlier failure left a valid nickname without a
|
||||
* reservation. Nothing retries while a dialog is up -- recovery is always in response to the user's dismissal.
|
||||
*/
|
||||
private fun applyDialogDismissed(
|
||||
state: AddUsernameState,
|
||||
stateEmitter: (AddUsernameState) -> Unit,
|
||||
clearDialog: (AddUsernameState.Dialogs) -> AddUsernameState.Dialogs
|
||||
) {
|
||||
stateEmitter(state.copy(dialogs = clearDialog(state.dialogs)))
|
||||
|
||||
if (state.username.isNotBlank() && state.validationError == null && state.reservation == null && !state.isReserving) {
|
||||
onEvent(AddUsernameScreenEvents.NicknameSettled(state.username))
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNickname(nickname: String): AddUsernameState.ValidationError? {
|
||||
return when (UsernameUtil.checkNickname(nickname)) {
|
||||
null -> null
|
||||
UsernameUtil.InvalidReason.TOO_SHORT -> AddUsernameState.ValidationError.TOO_SHORT
|
||||
UsernameUtil.InvalidReason.TOO_LONG -> AddUsernameState.ValidationError.TOO_LONG
|
||||
UsernameUtil.InvalidReason.STARTS_WITH_NUMBER -> AddUsernameState.ValidationError.CANNOT_START_WITH_DIGIT
|
||||
else -> AddUsernameState.ValidationError.INVALID_CHARACTERS
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: RegistrationRepository,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
|
||||
@@ -679,6 +679,8 @@
|
||||
<string name="AddUsernameScreen__usernames_cannot_begin_with_a_number">Usernames cannot begin with a number</string>
|
||||
<!-- Error shown when the chosen username cannot be reserved. -->
|
||||
<string name="AddUsernameScreen__this_username_is_not_available">This username is not available. Please try another.</string>
|
||||
<!-- Error shown when the user's temporary hold on a username expired before they submitted it. -->
|
||||
<string name="AddUsernameScreen__your_username_reservation_expired">Your username reservation expired. Please try again.</string>
|
||||
|
||||
<!-- ContactSupportDialog -->
|
||||
<!-- Title of the dialog asking the user whether they want to attach a debug log to their support request -->
|
||||
|
||||
+27
@@ -12,6 +12,7 @@ import org.signal.core.models.MasterKey
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest
|
||||
import org.signal.network.api.RegistrationApiV2.AccountAttributes
|
||||
@@ -37,6 +38,9 @@ import org.signal.network.api.RegistrationApiV2.SubmitVerificationCodeError
|
||||
import org.signal.network.api.RegistrationApiV2.SvrCredentials
|
||||
import org.signal.network.api.RegistrationApiV2.UpdateSessionError
|
||||
import org.signal.network.api.RegistrationApiV2.VerificationCodeTransport
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ConfirmedUsername
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.signal.registration.LinkAndSyncWaitResult
|
||||
import org.signal.registration.NetworkController
|
||||
import org.signal.registration.NetworkController.BackupMasterKeyError
|
||||
@@ -53,6 +57,7 @@ import org.signal.registration.NetworkController.RestoreMasterKeyError
|
||||
import org.signal.registration.NetworkController.SetAccountAttributesError
|
||||
import org.signal.registration.NetworkController.SetProfileError
|
||||
import org.signal.registration.NetworkController.SetRegistrationLockError
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
@@ -107,6 +112,10 @@ class FakeNetworkController(
|
||||
private set
|
||||
var accountAttributesSyncJobEnqueued = false
|
||||
private set
|
||||
var lastReservedNickname: String? = null
|
||||
private set
|
||||
var lastConfirmedUsername: Username? = null
|
||||
private set
|
||||
|
||||
/** How many times the flow re-committed the backup-id. */
|
||||
var reserveBackupIdCount = 0
|
||||
@@ -202,6 +211,14 @@ class FakeNetworkController(
|
||||
RequestResult.Success(Unit)
|
||||
}
|
||||
|
||||
var onReserveUsername: suspend (nickname: String) -> RequestResult<Username, ReserveUsernameError> = { nickname ->
|
||||
RequestResult.Success(Username("$nickname.42"))
|
||||
}
|
||||
|
||||
var onConfirmUsername: suspend (Username) -> RequestResult<ConfirmedUsername, ConfirmUsernameError> = { username ->
|
||||
RequestResult.Success(ConfirmedUsername(username, UsernameLinkComponents(ByteArray(32), UUID.randomUUID())))
|
||||
}
|
||||
|
||||
// -- Response factories with happy-path defaults, for handlers that only want to tweak a field or two.
|
||||
|
||||
fun session(
|
||||
@@ -422,6 +439,16 @@ class FakeNetworkController(
|
||||
return RequestResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun reserveUsername(nickname: String): RequestResult<Username, ReserveUsernameError> {
|
||||
lastReservedNickname = nickname
|
||||
return onReserveUsername(nickname)
|
||||
}
|
||||
|
||||
override suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
|
||||
lastConfirmedUsername = username
|
||||
return onConfirmUsername(username)
|
||||
}
|
||||
|
||||
private fun notExpected(): Nothing {
|
||||
throw NotImplementedError("This method is not expected to be called in the flow under test.")
|
||||
}
|
||||
|
||||
+11
@@ -18,6 +18,7 @@ import org.signal.registration.proto.RegistrationData
|
||||
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
|
||||
import org.signal.registration.screens.messagesync.LinkAndSyncProgress
|
||||
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
@@ -49,6 +50,11 @@ class FakeStorageController : StorageController {
|
||||
/** Profile data already on disk, used to pre-seed or skip the create-profile screen. */
|
||||
var storedProfileData: StoredProfileData = StoredProfileData()
|
||||
|
||||
var savedUsername: String? = null
|
||||
private set
|
||||
var savedUsernameLink: UsernameLinkComponents? = null
|
||||
private set
|
||||
|
||||
// -- Response handlers. Override these in tests to change what is found on disk and how restores play out.
|
||||
|
||||
var onScanLocalBackupFolder: suspend (folderUri: Uri) -> List<LocalBackupInfo> = { folderUri ->
|
||||
@@ -141,6 +147,11 @@ class FakeStorageController : StorageController {
|
||||
|
||||
override suspend fun getStoredProfileData(): StoredProfileData = storedProfileData
|
||||
|
||||
override suspend fun saveUsername(username: String, usernameLink: UsernameLinkComponents) {
|
||||
savedUsername = username
|
||||
savedUsernameLink = usernameLink
|
||||
}
|
||||
|
||||
private fun notExpected(): Nothing {
|
||||
throw NotImplementedError("This method is not expected to be called in the flow under test.")
|
||||
}
|
||||
|
||||
+194
-1
@@ -6,21 +6,35 @@
|
||||
package org.signal.registration.screens.addusername
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.network.service.UsernameService.ConfirmUsernameError
|
||||
import org.signal.network.service.UsernameService.ReserveUsernameError
|
||||
import org.signal.registration.RegistrationFlowEvent
|
||||
import org.signal.registration.RegistrationRepository
|
||||
import org.signal.registration.RegistrationRoute
|
||||
import java.io.IOException
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AddUsernameViewModelTest {
|
||||
@@ -28,6 +42,7 @@ class AddUsernameViewModelTest {
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private lateinit var mockRepository: RegistrationRepository
|
||||
private lateinit var parentEvents: MutableList<RegistrationFlowEvent>
|
||||
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
private lateinit var viewModel: AddUsernameViewModel
|
||||
|
||||
@@ -35,7 +50,8 @@ class AddUsernameViewModelTest {
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockRepository = mockk(relaxed = true)
|
||||
parentEventEmitter = {}
|
||||
parentEvents = mutableListOf()
|
||||
parentEventEmitter = { parentEvents.add(it) }
|
||||
viewModel = AddUsernameViewModel(
|
||||
repository = mockRepository,
|
||||
parentEventEmitter = parentEventEmitter
|
||||
@@ -61,4 +77,181 @@ class AddUsernameViewModelTest {
|
||||
|
||||
assertThat(actions).containsExactly(AddUsernameScreenActions.OpenLearnMoreArticle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `typing a valid nickname reserves a username after the debounce`() = runTest(testDispatcher) {
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.reservation).isEqualTo(Username("maya.45"))
|
||||
assertThat(viewModel.state.value.isReserving).isFalse()
|
||||
assertThat(viewModel.state.value.isSubmittable).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing the nickname clears any existing reservation`() = runTest(testDispatcher) {
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("mayab"))
|
||||
|
||||
assertThat(viewModel.state.value.reservation).isNull()
|
||||
assertThat(viewModel.state.value.isSubmittable).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a too-short nickname produces a validation error`() = runTest(testDispatcher) {
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("ma"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.TOO_SHORT)
|
||||
assertThat(viewModel.state.value.isSubmittable).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nickname starting with a digit produces a validation error`() = runTest(testDispatcher) {
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("1maya"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.CANNOT_START_WITH_DIGIT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nickname with invalid characters produces a validation error`() = runTest(testDispatcher) {
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("ma!a"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.INVALID_CHARACTERS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unavailable nickname produces a validation error`() = runTest(testDispatcher) {
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.NonSuccess(ReserveUsernameError.NotAvailable)
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.validationError).isEqualTo(AddUsernameState.ValidationError.NOT_AVAILABLE)
|
||||
assertThat(viewModel.state.value.isSubmittable).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network error while reserving shows the network error dialog`() = runTest(testDispatcher) {
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.RetryableNetworkError(IOException())
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.dialogs.networkError).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NextClicked confirms the reservation and advances to the profile screen`() = runTest(testDispatcher) {
|
||||
val reservation = Username("maya.45")
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(reservation)
|
||||
coEvery { mockRepository.confirmUsername(reservation) } returns RequestResult.Success(Unit)
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
viewModel.onEvent(AddUsernameScreenEvents.NextClicked)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.Profile, popCurrent = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NextClicked does nothing without a reservation`() = runTest(testDispatcher) {
|
||||
viewModel.onEvent(AddUsernameScreenEvents.NextClicked)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).isEmpty()
|
||||
assertThat(viewModel.state.value.showSpinner).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a taken username at confirmation shows the unavailable dialog and clears the reservation`() = runTest(testDispatcher) {
|
||||
val reservation = Username("maya.45")
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(reservation)
|
||||
coEvery { mockRepository.confirmUsername(reservation) } returns RequestResult.NonSuccess(ConfirmUsernameError.NotAvailable)
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
viewModel.onEvent(AddUsernameScreenEvents.NextClicked)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.dialogs.usernameUnavailable).isTrue()
|
||||
assertThat(viewModel.state.value.showSpinner).isFalse()
|
||||
assertThat(viewModel.state.value.reservation).isNull()
|
||||
assertThat(parentEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lapsed reservation at confirmation shows the lapsed dialog and clears the reservation`() = runTest(testDispatcher) {
|
||||
val reservation = Username("maya.45")
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(reservation)
|
||||
coEvery { mockRepository.confirmUsername(reservation) } returns RequestResult.NonSuccess(ConfirmUsernameError.ReservationInvalid)
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
viewModel.onEvent(AddUsernameScreenEvents.NextClicked)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.dialogs.reservationLapsed).isTrue()
|
||||
assertThat(viewModel.state.value.reservation).isNull()
|
||||
assertThat(parentEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing an error dialog retries the reservation`() = runTest(testDispatcher) {
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.RetryableNetworkError(IOException()) andThen RequestResult.Success(Username("maya.45"))
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
assertThat(viewModel.state.value.dialogs.networkError).isTrue()
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.NetworkErrorDialogDismissed)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.dialogs.networkError).isFalse()
|
||||
assertThat(viewModel.state.value.reservation).isEqualTo(Username("maya.45"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unchanged username keeps the reservation`() = runTest(testDispatcher) {
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(Username("maya.45"))
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.reservation).isEqualTo(Username("maya.45"))
|
||||
assertThat(viewModel.state.value.isSubmittable).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network error at confirmation shows the network error dialog and keeps the reservation`() = runTest(testDispatcher) {
|
||||
val reservation = Username("maya.45")
|
||||
coEvery { mockRepository.reserveUsername("maya") } returns RequestResult.Success(reservation)
|
||||
coEvery { mockRepository.confirmUsername(reservation) } returns RequestResult.RetryableNetworkError(IOException())
|
||||
|
||||
viewModel.onEvent(AddUsernameScreenEvents.UsernameChanged("maya"))
|
||||
advanceUntilIdle()
|
||||
viewModel.onEvent(AddUsernameScreenEvents.NextClicked)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.dialogs.networkError).isTrue()
|
||||
assertThat(viewModel.state.value.reservation).isEqualTo(reservation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SkipClicked advances to the profile screen`() = runTest(testDispatcher) {
|
||||
viewModel.onEvent(AddUsernameScreenEvents.SkipClicked)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.Profile, popCurrent = true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.network.service
|
||||
|
||||
import org.signal.core.util.UsernameUtil
|
||||
import org.signal.core.util.censor
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.BadRequestError
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.RetryLaterException
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.network.api.AccountApiV2
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.util.Usernames
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.toKotlinDuration
|
||||
|
||||
/**
|
||||
* Higher-level operations for reserving and confirming a username on the service, wrapping [AccountApiV2] and
|
||||
* handling the candidate generation, hashing, and link creation that surround the raw endpoints. Callers are
|
||||
* expected to persist any confirmed username themselves.
|
||||
*/
|
||||
class UsernameService(private val accountApi: AccountApiV2) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(UsernameService::class)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves a username composed of [nickname] plus a numeric discriminator. If [discriminator] is provided, only that
|
||||
* exact username is attempted; otherwise the service picks from a set of generated candidates. The service holds
|
||||
* the reservation for a short time (~5 minutes), during which it can be finalized via [confirmUsername]. Reserving
|
||||
* again replaces any previous reservation.
|
||||
*
|
||||
* `PUT /v1/accounts/username_hash/reserve`
|
||||
*/
|
||||
suspend fun reserveUsername(nickname: String, discriminator: String? = null): RequestResult<Username, ReserveUsernameError> {
|
||||
val candidates: List<Username> = try {
|
||||
if (discriminator == null) {
|
||||
Username.candidatesFrom(nickname, UsernameUtil.MIN_NICKNAME_LENGTH, UsernameUtil.MAX_NICKNAME_LENGTH)
|
||||
} else {
|
||||
listOf(Username("$nickname${Usernames.DELIMITER}$discriminator"))
|
||||
}
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "[reserveUsername] Failed to generate candidates.", e)
|
||||
return RequestResult.NonSuccess(ReserveUsernameError.NicknameInvalid)
|
||||
}
|
||||
|
||||
val hashes: List<ByteArray> = candidates.map { it.hash }
|
||||
|
||||
return when (val result = accountApi.reserveUsername(hashes)) {
|
||||
is RequestResult.Success -> {
|
||||
val reserved = candidates.firstOrNull { it.hash.contentEquals(result.result) }
|
||||
if (reserved == null) {
|
||||
Log.w(TAG, "[reserveUsername] The reserved hash was not one of our candidates.")
|
||||
RequestResult.NonSuccess(ReserveUsernameError.NicknameInvalid)
|
||||
} else {
|
||||
Log.i(TAG, "[reserveUsername] Successfully reserved a username.")
|
||||
RequestResult.Success(reserved)
|
||||
}
|
||||
}
|
||||
is RequestResult.NonSuccess -> {
|
||||
Log.w(TAG, "[reserveUsername] None of the candidates were available.")
|
||||
RequestResult.NonSuccess(ReserveUsernameError.NotAvailable)
|
||||
}
|
||||
is RequestResult.RetryableNetworkError -> {
|
||||
val networkError = result.networkError
|
||||
if (networkError is RetryLaterException) {
|
||||
Log.w(TAG, "[reserveUsername] Rate limited.")
|
||||
RequestResult.NonSuccess(ReserveUsernameError.RateLimited(networkError.duration.toKotlinDuration()))
|
||||
} else {
|
||||
RequestResult.RetryableNetworkError(networkError)
|
||||
}
|
||||
}
|
||||
is RequestResult.ApplicationError -> RequestResult.ApplicationError(result.cause)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a reservation previously made via [reserveUsername], assigning the username to the account and creating
|
||||
* a new username link for it. Nothing is persisted locally.
|
||||
*
|
||||
* `PUT /v1/accounts/username_hash/confirm`
|
||||
*/
|
||||
suspend fun confirmUsername(username: Username): RequestResult<ConfirmedUsername, ConfirmUsernameError> {
|
||||
val link: Username.UsernameLink = try {
|
||||
username.generateLink()
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "[confirmUsername] Failed to generate the username link.", e)
|
||||
return RequestResult.NonSuccess(ConfirmUsernameError.GenerationFailed)
|
||||
}
|
||||
|
||||
return when (val result = accountApi.confirmUsername(username, link)) {
|
||||
is RequestResult.Success -> {
|
||||
Log.i(TAG, "[confirmUsername] Successfully confirmed the username.")
|
||||
RequestResult.Success(ConfirmedUsername(username, UsernameLinkComponents(link.entropy, result.result)))
|
||||
}
|
||||
is RequestResult.NonSuccess -> when (val error = result.error) {
|
||||
is AccountApiV2.ConfirmUsernameError.UsernameNotReserved -> {
|
||||
Log.w(TAG, "[confirmUsername] The username was not reserved.")
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.ReservationInvalid)
|
||||
}
|
||||
is AccountApiV2.ConfirmUsernameError.UsernameUnavailable -> {
|
||||
Log.w(TAG, "[confirmUsername] The username is no longer available.")
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.NotAvailable)
|
||||
}
|
||||
is AccountApiV2.ConfirmUsernameError.InvalidRequest -> {
|
||||
Log.w(TAG, "[confirmUsername] The service could not parse the request.")
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.BadRequest)
|
||||
}
|
||||
is AccountApiV2.ConfirmUsernameError.RateLimited -> {
|
||||
Log.w(TAG, "[confirmUsername] Rate limited.")
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.RateLimited(error.retryAfter))
|
||||
}
|
||||
}
|
||||
is RequestResult.RetryableNetworkError -> RequestResult.RetryableNetworkError(result.networkError)
|
||||
is RequestResult.ApplicationError -> {
|
||||
val cause = result.cause
|
||||
if (cause is BaseUsernameException) {
|
||||
Log.w(TAG, "[confirmUsername] Failed to generate the username proof.", cause)
|
||||
RequestResult.NonSuccess(ConfirmUsernameError.GenerationFailed)
|
||||
} else {
|
||||
RequestResult.ApplicationError(cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A username that has been assigned to the account, along with the components of its shareable link. */
|
||||
data class ConfirmedUsername(val username: Username, val link: UsernameLinkComponents) {
|
||||
override fun toString(): String = "ConfirmedUsername(username=${username.username.censor()}, link=xxx)"
|
||||
}
|
||||
|
||||
sealed interface ReserveUsernameError : BadRequestError {
|
||||
/** The nickname could not produce any valid username candidates. */
|
||||
data object NicknameInvalid : ReserveUsernameError
|
||||
|
||||
/** None of the candidate usernames generated for the nickname were available. */
|
||||
data object NotAvailable : ReserveUsernameError
|
||||
|
||||
data class RateLimited(val retryAfter: Duration?) : ReserveUsernameError
|
||||
}
|
||||
|
||||
sealed interface ConfirmUsernameError : BadRequestError {
|
||||
/** The service has no record of the reservation (HTTP 409) -- it lapsed or was never made. */
|
||||
data object ReservationInvalid : ConfirmUsernameError
|
||||
|
||||
/** The username was claimed by someone else after it was reserved (HTTP 410). */
|
||||
data object NotAvailable : ConfirmUsernameError
|
||||
|
||||
/** The service could not parse the request (HTTP 422). Deterministic -- retrying will not help. */
|
||||
data object BadRequest : ConfirmUsernameError
|
||||
|
||||
/** The client failed to locally generate the username, its link, or its proof. Deterministic -- retrying will not help. */
|
||||
data object GenerationFailed : ConfirmUsernameError
|
||||
|
||||
data class RateLimited(val retryAfter: Duration?) : ConfirmUsernameError
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user