diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt index 3dae32f80e..9bbadceb6c 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index a42bd8f571..24d40e4f0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index b0df92bc9a..7a693c8969 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -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 /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index f3cfb77e7c..e9f65cd002 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -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, libSignalNetworkSupplier: Supplier): SignalWebSocket.AuthenticatedWebSocket fun provideUnauthWebSocket(signalServiceConfigurationSupplier: Supplier, libSignalNetworkSupplier: Supplier): 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 9be9ac46e4..867b0e9241 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index a073590691..b1b751a9b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt index 8f1a18cdad..fc0efb53b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java index 4c1cd6b6d1..a1f1e05461 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt index fe872e0ac2..47019691af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt index 47843c48aa..d8b8b18448 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt @@ -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> { - 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 { - 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 { - val candidates: List = 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 = 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 { + 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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index ae7ef3b138..0d9c50bd4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt index 00b1c12f42..cd900de5d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index 50791c7ada..9019eb6b44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -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 { + return AppDependencies.usernameService.reserveUsername(nickname) + } + + override suspend fun confirmUsername(username: Username): RequestResult { + return AppDependencies.usernameService.confirmUsername(username) + } + override suspend fun restoreAccountRecord( timeout: Duration ): RequestResult = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt index 8c2d544a20..0238f81279 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -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 diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 1cd0680a52..10ffa80397 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt b/core/util-jvm/src/main/java/org/signal/core/util/UsernameUtil.kt similarity index 99% rename from app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt rename to core/util-jvm/src/main/java/org/signal/core/util/UsernameUtil.kt index b636a6c200..feb34556da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.kt +++ b/core/util-jvm/src/main/java/org/signal/core/util/UsernameUtil.kt @@ -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 diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.kt b/core/util-jvm/src/test/java/org/signal/core/util/UsernameUtilTest.kt similarity index 95% rename from app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.kt rename to core/util-jvm/src/test/java/org/signal/core/util/UsernameUtilTest.kt index 1c77f79dda..9981a705ea 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/UsernameUtilTest.kt +++ b/core/util-jvm/src/test/java/org/signal/core/util/UsernameUtilTest.kt @@ -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 diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index 014625d8cc..a0d0556b69 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -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 { + NetworkDebugState.getOverride>("reserveUsername")?.let { + Log.d(TAG, "[reserveUsername] Returning debug override") + return it + } + return delegate.reserveUsername(nickname) + } + + override suspend fun confirmUsername(username: Username): RequestResult { + NetworkDebugState.getOverride>("confirmUsername")?.let { + Log.d(TAG, "[confirmUsername] Returning debug override") + return it + } + return delegate.confirmUsername(username) + } + override suspend fun restoreAccountRecord(timeout: kotlin.time.Duration): RequestResult { NetworkDebugState.getOverride>("restoreAccountRecord")?.let { Log.d(TAG, "[restoreAccountRecord] Returning debug override") diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index a8d713b286..ac77088d69 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -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 = 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 = 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 = 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(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 = 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(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 = 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 + ) + + @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 @@ -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 } } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt index a45c1effe3..7342d194e7 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt @@ -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() diff --git a/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt b/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt index 42922f2b24..c78d37c737 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt @@ -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) } diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index ec0b4098d0..9d56255190 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -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 + /** + * 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 + + /** + * 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 + /** * 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 diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index c79789698d..f213c97037 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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 = 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 = 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, diff --git a/feature/registration/src/main/java/org/signal/registration/StorageController.kt b/feature/registration/src/main/java/org/signal/registration/StorageController.kt index 2adc80aa8d..6ed61d8c66 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -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 + /** + * 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 diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreen.kt index 0d9c5c6727..81c329ba90 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreen.kt @@ -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? = 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 = {} ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenEvents.kt index 9a294c3b86..72e9d1c4e7 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenEvents.kt @@ -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) : 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() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameState.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameState.kt index 82b35f8946..c5a003f1ff 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameState.kt @@ -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 ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt index 3dfee72cd3..239bc6c7fb 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt @@ -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(Channel.BUFFERED) val actions: Flow = _actions.receiveAsFlow() + private val nicknameChanges = MutableSharedFlow(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 diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index e0dae13582..ee695587f0 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -679,6 +679,8 @@ Usernames cannot begin with a number This username is not available. Please try another. + + Your username reservation expired. Please try again. diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt index 2176b51fe9..6980076167 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeNetworkController.kt @@ -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 = { nickname -> + RequestResult.Success(Username("$nickname.42")) + } + + var onConfirmUsername: suspend (Username) -> RequestResult = { 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 { + lastReservedNickname = nickname + return onReserveUsername(nickname) + } + + override suspend fun confirmUsername(username: Username): RequestResult { + lastConfirmedUsername = username + return onConfirmUsername(username) + } + private fun notExpected(): Nothing { throw NotImplementedError("This method is not expected to be called in the flow under test.") } diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt index bdf126a3cc..c0269d3941 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeStorageController.kt @@ -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 = { 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.") } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt index 221b4ec21b..e809924089 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt @@ -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 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)) + } } diff --git a/lib/network/src/main/java/org/signal/network/service/UsernameService.kt b/lib/network/src/main/java/org/signal/network/service/UsernameService.kt new file mode 100644 index 0000000000..1bcabbfd89 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/service/UsernameService.kt @@ -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 { + val candidates: List = 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 = 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 { + 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 + } +}