Allow setting username in registration module.

This commit is contained in:
Greyson Parrelli
2026-08-25 10:57:44 -04:00
parent 30e0a8040e
commit 7a9fe0b0c1
33 changed files with 1074 additions and 145 deletions
@@ -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;
@@ -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
@@ -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
@@ -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;
@@ -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
@@ -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
@@ -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
@@ -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) {
@@ -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
@@ -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,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
@@ -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
@@ -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")
@@ -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
}
}
@@ -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()
@@ -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
@@ -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 = {}
)
}
@@ -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()
}
@@ -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
)
}
@@ -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 -->
@@ -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.")
}
@@ -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.")
}
@@ -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
}
}