From 91965d205a2a6e2cd55408d8c78ec40b91c9acd6 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Mon, 29 Jun 2026 12:52:37 -0400 Subject: [PATCH] Add device linking support to RegV5. --- .../securesms/backup/v2/BackupRepository.kt | 11 +- .../jobs/LinkedDeviceInactiveCheckJob.kt | 4 +- .../securesms/jobs/RefreshAttributesJob.java | 4 +- .../securesms/jobs/RefreshOwnProfileJob.kt | 2 +- .../linkdevice/LinkDeviceRepository.kt | 4 +- .../data/RegistrationRepository.kt | 70 +++-- .../v2/AppRegistrationNetworkController.kt | 286 +++++++++++++++++- .../v2/AppRegistrationStorageController.kt | 93 +++++- .../core/util/crypto}/DeviceNameCipher.kt | 24 +- .../src/main/protowire/DeviceName.proto | 2 +- .../core/util/crypto}/DeviceNameCipherTest.kt | 3 +- .../java/org/signal/core/util/AppUtil.java | 26 -- .../main/java/org/signal/core/util/AppUtil.kt | 73 +++++ .../registration/sample/MainActivity.kt | 2 +- .../sample/RegistrationApplication.kt | 2 +- .../sample/debug/DebugNetworkController.kt | 32 +- .../dependencies/DemoNetworkController.kt | 257 +++++++++++++++- .../dependencies/DemoStorageController.kt | 115 +++++++ .../sample/screens/main/MainScreen.kt | 39 +++ .../sample/screens/main/MainScreenState.kt | 14 +- .../screens/main/MainScreenViewModel.kt | 10 + .../pinsettings/PinSettingsViewModel.kt | 1 - .../sample/storage/RegistrationPreferences.kt | 22 +- .../signal/registration/NetworkController.kt | 148 ++++++++- .../registration/RegistrationRepository.kt | 208 ++++++++++++- .../signal/registration/StorageController.kt | 16 + .../screens/linkaccount/LinkAccountScreen.kt | 44 ++- .../linkaccount/LinkAccountScreenEvent.kt | 2 + .../linkaccount/LinkAccountScreenState.kt | 5 +- .../linkaccount/LinkAccountViewModel.kt | 132 +++++++- .../messagesync/LinkAndSyncProgress.kt | 35 +++ .../screens/messagesync/MessageSyncScreen.kt | 59 ++-- .../messagesync/MessageSyncScreenState.kt | 3 +- .../messagesync/MessageSyncViewModel.kt | 88 +++++- .../src/main/protowire/Registration.proto | 13 + .../src/main/res/values/strings.xml | 14 + .../linkaccount/LinkAccountViewModelTest.kt | 229 ++++++++++++++ .../messagesync/MessageSyncViewModelTest.kt | 133 ++++++++ .../api/account/DeviceAttributes.kt | 21 ++ .../api/link/TransferArchiveResponse.kt | 22 +- .../api/link/WaitForLinkedDeviceResponse.kt | 2 +- .../api/registration/RegistrationApi.kt | 3 +- .../internal/push/PushServiceSocket.java | 16 +- .../push/RegisterAsSecondaryDeviceRequest.kt | 4 +- 44 files changed, 2111 insertions(+), 182 deletions(-) rename {app/src/main/java/org/thoughtcrime/securesms/registration/secondary => core/util-jvm/src/main/java/org/signal/core/util/crypto}/DeviceNameCipher.kt (80%) rename {app => core/util-jvm}/src/main/protowire/DeviceName.proto (81%) rename {app/src/test/java/org/thoughtcrime/securesms/registration/secondary => core/util-jvm/src/test/java/org/signal/core/util/crypto}/DeviceNameCipherTest.kt (84%) delete mode 100644 core/util/src/main/java/org/signal/core/util/AppUtil.java create mode 100644 core/util/src/main/java/org/signal/core/util/AppUtil.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/messagesync/LinkAndSyncProgress.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt create mode 100644 lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/DeviceAttributes.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 34ff4763a1..e2dc149625 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -2322,7 +2322,7 @@ object BackupRepository { return RemoteRestoreResult.Success } - suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) { + suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey): RemoteRestoreResult { val context = AppDependencies.application ArchiveRestoreProgress.onRestorePending() @@ -2354,9 +2354,16 @@ object BackupRepository { override fun shouldCancel() = cancellationSignal() } + val cdn = response.cdn + val key = response.key + if (cdn == null || key == null) { + Log.w(TAG, "[restoreLinkAndSyncBackup] Response has no archive location (error=${response.error}); nothing to download.") + return RemoteRestoreResult.Failure + } + Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup") val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) - when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) { + when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempBackupFile, progressListener)) { is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful") else -> { Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt index 2313857177..8a046e1043 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LinkedDeviceInactiveCheckJob.kt @@ -6,15 +6,15 @@ package org.thoughtcrime.securesms.jobs import org.signal.core.util.Base64 +import org.signal.core.util.crypto.DeviceName +import org.signal.core.util.crypto.DeviceNameCipher import org.signal.core.util.logging.Log import org.signal.core.util.roundedString import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.devicelist.protos.DeviceName import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.protos.LeastActiveLinkedDevice -import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher import org.whispersystems.signalservice.api.push.SignalServiceAddress import java.io.IOException import kotlin.time.Duration.Companion.days diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index cbe982b447..41c8442e37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -6,7 +6,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.core.util.Base64; +import org.signal.core.util.crypto.DeviceNameCipher; import org.signal.core.util.logging.Log; +import org.signal.network.exceptions.NonSuccessfulResponseCodeException; import org.thoughtcrime.securesms.AppCapabilities; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.jobmanager.Job; @@ -16,13 +18,11 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberD import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SvrValues; import org.thoughtcrime.securesms.net.SignalNetwork; -import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher; import org.thoughtcrime.securesms.registration.data.RegistrationRepository; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.whispersystems.signalservice.api.NetworkResultUtil; import org.whispersystems.signalservice.api.account.AccountAttributes; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; -import org.signal.network.exceptions.NonSuccessfulResponseCodeException; import java.io.IOException; import java.nio.charset.StandardCharsets; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt index fde9a131d6..870254c0a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.kt @@ -86,7 +86,7 @@ class RefreshOwnProfileJob private constructor(parameters: Parameters) : BaseJob return } - if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) { + if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool || SignalStore.account.restoredAccountEntropyPoolFromPrimary) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) { Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet.") return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt index c694a9b246..0c8c931e64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -4,6 +4,8 @@ import android.net.Uri import org.signal.core.models.backup.MessageBackupKey import org.signal.core.util.Base64 import org.signal.core.util.Stopwatch +import org.signal.core.util.crypto.DeviceName +import org.signal.core.util.crypto.DeviceNameCipher import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log import org.signal.core.util.logging.logD @@ -20,14 +22,12 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveValidator import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.crypto.ProfileKeyUtil import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.devicelist.protos.DeviceName import org.thoughtcrime.securesms.jobs.DeviceNameChangeJob import org.thoughtcrime.securesms.jobs.E164FormattingJob import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.createAndUploadArchive import org.thoughtcrime.securesms.net.SignalNetwork -import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse import org.whispersystems.signalservice.api.link.TransferArchiveError import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt index 6f2a5d24cd..59c6624399 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt @@ -26,6 +26,7 @@ import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.PNI import org.signal.core.models.backup.MediaRootBackupKey import org.signal.core.util.Base64 +import org.signal.core.util.crypto.DeviceNameCipher import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.util.KeyHelper @@ -70,13 +71,13 @@ import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionC import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest -import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet import org.thoughtcrime.securesms.service.DirectoryRefreshListener import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.SvrNoDataException import org.whispersystems.signalservice.api.account.AccountAttributes +import org.whispersystems.signalservice.api.account.DeviceAttributes import org.whispersystems.signalservice.api.account.PreKeyCollection import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess import org.whispersystems.signalservice.api.kbs.PinHashUtil @@ -97,6 +98,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.coroutines.coroutineContext import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds /** @@ -488,23 +490,46 @@ object RegistrationRepository { val aci = message.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(message.aci) val pni = message.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(message.pni) - val universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context) - val unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey) + return registerAsLinkedDevice( + context = context, + deviceName = deviceName, + number = message.number!!, + provisioningCode = message.provisioningCode!!, + aci = aci, + pni = pni, + accountEntropyPool = AccountEntropyPool(message.accountEntropyPool!!), + registrationData = registrationData, + aciIdentityKeyPair = aciIdentityKeyPair, + pniIdentityKeyPair = pniIdentityKeyPair + ) + } + /** + * Registers this device as a linked (secondary) device using discrete fields rather than a raw + * [ProvisionMessage]. This is the form used by the regv5 registration module, which works with a + * decoupled provisioning message. + */ + @WorkerThread + fun registerAsLinkedDevice( + context: Context, + deviceName: String, + number: String, + provisioningCode: String, + aci: ACI, + pni: PNI, + accountEntropyPool: AccountEntropyPool, + registrationData: RegistrationData, + aciIdentityKeyPair: IdentityKeyPair, + pniIdentityKeyPair: IdentityKeyPair + ): NetworkResult { val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), aciIdentityKeyPair) - val accountAttributes = AccountAttributes( - signalingKey = null, - registrationId = getRegistrationId(), + val deviceAttributes = DeviceAttributes( fetchesMessages = registrationData.fcmToken == null, - registrationLock = null, - unidentifiedAccessKey = unidentifiedAccessKey, - unrestrictedUnidentifiedAccess = universalUnidentifiedAccess, - capabilities = AppCapabilities.getCapabilities(false), - discoverableByPhoneNumber = false, - name = Base64.encodeWithPadding(encryptedDeviceName), + registrationId = getRegistrationId(), pniRegistrationId = getPniRegistrationId(), - recoveryPassword = null + name = Base64.encodeWithPadding(encryptedDeviceName), + capabilities = AppCapabilities.getCapabilities(false) ) val aciPreKeys = generateSignedAndLastResortPreKeys(aciIdentityKeyPair, SignalStore.account.aciPreKeys) @@ -512,20 +537,18 @@ object RegistrationRepository { return AccountManagerFactory .getInstance() - .createUnauthenticated(context, message.number!!, -1, registrationData.password) + .createUnauthenticated(context, number, -1, registrationData.password) .registrationApi - .registerAsSecondaryDevice(message.provisioningCode!!, accountAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken) - .map { respone -> - val aep = AccountEntropyPool(message.accountEntropyPool!!) - + .registerAsSecondaryDevice(provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken) + .map { response -> RegisterAsLinkedDeviceResponse( - deviceId = respone.deviceId.toInt(), + deviceId = response.deviceId.toInt(), accountRegistrationResult = AccountRegistrationResult( uuid = aci.toString(), pni = pni.toString(), storageCapable = false, - number = message.number!!, - masterKey = aep.deriveMasterKey(), + number = number, + masterKey = accountEntropyPool.deriveMasterKey(), pin = null, aciPreKeyCollection = aciPreKeys, pniPreKeyCollection = pniPreKeys, @@ -674,7 +697,7 @@ object RegistrationRepository { return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id) } - suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 60.seconds): TransferArchiveResponse? { + suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 1.hours): TransferArchiveResponse? { val startTime = System.currentTimeMillis() var timeRemaining = maxWaitTime.inWholeMilliseconds @@ -683,7 +706,8 @@ object RegistrationRepository { when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) { is NetworkResult.Success -> { - Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Transfer archive data provided by primary") + // The primary has responded: either with an archive location, or an error telling us not to expect one. + Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Primary responded (hasArchive=${result.result.hasArchive}, error=${result.result.error})") return result.result } is NetworkResult.ApplicationError -> { 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 9f266913f6..d995164082 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 @@ -6,8 +6,10 @@ package org.thoughtcrime.securesms.registration.v2 import android.content.Context +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.isActive @@ -16,25 +18,33 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.ServiceId.PNI import org.signal.core.util.logging.Log 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.network.NetworkResult +import org.signal.registration.LinkAndSyncWaitResult import org.signal.registration.NetworkController import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.BackupMasterKeyError import org.signal.registration.NetworkController.CheckSvrCredentialsError import org.signal.registration.NetworkController.CheckSvrCredentialsResponse import org.signal.registration.NetworkController.CreateSessionError +import org.signal.registration.NetworkController.DeviceAttributes import org.signal.registration.NetworkController.GetSessionStatusError import org.signal.registration.NetworkController.GetSvrCredentialsError +import org.signal.registration.NetworkController.LinkDeviceProvisioningEvent +import org.signal.registration.NetworkController.LinkDeviceProvisioningMessage +import org.signal.registration.NetworkController.LinkDeviceResponse import org.signal.registration.NetworkController.PreKeyCollection import org.signal.registration.NetworkController.ProvisioningEvent import org.signal.registration.NetworkController.ProvisioningMessage import org.signal.registration.NetworkController.RegisterAccountError import org.signal.registration.NetworkController.RegisterAccountResponse +import org.signal.registration.NetworkController.RegisterAsLinkedDeviceError import org.signal.registration.NetworkController.RegistrationLockResponse import org.signal.registration.NetworkController.RequestVerificationCodeError import org.signal.registration.NetworkController.RestoreAccountRecordError @@ -68,21 +78,36 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest +import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore import org.thoughtcrime.securesms.registration.util.RegistrationUtil import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet +import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.SvrNoDataException import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess +import org.whispersystems.signalservice.api.link.TransferArchiveResponse +import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket +import org.whispersystems.signalservice.api.push.SignedPreKeyEntity import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher import org.whispersystems.signalservice.internal.push.AuthCredentials +import org.whispersystems.signalservice.internal.push.GcmRegistrationId +import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity +import org.whispersystems.signalservice.internal.push.ProvisionMessage import org.whispersystems.signalservice.internal.push.PushServiceSocket +import org.whispersystems.signalservice.internal.push.RegisterAsSecondaryDeviceRequest +import org.whispersystems.signalservice.internal.push.SyncMessage +import java.io.Closeable import java.io.IOException import java.util.Locale +import kotlin.coroutines.coroutineContext import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes +import org.whispersystems.signalservice.api.account.DeviceAttributes as ServiceDeviceAttributes import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection import org.whispersystems.signalservice.api.provisioning.RestoreMethod as ServiceRestoreMethod @@ -97,6 +122,7 @@ class AppRegistrationNetworkController( companion object { private val TAG = Log.tag(AppRegistrationNetworkController::class) private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds + private val RETRY_BACKOFF = 5.seconds } private val json = Json { ignoreUnknownKeys = true } @@ -740,7 +766,7 @@ class AppRegistrationNetworkController( } override fun startProvisioning(): Flow = callbackFlow { - val socketHandles = mutableListOf() + val socketHandles = mutableListOf() val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration() fun startSocket() { @@ -804,7 +830,7 @@ class AppRegistrationNetworkController( val rotationJob = launch { var count = 0 while (count < 5 && isActive) { - kotlinx.coroutines.delay(ProvisioningSocket.LIFESPAN / 2) + delay(ProvisioningSocket.LIFESPAN / 2) if (isActive) { startSocket() count++ @@ -822,6 +848,250 @@ class AppRegistrationNetworkController( } } + override fun startLinkDeviceProvisioning(): Flow = callbackFlow { + val socketHandles = mutableListOf() + val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration() + + fun startSocket() { + val handle = ProvisioningSocket.start( + mode = ProvisioningSocket.Mode.LINK, + identityKeyPair = IdentityKeyPair.generate(), + configuration = configuration, + handler = { id, t -> + Log.w(TAG, "[startLinkDeviceProvisioning] Socket [$id] failed", t) + trySend(LinkDeviceProvisioningEvent.Error(t)) + } + ) { socket -> + val url = socket.getProvisioningUrl() + trySend(LinkDeviceProvisioningEvent.QrCodeReady(url)) + + val result = socket.getProvisioningMessageDecryptResult() + + if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) { + val msg = result.message + val aci = msg.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(msg.aci) + val pni = msg.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(msg.pni) + + trySend( + LinkDeviceProvisioningEvent.MessageReceived( + LinkDeviceProvisioningMessage( + e164 = msg.number!!, + provisioningCode = msg.provisioningCode!!, + aci = aci.toString(), + pni = pni.toString(), + aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate!!.toByteArray())), + pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate!!.toByteArray())), + profileKey = msg.profileKey!!.toByteArray(), + ephemeralBackupKey = msg.ephemeralBackupKey, + accountEntropyPool = msg.accountEntropyPool, + mediaRootBackupKey = msg.mediaRootBackupKey, + readReceipts = msg.readReceipts + ) + ) + ) + channel.close() + } else { + Log.w(TAG, "[startLinkDeviceProvisioning] Failed to decrypt provisioning message") + trySend(LinkDeviceProvisioningEvent.Error(IOException("Failed to decrypt provisioning message"))) + } + } + + synchronized(socketHandles) { + socketHandles += handle + if (socketHandles.size > 2) { + socketHandles.removeAt(0).close() + } + } + } + + startSocket() + + val rotationJob = launch { + var count = 0 + while (count < 5 && isActive) { + delay(ProvisioningSocket.LIFESPAN / 2) + if (isActive) { + startSocket() + count++ + Log.d(TAG, "[startLinkDeviceProvisioning] Rotated socket, count: $count") + } + } + } + + awaitClose { + rotationJob.cancel() + synchronized(socketHandles) { + socketHandles.forEach { it.close() } + socketHandles.clear() + } + } + } + + override suspend fun registerAsLinkedDevice( + e164: String, + password: String, + provisioningCode: String, + deviceAttributes: DeviceAttributes, + aciPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection, + fcmToken: String? + ): RequestResult = withContext(Dispatchers.IO) { + try { + pushServiceSocket.registerAsSecondaryDevice( + e164, + password, + RegisterAsSecondaryDeviceRequest( + verificationCode = provisioningCode, + accountAttributes = deviceAttributes.toServiceDeviceAttributes(), + aciSignedPreKey = SignedPreKeyEntity(aciPreKeys.signedPreKey.id.toLong(), aciPreKeys.signedPreKey.keyPair.publicKey, aciPreKeys.signedPreKey.signature), + pniSignedPreKey = SignedPreKeyEntity(pniPreKeys.signedPreKey.id.toLong(), pniPreKeys.signedPreKey.keyPair.publicKey, pniPreKeys.signedPreKey.signature), + aciPqLastResortPreKey = KyberPreKeyEntity(aciPreKeys.lastResortKyberPreKey.id.toLong(), aciPreKeys.lastResortKyberPreKey.keyPair.publicKey, aciPreKeys.lastResortKyberPreKey.signature), + pniPqLastResortPreKey = KyberPreKeyEntity(pniPreKeys.lastResortKyberPreKey.id.toLong(), pniPreKeys.lastResortKyberPreKey.keyPair.publicKey, pniPreKeys.lastResortKyberPreKey.signature), + gcmToken = fcmToken?.let { GcmRegistrationId(it, true) } + ) + ).use { response -> + when (response.code) { + 200 -> RequestResult.Success(json.decodeFromString(response.body.string())) + 403 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.IncorrectVerification) + 409 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.MissingCapability) + 411 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.MaxLinkedDevices) + 422 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.InvalidRequest(response.body.string())) + 429 -> RequestResult.NonSuccess(RegisterAsLinkedDeviceError.RateLimited(response.retryAfter())) + else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}")) + } + } + } catch (e: IOException) { + RequestResult.RetryableNetworkError(e) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + RequestResult.ApplicationError(e) + } + } + + override suspend fun onLinkedDeviceRegistered() = withContext(Dispatchers.IO) { + try { + RemoteConfig.refreshSync() + } catch (e: IOException) { + Log.w(TAG, "[onLinkedDeviceRegistered] Failed to refresh remote config.", e) + } + + for (type in SyncMessage.Request.Type.entries) { + if (type == SyncMessage.Request.Type.UNKNOWN) { + continue + } + + Log.i(TAG, "[onLinkedDeviceRegistered] Sending sync request for $type") + try { + retryWithBackoff { + AppDependencies.signalServiceMessageSender.sendSyncMessage( + SignalServiceSyncMessage.forRequest(RequestMessage(SyncMessage.Request(type = type))) + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "[onLinkedDeviceRegistered] Failed to send sync request for $type after retries; continuing.", e) + } + } + } + + private suspend fun retryWithBackoff(maxAttempts: Int = 3, initialDelay: Duration = 1.seconds, block: suspend () -> T): T { + var attempt = 0 + while (true) { + try { + return block() + } catch (e: IOException) { + attempt++ + if (attempt >= maxAttempts) { + throw e + } + val backoff = initialDelay * attempt + Log.w(TAG, "[retryWithBackoff] Attempt $attempt failed; retrying in $backoff.", e) + delay(backoff) + } + } + } + + override suspend fun restoreLinkedDeviceFromStorageService() = withContext(Dispatchers.IO) { + if (SignalStore.account.restoredAccountEntropyPoolFromPrimary) { + Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Restoring account data from storage service.") + try { + StorageServiceRestore.restore() + } catch (e: CancellationException) { + Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Restoring account cancelled.", e) + throw e + } catch (e: Exception) { + Log.w(TAG, "[restoreLinkedDeviceFromStorageService] Storage service restore failed.", e) + } + } else { + Log.i(TAG, "[restoreLinkedDeviceFromStorageService] No account entropy pool from primary; skipping storage service restore.") + } + } + + override suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult = withContext(Dispatchers.IO) { + val response = awaitTransferArchiveFromPrimary() + val result = when { + response == null -> LinkAndSyncWaitResult.ContinueWithoutBackup + response.error == TransferArchiveResponse.ERROR_RELINK_REQUESTED -> LinkAndSyncWaitResult.RelinkRequired + response.error == TransferArchiveResponse.ERROR_CONTINUE_WITHOUT_UPLOAD -> LinkAndSyncWaitResult.ContinueWithoutBackup + response.hasArchive -> LinkAndSyncWaitResult.ArchiveAvailable(cdn = response.cdn!!, key = response.key!!) + else -> LinkAndSyncWaitResult.ContinueWithoutBackup + } + Log.i(TAG, "[awaitLinkAndSyncArchive] Result: $result") + result + } + + /** + * Waits for the primary device to make a link-and-sync transfer archive available, long-polling + * [org.signal.network.api.LinkDeviceApi.waitForPrimaryDevice] and retrying transient errors until + * [maxWaitTime] elapses. Returns null if no archive becomes available. + */ + private suspend fun awaitTransferArchiveFromPrimary(maxWaitTime: Duration = 1.hours): TransferArchiveResponse? { + val startTime = System.currentTimeMillis() + var timeRemaining = maxWaitTime.inWholeMilliseconds + + while (timeRemaining > 0 && coroutineContext.isActive) { + Log.d(TAG, "[awaitTransferArchiveFromPrimary] Willing to wait for $timeRemaining ms...") + + when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) { + is NetworkResult.Success -> { + Log.i(TAG, "[awaitTransferArchiveFromPrimary] Primary responded (hasArchive=${result.result.hasArchive}, error=${result.result.error})") + return result.result + } + is NetworkResult.ApplicationError -> { + Log.w(TAG, "[awaitTransferArchiveFromPrimary] Error processing response", result.throwable) + return null + } + is NetworkResult.NetworkError -> { + Log.w(TAG, "[awaitTransferArchiveFromPrimary] Network error while waiting; will retry after $RETRY_BACKOFF.", result.exception) + delay(RETRY_BACKOFF) + } + is NetworkResult.StatusCodeError -> { + when (result.code) { + 400 -> { + Log.w(TAG, "[awaitTransferArchiveFromPrimary] Invalid timeout.") + return null + } + 429 -> { + Log.w(TAG, "[awaitTransferArchiveFromPrimary] Rate-limited; will retry after ${result.retryAfter()}.") + result.retryAfter()?.let { delay(it) } + } + else -> { + Log.w(TAG, "[awaitTransferArchiveFromPrimary] Unexpected status ${result.code}; will retry after $RETRY_BACKOFF.") + delay(RETRY_BACKOFF) + } + } + } + } + + timeRemaining = maxWaitTime.inWholeMilliseconds - (System.currentTimeMillis() - startTime) + } + + Log.w(TAG, "[awaitTransferArchiveFromPrimary] No transfer archive from primary within $maxWaitTime.") + return null + } + private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes { return ServiceAccountAttributes( signalingKey, @@ -832,7 +1102,7 @@ class AppRegistrationNetworkController( unrestrictedUnidentifiedAccess, capabilities?.toServiceCapabilities(), discoverableByPhoneNumber, - name, + null, pniRegistrationId, recoveryPassword ) @@ -848,6 +1118,16 @@ class AppRegistrationNetworkController( ) } + private fun DeviceAttributes.toServiceDeviceAttributes(): ServiceDeviceAttributes { + return ServiceDeviceAttributes( + fetchesMessages = fetchesMessages, + registrationId = registrationId, + pniRegistrationId = pniRegistrationId, + name = name, + capabilities = capabilities?.toServiceCapabilities() + ) + } + private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection { return ServicePreKeyCollection( identityKey = identityKey, 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 915e0e1a2d..e17dbad0ed 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 @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.registration.v2 import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -23,6 +24,9 @@ import org.greenrobot.eventbus.ThreadMode import org.signal.archive.LocalBackupRestoreProgress import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.core.models.backup.MessageBackupKey +import org.signal.core.util.AppUtil +import org.signal.core.util.Result import org.signal.core.util.StreamUtil import org.signal.core.util.crypto.AttachmentSecretProvider import org.signal.core.util.logging.Log @@ -32,6 +36,7 @@ import org.signal.registration.StorageController import org.signal.registration.StoredProfileData 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.thoughtcrime.securesms.backup.FullBackupImporter import org.thoughtcrime.securesms.backup.v2.BackupRepository @@ -42,10 +47,12 @@ import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem import org.thoughtcrime.securesms.crypto.AppAttachmentSecretStore import org.thoughtcrime.securesms.crypto.ProfileKeyUtil import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.LinkedDeviceInfo import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState import org.thoughtcrime.securesms.keyvalue.Completed import org.thoughtcrime.securesms.keyvalue.NewAccount +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.Skipped import org.thoughtcrime.securesms.keyvalue.isDecisionPending @@ -55,6 +62,7 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.registration.data.RegistrationRepository import org.thoughtcrime.securesms.registration.util.RegistrationUtil import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.link.TransferArchiveResponse import java.io.File import java.io.IOException import java.time.LocalDateTime @@ -105,6 +113,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo Unit } + override suspend fun clearLocalDataAndRestart() = withContext(Dispatchers.Main) { + Log.w(TAG, "[clearLocalDataAndRestart] Wiping all local app data and attempting to relaunch.") + AppUtil.clearAllDataAndRestart(context) + } + override suspend fun getStoredProfileData(): StoredProfileData = withContext(Dispatchers.IO) { if (!SignalStore.account.isRegistered) { return@withContext StoredProfileData() @@ -125,9 +138,9 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo } val discoverable: Boolean? = when (SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode) { - org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE -> true - org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE -> false - org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.UNDECIDED -> null + PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE -> true + PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE -> false + PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.UNDECIDED -> null } StoredProfileData( @@ -173,7 +186,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo // than lazily generating a new AEP. val accountEntropyPool: AccountEntropyPool? = data.accountEntropyPool.takeIf { it.isNotEmpty() }?.let { AccountEntropyPool(it) } if (accountEntropyPool != null) { - SignalStore.account.restoreAccountEntropyPool(accountEntropyPool) + if (data.linkedDeviceData != null) { + SignalStore.account.setAccountEntropyPoolFromPrimaryDevice(accountEntropyPool) + } else { + SignalStore.account.restoreAccountEntropyPool(accountEntropyPool) + } } val masterKey: MasterKey? = accountEntropyPool?.deriveMasterKey() @@ -215,15 +232,27 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo fcmEnabled = SignalStore.account.fcmEnabled fcmToken = SignalStore.account.fcmToken ?: "" reglockEnabled = data.registrationLockEnabled + + data.linkedDeviceData?.let { linkData -> + linkedDeviceInfo = LinkedDeviceInfo( + deviceId = linkData.deviceId, + deviceName = linkData.deviceName, + ephemeralBackupKey = linkData.ephemeralBackupKey, + accountEntropyPool = data.accountEntropyPool, + mediaRootBackupKey = linkData.mediaRootBackupKey + ) + } }.build() // TODO [greyson] Should probably move this stuff into this file as we get closer to being done RegistrationRepository.registerAccountLocally(context, metadata) SignalStore.registration.localRegistrationMetadata = metadata + + data.linkedDeviceData?.readReceipts?.let { TextSecurePreferences.setReadReceiptsEnabled(context, it) } } // Handle PIN/master key - if (data.pin.isNotEmpty() && masterKey != null) { + if (data.pin.isNotEmpty() && masterKey != null && data.linkedDeviceData == null) { SvrRepository.onRegistrationComplete( masterKey, data.pin, @@ -321,11 +350,11 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo val snapshotFileSystem = SnapshotFileSystem(context, backupDir) when (val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey)) { - is org.signal.core.util.Result.Success -> { + is Result.Success -> { emit(LocalBackupRestoreProgress.Complete) Log.d(TAG, "V2 restore complete.") } - is org.signal.core.util.Result.Failure -> { + is Result.Failure -> { Log.w(TAG, "V2 restore failed: ${result.failure}") emit(LocalBackupRestoreProgress.Error(IOException("V2 restore failed: ${result.failure}"))) } @@ -451,6 +480,56 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo } } + override fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow = callbackFlow { + val ephemeralBackupKeyBytes = SignalStore.registration.localRegistrationMetadata?.linkedDeviceInfo?.ephemeralBackupKey?.toByteArray() + + if (ephemeralBackupKeyBytes == null) { + Log.i(TAG, "[restoreLinkAndSyncBackup] No ephemeral backup key present; nothing to restore.") + trySend(LinkAndSyncProgress.Complete) + channel.close() + return@callbackFlow + } + + val subscriber = object { + @Subscribe(threadMode = ThreadMode.POSTING) + fun onRestoreEvent(event: RestoreV2Event) { + val progress = when (event.type) { + RestoreV2Event.Type.PROGRESS_DOWNLOAD -> LinkAndSyncProgress.Downloading(event.count, event.estimatedTotalCount) + RestoreV2Event.Type.PROGRESS_RESTORE -> LinkAndSyncProgress.Restoring + RestoreV2Event.Type.PROGRESS_FINALIZING -> LinkAndSyncProgress.Restoring + } + trySend(progress) + } + } + + EventBus.getDefault().register(subscriber) + + launch(Dispatchers.IO) { + try { + when (val result = BackupRepository.restoreLinkAndSyncBackup(TransferArchiveResponse(cdn = cdn, key = key), MessageBackupKey(ephemeralBackupKeyBytes))) { + RemoteRestoreResult.Success -> send(LinkAndSyncProgress.Complete) + RemoteRestoreResult.Canceled -> Log.i(TAG, "[restoreLinkAndSyncBackup] Restore canceled.") + else -> { + Log.w(TAG, "[restoreLinkAndSyncBackup] Link-and-sync restore did not succeed: $result") + send(LinkAndSyncProgress.Failed()) + } + } + } catch (e: CancellationException) { + Log.d(TAG, "[restoreLinkAndSyncBackup] Restore cancelled, aborting.") + throw e + } catch (e: Exception) { + Log.w(TAG, "[restoreLinkAndSyncBackup] Link-and-sync restore failed.", e) + send(LinkAndSyncProgress.Failed(e)) + } finally { + channel.close() + } + } + + awaitClose { + EventBus.getDefault().unregister(subscriber) + } + } + private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) { val file = File(context.cacheDir, TEMP_PROTO_FILENAME) file.writeBytes(RegistrationData.ADAPTER.encode(data)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt b/core/util-jvm/src/main/java/org/signal/core/util/crypto/DeviceNameCipher.kt similarity index 80% rename from app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt rename to core/util-jvm/src/main/java/org/signal/core/util/crypto/DeviceNameCipher.kt index b9f89fa453..962662cd56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipher.kt +++ b/core/util-jvm/src/main/java/org/signal/core/util/crypto/DeviceNameCipher.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.registration.secondary +package org.signal.core.util.crypto import okio.ByteString.Companion.toByteString import org.signal.core.util.logging.Log @@ -7,8 +7,6 @@ import org.signal.libsignal.protocol.InvalidKeyException import org.signal.libsignal.protocol.ecc.ECKeyPair import org.signal.libsignal.protocol.ecc.ECPrivateKey import org.signal.libsignal.protocol.ecc.ECPublicKey -import org.signal.libsignal.protocol.util.ByteUtil -import org.thoughtcrime.securesms.devicelist.protos.DeviceName import java.nio.charset.Charset import java.security.GeneralSecurityException import java.security.MessageDigest @@ -22,7 +20,7 @@ import javax.crypto.spec.SecretKeySpec */ object DeviceNameCipher { - private val TAG = Log.tag(DeviceNameCipher::class.java) + private val TAG = Log.tag(DeviceNameCipher::class) private const val SYNTHETIC_IV_LENGTH = 16 @@ -35,7 +33,7 @@ object DeviceNameCipher { val cipherKey: ByteArray = computeCipherKey(masterSecret, syntheticIv) val cipher = Cipher.getInstance("AES/CTR/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(createEmptyByteArray(16))) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16))) val cipherText = cipher.doFinal(plaintext) return DeviceName( @@ -61,23 +59,13 @@ object DeviceNameCipher { val ephemeralPublic = ECPublicKey(deviceName.ephemeralPublic.toByteArray()) val masterSecret = identityKey.calculateAgreement(ephemeralPublic) - val mac = Mac.getInstance("HmacSHA256") - mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) - val cipherKeyPart1 = mac.doFinal("cipher".toByteArray()) - - mac.init(SecretKeySpec(cipherKeyPart1, "HmacSHA256")) - val cipherKey = mac.doFinal(syntheticIv) + val cipherKey = computeCipherKey(masterSecret, syntheticIv) val cipher = Cipher.getInstance("AES/CTR/NoPadding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16))) val plaintext = cipher.doFinal(cipherText) - mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) - val verificationPart1 = mac.doFinal("auth".toByteArray()) - - mac.init(SecretKeySpec(verificationPart1, "HmacSHA256")) - val verificationPart2 = mac.doFinal(plaintext) - val ourSyntheticIv = ByteUtil.trim(verificationPart2, 16) + val ourSyntheticIv = computeSyntheticIv(masterSecret, plaintext) if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) { throw GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.") @@ -116,6 +104,4 @@ object DeviceNameCipher { ivMac.init(SecretKeySpec(syntheticIvKey, "HmacSHA256")) return ivMac.doFinal(plaintext).sliceArray(0 until SYNTHETIC_IV_LENGTH) } - - private fun createEmptyByteArray(length: Int): ByteArray = ByteArray(length) } diff --git a/app/src/main/protowire/DeviceName.proto b/core/util-jvm/src/main/protowire/DeviceName.proto similarity index 81% rename from app/src/main/protowire/DeviceName.proto rename to core/util-jvm/src/main/protowire/DeviceName.proto index c3139b20e8..7db04ed05e 100644 --- a/app/src/main/protowire/DeviceName.proto +++ b/core/util-jvm/src/main/protowire/DeviceName.proto @@ -8,7 +8,7 @@ syntax = "proto2"; package signalservice; -option java_package = "org.thoughtcrime.securesms.devicelist.protos"; +option java_package = "org.signal.core.util.crypto"; message DeviceName { optional bytes ephemeralPublic = 1; diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipherTest.kt b/core/util-jvm/src/test/java/org/signal/core/util/crypto/DeviceNameCipherTest.kt similarity index 84% rename from app/src/test/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipherTest.kt rename to core/util-jvm/src/test/java/org/signal/core/util/crypto/DeviceNameCipherTest.kt index 33df299bae..8dcd5a32f8 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/DeviceNameCipherTest.kt +++ b/core/util-jvm/src/test/java/org/signal/core/util/crypto/DeviceNameCipherTest.kt @@ -1,10 +1,9 @@ -package org.thoughtcrime.securesms.registration.secondary +package org.signal.core.util.crypto import assertk.assertThat import assertk.assertions.isEqualTo import org.junit.Test import org.signal.libsignal.protocol.IdentityKeyPair -import org.thoughtcrime.securesms.devicelist.protos.DeviceName import java.nio.charset.Charset class DeviceNameCipherTest { diff --git a/core/util/src/main/java/org/signal/core/util/AppUtil.java b/core/util/src/main/java/org/signal/core/util/AppUtil.java deleted file mode 100644 index c9c690db0e..0000000000 --- a/core/util/src/main/java/org/signal/core/util/AppUtil.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.signal.core.util; - -import android.app.ActivityManager; -import android.content.Context; -import android.content.Intent; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; - -public final class AppUtil { - - private AppUtil() {} - - /** - * Restarts the application. Should generally only be used for internal tools. - */ - public static void restart(@NonNull Context context) { - String packageName = context.getPackageName(); - Intent defaultIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); - - defaultIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - context.startActivity(defaultIntent); - Runtime.getRuntime().exit(0); - } -} diff --git a/core/util/src/main/java/org/signal/core/util/AppUtil.kt b/core/util/src/main/java/org/signal/core/util/AppUtil.kt new file mode 100644 index 0000000000..e9d2cc135a --- /dev/null +++ b/core/util/src/main/java/org/signal/core/util/AppUtil.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util + +import android.app.ActivityManager +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import org.signal.core.util.logging.Log + +object AppUtil { + + private val TAG = Log.tag(AppUtil::class) + + private const val RESTART_REQUEST_CODE = 1 + private const val RESTART_DELAY_MS = 250L + + /** + * Restarts the application. Should generally only be used for internal tools. + */ + @JvmStatic + fun restart(context: Context) { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + if (launchIntent != null) { + context.startActivity(launchIntent) + } + + Runtime.getRuntime().exit(0) + } + + /** + * DANGER! Wipes ALL local app data (databases, key-value store, shared prefs, files) and attempts to relaunch the app + * into a fresh state. + */ + @JvmStatic + fun clearAllDataAndRestart(context: Context) { + scheduleRestart(context) + + val activityManager = ContextCompat.getSystemService(context, ActivityManager::class.java) + if (activityManager == null || !activityManager.clearApplicationUserData()) { + Log.w(TAG, "Could not wipe app data; falling back to a plain restart.") + restart(context) + } + } + + private fun scheduleRestart(context: Context) { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + + if (launchIntent == null) { + Log.w(TAG, "No launch intent available; cannot schedule a relaunch.") + return + } + + val alarmManager = ContextCompat.getSystemService(context, AlarmManager::class.java) + if (alarmManager == null) { + Log.w(TAG, "No AlarmManager available; cannot schedule a relaunch.") + return + } + + val pendingIntent = PendingIntent.getActivity(context, RESTART_REQUEST_CODE, launchIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT) + alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + RESTART_DELAY_MS, pendingIntent) + } +} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt b/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt index 013b97342d..09e315166a 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/MainActivity.kt @@ -136,7 +136,7 @@ private fun SampleNavHost( context = context.applicationContext, networkController = registrationDependencies.networkController, storageController = registrationDependencies.storageController, - isLinkAndSyncAvailable = false + isLinkAndSyncAvailable = registrationDependencies.isLinkAndSyncAvailable ) } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt index 36624008b2..3e17a06365 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/RegistrationApplication.kt @@ -69,7 +69,7 @@ class RegistrationApplication : Application() { .setPositiveButton(android.R.string.ok, null) .show() }, - isLinkAndSyncAvailable = false + isLinkAndSyncAvailable = true ) ) 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 ae86bba340..2aa78e3844 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 @@ -10,12 +10,14 @@ import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey import org.signal.core.util.logging.Log import org.signal.libsignal.net.RequestResult +import org.signal.registration.LinkAndSyncWaitResult import org.signal.registration.NetworkController import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.BackupMasterKeyError import org.signal.registration.NetworkController.CheckSvrCredentialsError import org.signal.registration.NetworkController.CheckSvrCredentialsResponse import org.signal.registration.NetworkController.CreateSessionError +import org.signal.registration.NetworkController.DeviceAttributes import org.signal.registration.NetworkController.GetBackupInfoError import org.signal.registration.NetworkController.GetBackupInfoResponse import org.signal.registration.NetworkController.GetSessionStatusError @@ -246,7 +248,35 @@ class DebugNetworkController( return delegate.startProvisioning() } - override fun startNewDeviceTransferServer(context: android.content.Context, aep: org.signal.core.models.AccountEntropyPool) { + override fun startLinkDeviceProvisioning(): Flow { + return delegate.startLinkDeviceProvisioning() + } + + override suspend fun registerAsLinkedDevice( + e164: String, + password: String, + provisioningCode: String, + deviceAttributes: DeviceAttributes, + aciPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection, + fcmToken: String? + ): RequestResult { + return delegate.registerAsLinkedDevice(e164, password, provisioningCode, deviceAttributes, aciPreKeys, pniPreKeys, fcmToken) + } + + override suspend fun onLinkedDeviceRegistered() { + delegate.onLinkedDeviceRegistered() + } + + override suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult { + return delegate.awaitLinkAndSyncArchive() + } + + override suspend fun restoreLinkedDeviceFromStorageService() { + delegate.restoreLinkedDeviceFromStorageService() + } + + override fun startNewDeviceTransferServer(context: android.content.Context, aep: AccountEntropyPool) { if (NetworkDebugState.fakeDeviceTransfer.value) { Log.d(TAG, "[startNewDeviceTransferServer] Fake device transfer enabled (debug override)") org.signal.registration.sample.dependencies.FakeDeviceTransferRunner.start() 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 095efa7184..9b36f122bc 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 @@ -39,12 +39,16 @@ import org.signal.libsignal.protocol.ecc.ECPrivateKey import org.signal.libsignal.zkgroup.GenericServerPublicParams import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse +import org.signal.network.NetworkResult +import org.signal.network.api.LinkDeviceApi import org.signal.network.service.StorageServiceService +import org.signal.registration.LinkAndSyncWaitResult import org.signal.registration.NetworkController import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.CheckSvrCredentialsRequest import org.signal.registration.NetworkController.CheckSvrCredentialsResponse import org.signal.registration.NetworkController.CreateSessionError +import org.signal.registration.NetworkController.DeviceAttributes import org.signal.registration.NetworkController.GetSessionStatusError import org.signal.registration.NetworkController.PreKeyCollection import org.signal.registration.NetworkController.ProvisioningEvent @@ -63,7 +67,9 @@ import org.signal.registration.sample.MainActivity import org.signal.registration.sample.fcm.FcmUtil 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.registration.RegistrationApi import org.whispersystems.signalservice.api.storage.StorageServiceApi import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse @@ -74,17 +80,21 @@ import org.whispersystems.signalservice.api.websocket.WebSocketFactory import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher import org.whispersystems.signalservice.internal.push.AuthCredentials +import org.whispersystems.signalservice.internal.push.ProvisionMessage import org.whispersystems.signalservice.internal.push.PushServiceSocket import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection +import java.io.Closeable import java.io.IOException import java.time.Instant import java.util.Locale import kotlin.time.Duration import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes +import org.whispersystems.signalservice.api.account.DeviceAttributes as ServiceDeviceAttributes import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection class DemoNetworkController( @@ -98,6 +108,7 @@ class DemoNetworkController( private val TAG = Log.tag(DemoNetworkController::class) const val DEVICE_TRANSFER_NOTIFICATION_CHANNEL_ID = "device_transfer" private const val DEVICE_TRANSFER_NOTIFICATION_ID = 4321 + private const val USER_AGENT = "Signal-Android-Registration-Sample" } private val json = Json { ignoreUnknownKeys = true } @@ -424,8 +435,227 @@ class DemoNetworkController( ) } + override fun startLinkDeviceProvisioning(): Flow = callbackFlow { + val socketHandles = mutableListOf() + + fun startSocket() { + val handle = ProvisioningSocket.start( + mode = ProvisioningSocket.Mode.LINK, + identityKeyPair = IdentityKeyPair.generate(), + configuration = serviceConfiguration, + handler = { id, t -> + Log.w(TAG, "[startLinkDeviceProvisioning] Socket [$id] failed", t) + trySend(NetworkController.LinkDeviceProvisioningEvent.Error(t)) + } + ) { socket -> + val url = socket.getProvisioningUrl() + trySend(NetworkController.LinkDeviceProvisioningEvent.QrCodeReady(url)) + + val result = socket.getProvisioningMessageDecryptResult() + + if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) { + val msg = result.message + val aci = msg.aciBinary?.let { ServiceId.ACI.parseOrThrow(it) } ?: ServiceId.ACI.parseOrThrow(msg.aci) + val pni = msg.pniBinary?.let { ServiceId.PNI.parseOrThrow(it) } ?: ServiceId.PNI.parseOrThrow(msg.pni) + + trySend( + NetworkController.LinkDeviceProvisioningEvent.MessageReceived( + NetworkController.LinkDeviceProvisioningMessage( + e164 = msg.number!!, + provisioningCode = msg.provisioningCode!!, + aci = aci.toString(), + pni = pni.toString(), + aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate!!.toByteArray())), + pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic!!.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate!!.toByteArray())), + profileKey = msg.profileKey!!.toByteArray(), + ephemeralBackupKey = msg.ephemeralBackupKey, + accountEntropyPool = msg.accountEntropyPool, + mediaRootBackupKey = msg.mediaRootBackupKey, + readReceipts = msg.readReceipts + ) + ) + ) + channel.close() + } else { + Log.w(TAG, "[startLinkDeviceProvisioning] Failed to decrypt provisioning message") + trySend(NetworkController.LinkDeviceProvisioningEvent.Error(IOException("Failed to decrypt provisioning message"))) + } + } + + synchronized(socketHandles) { + socketHandles += handle + if (socketHandles.size > 2) { + socketHandles.removeAt(0).close() + } + } + } + + startSocket() + + val rotationJob = launch { + var count = 0 + while (count < 5 && isActive) { + delay(ProvisioningSocket.LIFESPAN / 2) + if (isActive) { + startSocket() + count++ + Log.d(TAG, "[startLinkDeviceProvisioning] Rotated socket, count: $count") + } + } + } + + awaitClose { + rotationJob.cancel() + synchronized(socketHandles) { + socketHandles.forEach { it.close() } + socketHandles.clear() + } + } + } + + override suspend fun registerAsLinkedDevice( + e164: String, + password: String, + provisioningCode: String, + deviceAttributes: DeviceAttributes, + aciPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection, + fcmToken: String? + ): RequestResult = withContext(Dispatchers.IO) { + try { + // The link endpoint authenticates via basic auth (e164:password) since this device has no ACI yet. + val credentialsProvider = StaticCredentialsProvider(null, null, e164, 1, password) + val linkSocket = PushServiceSocket(serviceConfiguration, credentialsProvider, USER_AGENT, true) + + val result = RegistrationApi(linkSocket).registerAsSecondaryDevice( + provisioningCode, + deviceAttributes.toServiceDeviceAttributes(), + aciPreKeys.toServicePreKeyCollection(), + pniPreKeys.toServicePreKeyCollection(), + fcmToken + ) + + when (result) { + is NetworkResult.Success -> { + Log.i(TAG, "[registerAsLinkedDevice] Linked successfully (deviceId=${result.result.deviceId}).") + RequestResult.Success(NetworkController.LinkDeviceResponse(deviceId = result.result.deviceId.toInt())) + } + is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable) + is NetworkResult.NetworkError<*> -> RequestResult.RetryableNetworkError(result.exception) + is NetworkResult.StatusCodeError -> { + Log.w(TAG, "[registerAsLinkedDevice] Status code error: ${result.code}") + val error = when (result.code) { + 403 -> NetworkController.RegisterAsLinkedDeviceError.IncorrectVerification + 409 -> NetworkController.RegisterAsLinkedDeviceError.MissingCapability + 411 -> NetworkController.RegisterAsLinkedDeviceError.MaxLinkedDevices + 422 -> NetworkController.RegisterAsLinkedDeviceError.InvalidRequest(result.exception.message) + 429 -> NetworkController.RegisterAsLinkedDeviceError.RateLimited(result.retryAfter()) + else -> return@withContext RequestResult.ApplicationError(result.exception) + } + RequestResult.NonSuccess(error) + } + } + } catch (e: Exception) { + Log.w(TAG, "[registerAsLinkedDevice] Failed to register as linked device.", e) + RequestResult.ApplicationError(e) + } + } + + override suspend fun onLinkedDeviceRegistered() { + // The demo stops before importing the backup proto / setting up app state, so there is no real + // post-registration housekeeping to do here. The account data is persisted via commitRegistrationData(). + Log.i(TAG, "[onLinkedDeviceRegistered] No-op in demo.") + } + + override suspend fun restoreLinkedDeviceFromStorageService() { + // The demo can do a real storage-service restore -- reuse the same account-record restore the + // normal flow uses. It no-ops gracefully if credentials/master key aren't available. + Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Restoring account record from storage service...") + when (val result = restoreAccountRecord(timeout = 30.seconds)) { + is RequestResult.Success -> Log.i(TAG, "[restoreLinkedDeviceFromStorageService] Storage service restore complete.") + else -> Log.w(TAG, "[restoreLinkedDeviceFromStorageService] Storage service restore did not complete: $result") + } + } + + override suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult = withContext(Dispatchers.IO) { + val response = awaitTransferArchive() + val result = when { + response == null -> LinkAndSyncWaitResult.ContinueWithoutBackup + response.error == TransferArchiveResponse.ERROR_RELINK_REQUESTED -> LinkAndSyncWaitResult.RelinkRequired + response.error == TransferArchiveResponse.ERROR_CONTINUE_WITHOUT_UPLOAD -> LinkAndSyncWaitResult.ContinueWithoutBackup + response.hasArchive -> LinkAndSyncWaitResult.ArchiveAvailable(cdn = response.cdn!!, key = response.key!!) + else -> LinkAndSyncWaitResult.ContinueWithoutBackup + } + Log.i(TAG, "[awaitLinkAndSyncArchive] Result: $result") + result + } + + /** + * Connects an authenticated websocket as the linked device and long-polls (retrying up to ~1 hour, mirroring + * Desktop) for the primary to make a transfer archive available. Returns the primary's response (which may carry + * a [TransferArchiveResponse.error] instead of an archive), or null if the primary never responded in time. + */ + private suspend fun awaitTransferArchive(): TransferArchiveResponse? { + val aci = RegistrationPreferences.aci + val pni = RegistrationPreferences.pni + val e164 = RegistrationPreferences.e164 + val password = RegistrationPreferences.servicePassword + val deviceId = RegistrationPreferences.linkedDeviceId + + if (aci == null || e164 == null || password == null || deviceId <= 0) { + Log.w(TAG, "[awaitTransferArchive] Missing linked-device credentials.") + return null + } + + val network = Network(Network.Environment.STAGING, USER_AGENT, emptyMap(), Network.BuildVariant.PRODUCTION) + val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, deviceId, password) + val healthMonitor = object : HealthMonitor { + override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {} + override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {} + override fun onReceivedAlerts(alerts: Array, isIdentifiedWebSocket: Boolean) {} + } + val libSignalConnection = LibSignalChatConnection( + name = "LinkAndSync", + network = network, + credentialsProvider = credentialsProvider, + receiveStories = false, + healthMonitor = healthMonitor + ) + val authWebSocket = SignalWebSocket.AuthenticatedWebSocket( + connectionFactory = { libSignalConnection }, + canConnect = { true }, + sleepTimer = { millis -> Thread.sleep(millis) }, + disconnectTimeoutMs = 60.seconds.inWholeMilliseconds + ) + + return try { + authWebSocket.connect() + val deadline = System.currentTimeMillis() + 1.hours.inWholeMilliseconds + var archive: TransferArchiveResponse? = null + while (archive == null && System.currentTimeMillis() < deadline) { + Log.i(TAG, "[awaitTransferArchive] Waiting for primary to provide a transfer archive...") + when (val result = LinkDeviceApi(authWebSocket).waitForPrimaryDevice(timeout = 30.seconds)) { + is NetworkResult.Success -> archive = result.result + else -> Log.d(TAG, "[awaitTransferArchive] No archive yet; continuing to wait.") + } + } + archive + } finally { + authWebSocket.disconnect() + } + } + + /** The device id for authenticated calls: the linked-device id if this is a secondary device, else 1 (primary). */ + private fun currentDeviceId(): Int = RegistrationPreferences.linkedDeviceId.takeIf { it > 0 } ?: 1 + + /** Basic-auth username for authenticated REST calls. A secondary device must authenticate as ".". */ + private fun authUsername(aci: ServiceId.ACI): String { + val deviceId = currentDeviceId() + return if (deviceId != 1) "$aci.$deviceId" else aci.toString() + } + override fun startProvisioning(): Flow = callbackFlow { - val socketHandles = mutableListOf() + val socketHandles = mutableListOf() fun startSocket() { val handle = ProvisioningSocket.start( @@ -577,7 +807,7 @@ class DemoNetworkController( } val network = Network(Network.Environment.STAGING, "Signal-Android-Registration-Sample", emptyMap(), Network.BuildVariant.PRODUCTION) - val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, 1, password) + val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, currentDeviceId(), password) val healthMonitor = object : HealthMonitor { override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {} override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {} @@ -679,7 +909,7 @@ class DemoNetworkController( val registrationLockToken = masterKey.deriveRegistrationLock() try { - val credentials = okhttp3.Credentials.basic(aci.toString(), password) + val credentials = okhttp3.Credentials.basic(authUsername(aci), password) val baseUrl = serviceConfiguration.signalServiceUrls[0].url val requestBody = """{"registrationLock":"$registrationLockToken"}""" .toRequestBody("application/json".toMediaType()) @@ -726,7 +956,7 @@ class DemoNetworkController( } try { - val credentials = okhttp3.Credentials.basic(aci.toString(), password) + val credentials = okhttp3.Credentials.basic(authUsername(aci), password) val baseUrl = serviceConfiguration.signalServiceUrls[0].url val request = okhttp3.Request.Builder() @@ -770,7 +1000,7 @@ class DemoNetworkController( } try { - val credentials = okhttp3.Credentials.basic(aci.toString(), password) + val credentials = okhttp3.Credentials.basic(authUsername(aci), password) val baseUrl = serviceConfiguration.signalServiceUrls[0].url val requestBody = json.encodeToString(AccountAttributes.serializer(), attributes) .toRequestBody("application/json".toMediaType()) @@ -817,7 +1047,7 @@ class DemoNetworkController( } try { - val credentials = okhttp3.Credentials.basic(aci.toString(), password) + val credentials = okhttp3.Credentials.basic(authUsername(aci), password) val baseUrl = serviceConfiguration.signalServiceUrls[0].url val request = okhttp3.Request.Builder() @@ -935,7 +1165,7 @@ class DemoNetworkController( val storageKey = masterKey.deriveStorageServiceKey() val network = Network(Network.Environment.STAGING, "Signal-Android-Registration-Sample", emptyMap(), Network.BuildVariant.PRODUCTION) - val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, 1, password) + val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, currentDeviceId(), password) val healthMonitor = object : HealthMonitor { override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {} override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {} @@ -1075,7 +1305,6 @@ class DemoNetworkController( spqr = true, usernameChangeSyncMessage = true ), - name = null, pniRegistrationId = RegistrationPreferences.pniRegistrationId, recoveryPassword = recoveryPassword ) @@ -1307,7 +1536,7 @@ class DemoNetworkController( unrestrictedUnidentifiedAccess, capabilities?.toServiceCapabilities(), discoverableByPhoneNumber, - name, + null, pniRegistrationId, recoveryPassword ) @@ -1323,6 +1552,16 @@ class DemoNetworkController( ) } + private fun DeviceAttributes.toServiceDeviceAttributes(): ServiceDeviceAttributes { + return ServiceDeviceAttributes( + fetchesMessages = fetchesMessages, + registrationId = registrationId, + pniRegistrationId = pniRegistrationId, + name = name, + capabilities = capabilities?.toServiceCapabilities() + ) + } + private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection { return ServicePreKeyCollection( identityKey = identityKey, 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 d349fef2b1..ebcc64ea28 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 @@ -8,22 +8,31 @@ package org.signal.registration.sample.dependencies import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.signal.archive.LocalBackupRestoreProgress +import org.signal.archive.stream.EncryptedBackupReader import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.PNI +import org.signal.core.models.backup.MessageBackupKey +import org.signal.core.util.AppUtil import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.signal.network.NetworkResult import org.signal.registration.NetworkController import org.signal.registration.NewRegistrationData import org.signal.registration.PreExistingRegistrationData @@ -32,10 +41,17 @@ import org.signal.registration.StorageController import org.signal.registration.StoredProfileData import org.signal.registration.proto.ProvisioningData import org.signal.registration.proto.RegistrationData +import org.signal.registration.sample.RegistrationApplication import org.signal.registration.sample.storage.RegistrationDatabase import org.signal.registration.sample.storage.RegistrationPreferences 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.SignalServiceMessageReceiver +import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment +import org.whispersystems.signalservice.internal.push.PushServiceSocket +import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider import java.io.File import java.time.LocalDateTime @@ -49,6 +65,7 @@ class DemoStorageController(private val context: Context) : StorageController { private val TAG = Log.tag(DemoStorageController::class) private const val TEMP_PROTO_FILENAME = "registration_data.pb" private const val SIMULATED_STAGE_DELAY_MS = 500L + private const val USER_AGENT = "Signal-Android-Registration-Sample" private val MODERN_BACKUP_PATTERN = Regex("^signal-backup-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})$") private val LEGACY_BACKUP_PATTERN = Regex("^signal-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})\\.backup$") } @@ -75,6 +92,14 @@ class DemoStorageController(private val context: Context) : StorageController { db.clearAllPreKeys() } + override suspend fun clearLocalDataAndRestart() { + Log.w(TAG, "[clearLocalDataAndRestart] Relink requested; clearing demo data and restarting.") + clearAllData() + withContext(Dispatchers.Main) { + AppUtil.restart(context) + } + } + override suspend fun readInProgressRegistrationData(): RegistrationData = withContext(Dispatchers.IO) { val file = File(context.filesDir, TEMP_PROTO_FILENAME) if (file.exists()) { @@ -150,6 +175,13 @@ class DemoStorageController(private val context: Context) : StorageController { ) } + // Linked-device data (persisted so the link-and-sync step can authenticate as this device and the + // home screen can show the linked account). + data.linkedDeviceData?.let { linkData -> + RegistrationPreferences.linkedDeviceId = linkData.deviceId + RegistrationPreferences.ephemeralBackupKey = linkData.ephemeralBackupKey?.toByteArray() + } + // PIN data if (data.pin.isNotEmpty()) { RegistrationPreferences.pin = data.pin @@ -324,6 +356,89 @@ class DemoStorageController(private val context: Context) : StorageController { Log.d(TAG, "Simulated remote restore complete.") }.flowOn(Dispatchers.IO) + /** + * Performs a real link-and-sync restore against the (staging) service. The demo + * stops short of actually persisting any sync data but does read through the frames. + */ + override fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow = callbackFlow { + val aci = RegistrationPreferences.aci + val pni = RegistrationPreferences.pni + val e164 = RegistrationPreferences.e164 + val password = RegistrationPreferences.servicePassword + val deviceId = RegistrationPreferences.linkedDeviceId + val ephemeralBackupKeyBytes = RegistrationPreferences.ephemeralBackupKey + + if (aci == null || e164 == null || password == null || deviceId <= 0 || ephemeralBackupKeyBytes == null) { + Log.i(TAG, "[restoreLinkAndSyncBackup] No link-and-sync backup expected; nothing to restore.") + trySend(LinkAndSyncProgress.Complete) + close() + return@callbackFlow + } + + val job = launch(Dispatchers.IO) { + val tempFile = File.createTempFile("link-and-sync", ".backup", context.cacheDir) + try { + val configuration = RegistrationApplication.serviceConfiguration + val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, deviceId, password) + + // Download the encrypted backup file from the CDN (cdn/key obtained from awaitLinkAndSyncArchive on the + // network side), reporting progress. + Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup from CDN $cdn...") + val messageReceiver = SignalServiceMessageReceiver(PushServiceSocket(configuration, credentialsProvider, USER_AGENT, true)) + val progressListener = object : SignalServiceAttachment.ProgressListener { + override fun onAttachmentProgress(progress: AttachmentTransferProgress) { + trySend(LinkAndSyncProgress.Downloading(progress.transmitted, progress.total)) + } + + override fun shouldCancel(): Boolean = !isActive + } + + val download = messageReceiver.retrieveLinkAndSyncBackup(cdn, key, tempFile, progressListener) + if (download !is NetworkResult.Success) { + Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file.") + trySend(LinkAndSyncProgress.Failed()) + return@launch + } + + // Decrypt + parse the backup proto to prove the round-trip worked. We intentionally do NOT + // import the frames into a database -- that is the app's full backup-restore pipeline. + trySend(LinkAndSyncProgress.Restoring) + val downloadedBytes = tempFile.length() + var frameCount = 0 + EncryptedBackupReader.createForLocalOrLinking( + key = MessageBackupKey(ephemeralBackupKeyBytes), + aci = aci, + length = downloadedBytes, + dataStream = { tempFile.inputStream() } + ).use { reader -> + val hasHeader = reader.getHeader() != null + while (reader.hasNext()) { + reader.next() + frameCount++ + } + Log.i(TAG, "[restoreLinkAndSyncBackup] Decrypted backup proto (header=$hasHeader, frames=$frameCount). Not importing to a database (out of scope for the demo).") + } + + // Persist a summary so the demo's home screen can display the link-and-sync result. + RegistrationPreferences.linkAndSyncFrameCount = frameCount + RegistrationPreferences.linkAndSyncDownloadedBytes = downloadedBytes + + trySend(LinkAndSyncProgress.Complete) + } catch (e: CancellationException) { + Log.d(TAG, "[restoreLinkAndSyncBackup] Restore cancelled, aborting.") + throw e + } catch (e: Exception) { + Log.w(TAG, "[restoreLinkAndSyncBackup] Link-and-sync restore failed.", e) + trySend(LinkAndSyncProgress.Failed(e)) + } finally { + tempFile.delete() + close() + } + } + + awaitClose { job.cancel() } + } + private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) { val file = File(context.filesDir, TEMP_PROTO_FILENAME) file.writeBytes(RegistrationData.ADAPTER.encode(data)) diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt index 2df456de5c..358ec83d0b 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt @@ -151,6 +151,11 @@ fun MainScreen( RegistrationInfo(state.existingRegistrationState) + if (state.linkedDeviceState != null) { + Spacer(modifier = Modifier.height(16.dp)) + LinkedDeviceInfo(state.linkedDeviceState) + } + if (state.profileState != null) { Spacer(modifier = Modifier.height(16.dp)) ProfileInfo(state.profileState) @@ -236,6 +241,40 @@ private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) { } } +@Composable +private fun LinkedDeviceInfo(data: MainScreenState.LinkedDeviceState) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Linked Device", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + RegistrationField(label = "Device Type", value = "Linked (secondary)") + RegistrationField(label = "Device ID", value = data.deviceId.toString()) + RegistrationField(label = "Link & Sync Offered", value = if (data.linkAndSyncOffered) "Yes" else "No") + RegistrationField( + label = "Link & Sync Backup", + value = when { + !data.linkAndSyncOffered -> "Not offered by primary" + data.linkAndSyncFrameCount >= 0 -> "Downloaded ${data.linkAndSyncDownloadedBytes} bytes, ${data.linkAndSyncFrameCount} frames (not imported)" + else -> "Offered, not completed" + } + ) + } + } +} + @Composable private fun ProfileInfo(profile: MainScreenState.ProfileState) { Card( diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt index cc55105bdc..d764b17a2c 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt @@ -9,8 +9,20 @@ data class MainScreenState( val existingRegistrationState: ExistingRegistrationState? = null, val registrationExpired: Boolean = false, val pendingFlowState: PendingFlowState? = null, - val profileState: ProfileState? = null + val profileState: ProfileState? = null, + val linkedDeviceState: LinkedDeviceState? = null ) { + /** + * Details specific to this device having been registered as a linked (secondary) device. + * Null when this is a primary device. + */ + data class LinkedDeviceState( + val deviceId: Int, + val linkAndSyncOffered: Boolean, + val linkAndSyncFrameCount: Int, + val linkAndSyncDownloadedBytes: Long + ) + data class PendingFlowState( val e164: String?, val backstackSize: Int, diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt index 078b01aeeb..3239fe394e 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt @@ -89,6 +89,16 @@ class MainScreenViewModel( ) }, pendingFlowState = loadPendingFlowState(), + linkedDeviceState = if (RegistrationPreferences.linkedDeviceId > 0) { + MainScreenState.LinkedDeviceState( + deviceId = RegistrationPreferences.linkedDeviceId, + linkAndSyncOffered = RegistrationPreferences.ephemeralBackupKey != null, + linkAndSyncFrameCount = RegistrationPreferences.linkAndSyncFrameCount, + linkAndSyncDownloadedBytes = RegistrationPreferences.linkAndSyncDownloadedBytes + ) + } else { + null + }, registrationExpired = false ) diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt index 8e7df9a28d..634c59b9b2 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/pinsettings/PinSettingsViewModel.kt @@ -185,7 +185,6 @@ class PinSettingsViewModel( spqr = true, usernameChangeSyncMessage = true ), - name = null, pniRegistrationId = RegistrationPreferences.pniRegistrationId, recoveryPassword = recoveryPassword ) 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 52a94f3372..4758d399ca 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 @@ -60,6 +60,10 @@ object RegistrationPreferences { private const val KEY_PROFILE_AVATAR = "profile_avatar" private const val KEY_PROFILE_DISCOVERABLE = "profile_discoverable" private const val KEY_RESTORE_DECISION = "restore_decision" + private const val KEY_LINKED_DEVICE_ID = "linked_device_id" + 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" fun init(context: Application) { this.context = context @@ -87,7 +91,7 @@ object RegistrationPreferences { var aep: AccountEntropyPool? get() = prefs.getString(KEY_AEP, null)?.let { AccountEntropyPool(it) } - set(value) = prefs.edit { putString(KEY_AEP, value?.toString()) } + set(value) = prefs.edit { putString(KEY_AEP, value?.value) } var profileKey: ProfileKey? get() = prefs.getString(KEY_PROFILE_KEY, null)?.let { ProfileKey(Base64.decode(it)) } @@ -165,6 +169,22 @@ object RegistrationPreferences { if (value == null) remove(KEY_PROFILE_DISCOVERABLE) else putBoolean(KEY_PROFILE_DISCOVERABLE, value) } + var linkedDeviceId: Int + get() = prefs.getInt(KEY_LINKED_DEVICE_ID, -1) + set(value) = prefs.edit { putInt(KEY_LINKED_DEVICE_ID, value) } + + var ephemeralBackupKey: ByteArray? + get() = prefs.getString(KEY_EPHEMERAL_BACKUP_KEY, null)?.let { Base64.decode(it) } + set(value) = prefs.edit { putString(KEY_EPHEMERAL_BACKUP_KEY, value?.let { Base64.encodeWithPadding(it) }) } + + var linkAndSyncFrameCount: Int + get() = prefs.getInt(KEY_LINK_AND_SYNC_FRAME_COUNT, -1) + set(value) = prefs.edit { putInt(KEY_LINK_AND_SYNC_FRAME_COUNT, value) } + + var linkAndSyncDownloadedBytes: Long + get() = prefs.getLong(KEY_LINK_AND_SYNC_DOWNLOADED_BYTES, -1L) + set(value) = prefs.edit { putLong(KEY_LINK_AND_SYNC_DOWNLOADED_BYTES, value) } + var restoredSvr2Credentials: List get() = prefs.getStringSet(KEY_SVR2_CREDENTIALS, emptySet())?.mapNotNull { parseCredential(it) } ?: emptyList() set(value) = prefs.edit { putStringSet(KEY_SVR2_CREDENTIALS, value.map { serializeCredential(it) }.toSet()) } 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 9dcff39fda..2ab260e25c 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import okio.ByteString import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey import org.signal.core.util.serialization.ByteArrayToBase64Serializer @@ -229,6 +230,68 @@ interface NetworkController { */ fun startProvisioning(): Flow + /** + * Starts a provisioning session for QR-based device linking (registering this device as a secondary + * device on a pre-existing account). + * + * The returned flow emits [LinkDeviceProvisioningEvent]s: + * - [LinkDeviceProvisioningEvent.QrCodeReady] whenever a new QR code URL is available (e.g. due to socket rotation). + * - [LinkDeviceProvisioningEvent.MessageReceived] when the primary device scans the QR code and sends provisioning data. + * - [LinkDeviceProvisioningEvent.Error] if the provisioning session encounters an unrecoverable error. + * + * The flow manages socket lifecycle (rotation, keep-alive) internally. Cancel the collecting coroutine to stop provisioning. + */ + fun startLinkDeviceProvisioning(): Flow + + /** + * Performs the network call to register this device as a linked (secondary) device on a pre-existing + * account (`PUT /v1/devices/link`), authenticated via basic auth with [e164] and [password]. + * + * This only performs the network request and returns the assigned device id. The caller is responsible + * for committing the account locally (via [StorageController.commitRegistrationData]) and performing the + * post-registration housekeeping (via [onLinkedDeviceRegistered]) and any restores. + */ + suspend fun registerAsLinkedDevice( + e164: String, + password: String, + provisioningCode: String, + deviceAttributes: DeviceAttributes, + aciPreKeys: PreKeyCollection, + pniPreKeys: PreKeyCollection, + fcmToken: String? + ): RequestResult + + /** + * Performs the network-side post-registration work for a freshly linked device, after the account has been + * committed locally via [StorageController.commitRegistrationData]: refreshes remote config and requests the + * initial sync messages from the primary. + * + * Intentionally does *not* include the link-and-sync backup restore (see [StorageController.restoreLinkAndSyncBackup]) + * or the storage-service restore (see [restoreLinkedDeviceFromStorageService]); the registration module + * sequences those separately so progress can be surfaced and timing controlled. Local-state finalization (e.g. + * the read-receipts preference) is applied as part of [StorageController.commitRegistrationData]. + */ + suspend fun onLinkedDeviceRegistered() + + /** + * Waits for the primary device to make a decision on a link-and-sync transfer (a long-poll that may be + * retried internally up to ~1 hour). + * + * Intended to be called while showing a spinner before navigating to the message-sync screen. + */ + suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult + + /** + * Restores account data from the storage service after this device has been linked as a secondary device. + * + * The registration module decides *when* to call this: immediately after [registerAsLinkedDevice] when there + * is no link-and-sync backup, or only after the link-and-sync backup has been applied when there is one. + * + * Implementations should be best-effort and may no-op when there is nothing to restore (e.g. when the primary + * did not share an account entropy pool). + */ + suspend fun restoreLinkedDeviceFromStorageService() + /** * Starts `DeviceToDeviceTransferService` in server mode on the new device. The concrete * [org.signal.devicetransfer.ServerTask] that receives and imports the backup lives in the app @@ -283,19 +346,6 @@ interface NetworkController { discoverableByPhoneNumber: Boolean ): RequestResult -// /** -// * Registers a device as a linked device on a pre-existing account. -// * -// * `PUT /v1/devices/link` -// * -// * - 403: Incorrect account verification -// * - 409: Device missing required account capability -// * - 411: Account reached max number of linked devices -// * - 422: Request is invalid -// * - 429: Rate limited -// */ -// suspend fun registerAsSecondaryDevice(verificationCode: String, attributes: AccountAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?) - sealed class CreateSessionError : BadRequestError { data class InvalidRequest(val message: String) : CreateSessionError() data class RateLimited(val retryAfter: Duration) : CreateSessionError() @@ -424,7 +474,6 @@ interface NetworkController { val unrestrictedUnidentifiedAccess: Boolean, val discoverableByPhoneNumber: Boolean, val capabilities: Capabilities?, - val name: String?, val pniRegistrationId: Int, val recoveryPassword: String? ) { @@ -439,6 +488,15 @@ interface NetworkController { ) } + @Serializable + class DeviceAttributes( + val fetchesMessages: Boolean, + val registrationId: Int, + val pniRegistrationId: Int, + val name: String?, + val capabilities: AccountAttributes.Capabilities? + ) + @Serializable @Parcelize data class RegisterAccountResponse( @@ -589,4 +647,66 @@ interface NetworkController { /** The provisioning session encountered an error. */ data class Error(val cause: Throwable?) : ProvisioningEvent } + + /** + * Data received from the primary device during QR-based device linking. + * + * The ACI/PNI are resolved to their canonical string form by the implementation. Identity keys are + * provided by the primary so this device shares the account's identity. + */ + class LinkDeviceProvisioningMessage( + val e164: String, + val provisioningCode: String, + val aci: String, + val pni: String, + val aciIdentityKeyPair: IdentityKeyPair, + val pniIdentityKeyPair: IdentityKeyPair, + val profileKey: ByteArray, + val ephemeralBackupKey: ByteString?, + val accountEntropyPool: String?, + val mediaRootBackupKey: ByteString?, + val readReceipts: Boolean? + ) + + /** + * Events emitted during a device-linking provisioning session. + */ + sealed interface LinkDeviceProvisioningEvent { + /** A new QR code URL is available for display. */ + data class QrCodeReady(val url: String) : LinkDeviceProvisioningEvent + + /** The primary device has scanned the QR code and sent provisioning data. */ + data class MessageReceived(val message: LinkDeviceProvisioningMessage) : LinkDeviceProvisioningEvent + + /** The provisioning session encountered an error. */ + data class Error(val cause: Throwable?) : LinkDeviceProvisioningEvent + } + + /** Minimal view of the `PUT /v1/devices/link` success body; we only need the assigned device id. */ + @Serializable + data class LinkDeviceResponse( + val deviceId: Int + ) + + sealed interface RegisterAsLinkedDeviceError : BadRequestError { + data object IncorrectVerification : RegisterAsLinkedDeviceError + data object MissingCapability : RegisterAsLinkedDeviceError + data object MaxLinkedDevices : RegisterAsLinkedDeviceError + data class InvalidRequest(val message: String? = null) : RegisterAsLinkedDeviceError + data class RateLimited(val retryAfter: Duration?) : RegisterAsLinkedDeviceError + } +} + +/** + * Result of waiting for the primary's link-and-sync transfer archive. + */ +sealed interface LinkAndSyncWaitResult { + /** The primary made a backup available at the given CDN location; proceed to download + apply it. */ + data class ArchiveAvailable(val cdn: Int, val key: String) : LinkAndSyncWaitResult + + /** The primary declined to sync, or never delivered an archive in time. */ + data object ContinueWithoutBackup : LinkAndSyncWaitResult + + /** The primary asked this device to re-link. Registration should be reset. */ + data object RelinkRequired : LinkAndSyncWaitResult } 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 10ce844621..d6264bd2f0 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -11,14 +11,20 @@ import android.net.Uri import com.google.i18n.phonenumbers.PhoneNumberUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okio.ByteString.Companion.toByteString import org.signal.archive.LocalBackupRestoreProgress import org.signal.core.models.AccountEntropyPool import org.signal.core.models.MasterKey +import org.signal.core.models.ServiceId.ACI +import org.signal.core.models.ServiceId.PNI import org.signal.core.util.Base64 +import org.signal.core.util.Hex import org.signal.core.util.Util +import org.signal.core.util.crypto.DeviceNameCipher import org.signal.core.util.logging.Log import org.signal.libsignal.net.RequestResult import org.signal.libsignal.protocol.IdentityKeyPair @@ -30,6 +36,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.CreateSessionError +import org.signal.registration.NetworkController.DeviceAttributes import org.signal.registration.NetworkController.MasterKeyResponse import org.signal.registration.NetworkController.PreKeyCollection import org.signal.registration.NetworkController.ProvisioningEvent @@ -40,11 +47,14 @@ import org.signal.registration.NetworkController.RestoreMasterKeyError import org.signal.registration.NetworkController.SessionMetadata import org.signal.registration.NetworkController.SvrCredentials import org.signal.registration.NetworkController.UpdateSessionError +import org.signal.registration.proto.LinkedDeviceData import org.signal.registration.proto.ProvisioningData import org.signal.registration.proto.SvrCredential import org.signal.registration.screens.localbackuprestore.LocalBackupInfo +import org.signal.registration.screens.messagesync.LinkAndSyncProgress import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress import org.signal.registration.util.SensitiveLog +import java.nio.charset.StandardCharsets import java.security.SecureRandom import java.util.Locale import javax.crypto.Cipher @@ -250,6 +260,160 @@ class RegistrationRepository(val context: Context, val networkController: Networ return networkController.startProvisioning() } + /** + * Starts a provisioning session for QR-based device linking. + * See [NetworkController.startLinkDeviceProvisioning]. + */ + fun startLinkDeviceProvisioning(): Flow { + return networkController.startLinkDeviceProvisioning() + } + + /** + * Registers this device as a linked (secondary) device using data from the primary. + * See [NetworkController.registerAsLinkedDevice]. + */ + suspend fun registerAsLinkedDevice( + message: NetworkController.LinkDeviceProvisioningMessage, + deviceName: String + ): RequestResult = withContext(Dispatchers.IO) { + checkNotNull(message.accountEntropyPool) { "Link provisioning message missing account entropy pool" } + + val e164 = message.e164 + val accountEntropyPool = AccountEntropyPool(message.accountEntropyPool) + val aci = ACI.parseOrThrow(message.aci) + val pni = PNI.parseOrThrow(message.pni) + val aciIdentityKeyPair = message.aciIdentityKeyPair + val pniIdentityKeyPair = message.pniIdentityKeyPair + val profileKey = ProfileKey(message.profileKey) + val provisioningCode = message.provisioningCode + + val keyMaterial = generateKeyMaterial( + existingAccountEntropyPool = accountEntropyPool, + existingAciIdentityKeyPair = aciIdentityKeyPair, + existingPniIdentityKeyPair = pniIdentityKeyPair, + profileKey = profileKey + ) + + storageController.updateInProgressRegistrationData { + this.aciIdentityKeyPair = keyMaterial.aciIdentityKeyPair.serialize().toByteString() + this.pniIdentityKeyPair = keyMaterial.pniIdentityKeyPair.serialize().toByteString() + this.aciSignedPreKey = keyMaterial.aciSignedPreKey.serialize().toByteString() + this.pniSignedPreKey = keyMaterial.pniSignedPreKey.serialize().toByteString() + this.aciLastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey.serialize().toByteString() + this.pniLastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey.serialize().toByteString() + this.aciRegistrationId = keyMaterial.aciRegistrationId + this.pniRegistrationId = keyMaterial.pniRegistrationId + this.unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey.toByteString() + this.profileKey = keyMaterial.profileKey.toByteString() + this.servicePassword = keyMaterial.servicePassword + this.accountEntropyPool = keyMaterial.accountEntropyPool.value + } + + val fcmToken = networkController.getFcmToken() + + storageController.updateInProgressRegistrationData { + this.fetchesMessages = fcmToken == null + } + + val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), aciIdentityKeyPair) + + val deviceAttributes = DeviceAttributes( + fetchesMessages = fcmToken == null, + registrationId = keyMaterial.aciRegistrationId, + pniRegistrationId = keyMaterial.pniRegistrationId, + name = Base64.encodeWithPadding(encryptedDeviceName), + capabilities = getAccountCapabilities() + ) + + val aciPreKeys = PreKeyCollection( + identityKey = keyMaterial.aciIdentityKeyPair.publicKey, + signedPreKey = keyMaterial.aciSignedPreKey, + lastResortKyberPreKey = keyMaterial.aciLastResortKyberPreKey + ) + + val pniPreKeys = PreKeyCollection( + identityKey = keyMaterial.pniIdentityKeyPair.publicKey, + signedPreKey = keyMaterial.pniSignedPreKey, + lastResortKyberPreKey = keyMaterial.pniLastResortKyberPreKey + ) + + val result = networkController.registerAsLinkedDevice( + e164 = e164, + password = keyMaterial.servicePassword, + provisioningCode = provisioningCode, + deviceAttributes = deviceAttributes, + aciPreKeys = aciPreKeys, + pniPreKeys = pniPreKeys, + fcmToken = fcmToken + ) + + if (result is RequestResult.Success) { + storageController.updateInProgressRegistrationData { + this.e164 = e164 + this.aci = aci.toString() + this.pni = pni.toString() + this.linkedDeviceData = LinkedDeviceData( + deviceId = result.result.deviceId.toInt(), + deviceName = deviceName, + ephemeralBackupKey = message.ephemeralBackupKey, + mediaRootBackupKey = message.mediaRootBackupKey, + readReceipts = message.readReceipts + ) + } + + // Commits the account locally (writes keys, identity, the LinkedDeviceInfo and read-receipts pref built from linkedDeviceData). + storageController.commitRegistrationData() + + // Network post-registration work: refresh remote config and request the initial sync from the primary. + // The link-and-sync backup restore and storage-service restore are sequenced separately by the caller. + networkController.onLinkedDeviceRegistered() + } + + result.map { LinkedDeviceResult(hasLinkAndSyncBackup = message.ephemeralBackupKey != null) } + } + + /** + * Restores the link-and-sync message backup made available by the primary device, surfacing progress. + * See [StorageController.restoreLinkAndSyncBackup]. + */ + fun restoreLinkAndSyncBackup(): Flow = flow { + emit(LinkAndSyncProgress.Waiting) + when (val result = networkController.awaitLinkAndSyncArchive()) { + is LinkAndSyncWaitResult.ArchiveAvailable -> emitAll(storageController.restoreLinkAndSyncBackup(result.cdn, result.key)) + LinkAndSyncWaitResult.ContinueWithoutBackup -> { + Log.w(TAG, "[restoreLinkAndSyncBackup] Primary declined to provide a backup; continuing without one.") + emit(LinkAndSyncProgress.Complete) + } + LinkAndSyncWaitResult.RelinkRequired -> { + Log.w(TAG, "[restoreLinkAndSyncBackup] Primary requested re-link; local registration is invalid.") + emit(LinkAndSyncProgress.RelinkRequired) + } + } + } + + /** + * Waits for the primary to make a link-and-sync archive available. + */ + suspend fun awaitLinkAndSyncArchive(): LinkAndSyncWaitResult = withContext(Dispatchers.IO) { + val ephemeralBackupKey = storageController.readInProgressRegistrationData().linkedDeviceData?.ephemeralBackupKey + if (ephemeralBackupKey == null) { + Log.i(TAG, "[awaitLinkAndSyncArchive] No ephemeral backup key in registration data; no archive expected.") + return@withContext LinkAndSyncWaitResult.ContinueWithoutBackup + } + networkController.awaitLinkAndSyncArchive() + } + + /** + * Restores account data from the storage service after linking. Call immediately after linking when there + * is no link-and-sync backup, or only after the backup has been applied when there is one. + * + * See [NetworkController.restoreLinkedDeviceFromStorageService]. + */ + suspend fun restoreLinkedDeviceFromStorageService() = withContext(Dispatchers.IO) { + networkController.restoreLinkedDeviceFromStorageService() + setRestoreDecision(RestoreDecision.NEW_ACCOUNT) + } + /** * Reports the user's chosen restore method to the server so the old (quick-restore) device's UI can update. * See [NetworkController.setRestoreMethod]. @@ -370,7 +534,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ val newMasterKey = keyMaterial.accountEntropyPool.deriveMasterKey() val newRecoveryPassword = newMasterKey.deriveRegistrationRecoveryPassword() - SensitiveLog.d(TAG, "[registerAccount] Using master key [${org.signal.core.util.Hex.toStringCondensed(newMasterKey.serialize())}] and RRP [$newRecoveryPassword]") + SensitiveLog.d(TAG, "[registerAccount] Using master key [${Hex.toStringCondensed(newMasterKey.serialize())}] and RRP [$newRecoveryPassword]") val accountAttributes = AccountAttributes( signalingKey = null, @@ -382,14 +546,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ unidentifiedAccessKey = keyMaterial.unidentifiedAccessKey, unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess, discoverableByPhoneNumber = false, // Important -- this should be false initially, and then the user should be given a choice as to whether to turn it on later - capabilities = AccountAttributes.Capabilities( - storage = true, // True initially -- can turn off later if users opt-out - versionedExpirationTimer = true, - attachmentBackfill = true, - spqr = true, - usernameChangeSyncMessage = true - ), - name = null, + capabilities = getAccountCapabilities(), pniRegistrationId = keyMaterial.pniRegistrationId, recoveryPassword = newRecoveryPassword ) @@ -594,6 +751,14 @@ class RegistrationRepository(val context: Context, val networkController: Networ } } + /** + * Wipes all local app data and relaunches the app. Used when the primary asks a freshly-linked device to + * re-link, which leaves this device's partial local registration invalid. + */ + suspend fun clearLocalDataAndRestart() { + storageController.clearLocalDataAndRestart() + } + /** * Clears any persisted flow state JSON from the in-progress registration data. */ @@ -666,7 +831,8 @@ class RegistrationRepository(val context: Context, val networkController: Networ private fun generateKeyMaterial( existingAccountEntropyPool: AccountEntropyPool? = null, existingAciIdentityKeyPair: IdentityKeyPair? = null, - existingPniIdentityKeyPair: IdentityKeyPair? = null + existingPniIdentityKeyPair: IdentityKeyPair? = null, + profileKey: ProfileKey? = null ): KeyMaterial { val accountEntropyPool = existingAccountEntropyPool ?: AccountEntropyPool.generate() val aciIdentityKeyPair = existingAciIdentityKeyPair ?: IdentityKeyPair.generate() @@ -679,7 +845,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ val aciLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, aciIdentityKeyPair) val pniLastResortKyberPreKey = generateKyberPreKey(generatePreKeyId(), timestamp, pniIdentityKeyPair) - val profileKey = generateProfileKey() + val profileKey = profileKey ?: generateProfileKey() return KeyMaterial( aciIdentityKeyPair = aciIdentityKeyPair, @@ -729,6 +895,16 @@ class RegistrationRepository(val context: Context, val networkController: Networ return Base64.encodeWithPadding(passwordBytes) } + fun getAccountCapabilities(): AccountAttributes.Capabilities { + return AccountAttributes.Capabilities( + storage = true, // True initially -- can turn off later if users opt-out + versionedExpirationTimer = true, + attachmentBackfill = true, + spqr = true, + usernameChangeSyncMessage = true + ) + } + private fun deriveUnidentifiedAccessKey(profileKey: ProfileKey): ByteArray { val nonce = ByteArray(12) val input = ByteArray(16) @@ -740,3 +916,13 @@ class RegistrationRepository(val context: Context, val networkController: Networ return ciphertext.copyOf(16) } } + +/** + * Result of registering as a linked (secondary) device. + * + * @param hasLinkAndSyncBackup Whether the primary provided an ephemeral backup key, meaning the caller should + * wait for and apply a link-and-sync message backup before finishing. + */ +data class LinkedDeviceResult( + val hasLinkAndSyncBackup: 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 382f84684d..ae1629c808 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -19,6 +19,7 @@ import org.signal.libsignal.protocol.state.KyberPreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord 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.signal.registration.util.ACIParceler import org.signal.registration.util.AccountEntropyPoolParceler @@ -55,6 +56,12 @@ interface StorageController { */ suspend fun clearAllData() + /** + * Wipes **all** local app data and attempts to relaunch the app into a fresh state. Used when the primary + * asks a freshly-linked device to re-link. + */ + suspend fun clearLocalDataAndRestart() + /** * Reads the persisted [RegistrationData] proto that is currently in the process of being worked on. * Returns a default empty [RegistrationData] if nothing has been written yet. @@ -117,6 +124,15 @@ interface StorageController { */ fun restoreRemoteBackup(aep: AccountEntropyPool): Flow + /** + * Downloads and imports the link-and-sync message backup from the given CDN location ([cdn]/[key]). The ephemeral + * backup key needed to decrypt the backup is read from the locally persisted registration metadata committed + * during registration. + * + * @return A [Flow] of [LinkAndSyncProgress] reporting progress through completion or error. + */ + fun restoreLinkAndSyncBackup(cdn: Int, key: String): Flow + /** * Scans the given folder URI for local backup files, checking for both modern * folder-based backups and legacy .backup files. diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt index 6b50e88abe..59243385e2 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreen.kt @@ -57,6 +57,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.signal.core.ui.WindowBreakpoint 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.IconButtons import org.signal.core.ui.compose.LocalAnimateVisibilityScope import org.signal.core.ui.compose.LocalSharedTransitionScope @@ -105,6 +107,32 @@ fun LinkAccountScreen( } } } + + StateDialogs(state = state, onEvent = onEvent) + } +} + +@Composable +private fun StateDialogs( + state: LinkAccountScreenState, + onEvent: (LinkAccountScreenEvent) -> Unit +) { + if (state.isRegistering) { + Dialogs.IndeterminateProgressDialog( + message = stringResource(R.string.LinkAccountScreen__linking_device) + ) + } else if (state.isWaitingForPrimary) { + Dialogs.IndeterminateProgressDialog( + message = stringResource(R.string.LinkAccountScreen__waiting_for_your_other_device) + ) + } + + if (state.showError) { + Dialogs.SimpleMessageDialog( + message = stringResource(R.string.LinkAccountScreen__error_linking_device), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onEvent(LinkAccountScreenEvent.DismissError) } + ) } } @@ -299,7 +327,7 @@ private fun QrCodeContent( modifier = Modifier.fillMaxSize() ) { when (target) { - QrState.Failed -> QrCodeFailed() + QrState.Failed -> QrCodeFailed(onEvent) is QrState.Loaded -> QrCodeDisplay(target.qrCodeData, state.displayQrOverlay, sharedTransitionScope, animatedVisibilityScope) QrState.Loading -> QrCodeLoading() QrState.Scanned -> QrCodeScanned() @@ -383,7 +411,9 @@ private fun QrCodeScanned() { } @Composable -private fun QrCodeFailed() { +private fun QrCodeFailed( + onEvent: (LinkAccountScreenEvent) -> Unit +) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = spacedBy(8.dp) @@ -399,6 +429,9 @@ private fun QrCodeFailed() { style = MaterialTheme.typography.bodyMedium, color = colorResource(org.signal.core.ui.R.color.signal_light_colorError) ) + Buttons.Small(onClick = { onEvent(LinkAccountScreenEvent.RetryQrCode) }) { + Text(text = stringResource(R.string.LinkAccountScreen__retry)) + } } } @@ -421,7 +454,10 @@ fun QrCodeOverlay( onClick = { onEvent(LinkAccountScreenEvent.HideOverlayClick) }, modifier = Modifier.testTag(TestTags.LINK_ACCOUNT_HIDE_OVERLAY_BUTTON) ) { - Icon(imageVector = SignalIcons.X.imageVector, contentDescription = null) // TODO 'close' + Icon( + imageVector = SignalIcons.X.imageVector, + contentDescription = stringResource(R.string.LinkAccountScreen__close_qr_code) + ) } } } @@ -574,6 +610,8 @@ private fun LinkAccountScreenPreview() { LinkAccountScreenEvent.DisplayOverlayClick -> displayQrOverlay = true LinkAccountScreenEvent.GetHelpClick -> Unit LinkAccountScreenEvent.HideOverlayClick -> displayQrOverlay = false + LinkAccountScreenEvent.RetryQrCode -> Unit + LinkAccountScreenEvent.DismissError -> Unit } } ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt index 3a050d9cfb..a155301a1a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenEvent.kt @@ -10,4 +10,6 @@ sealed class LinkAccountScreenEvent { data object CreateAccountClick : LinkAccountScreenEvent() data object DisplayOverlayClick : LinkAccountScreenEvent() data object HideOverlayClick : LinkAccountScreenEvent() + data object RetryQrCode : LinkAccountScreenEvent() + data object DismissError : LinkAccountScreenEvent() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt index b5928b1e5e..7ed693c513 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenState.kt @@ -9,5 +9,8 @@ import org.signal.registration.screens.quickrestore.QrState data class LinkAccountScreenState( val qrCodeState: QrState = QrState.Loading, - val displayQrOverlay: Boolean = false + val displayQrOverlay: Boolean = false, + val isRegistering: Boolean = false, + val isWaitingForPrimary: Boolean = false, + val showError: Boolean = false ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt index c3c3d35e43..1f5fc27573 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt @@ -5,20 +5,35 @@ package org.signal.registration.screens.linkaccount +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.QrCodeData import org.signal.core.util.logging.Log +import org.signal.libsignal.net.RequestResult +import org.signal.registration.LinkAndSyncWaitResult +import org.signal.registration.NetworkController import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository import org.signal.registration.RegistrationRoute import org.signal.registration.screens.EventDrivenViewModel +import org.signal.registration.screens.quickrestore.QrState import org.signal.registration.screens.util.navigateTo +/** + * Handles creating and maintaining a provisioning websocket in pursuit of adding this device as a + * linked (secondary) device, then registering it once the primary scans the QR code. + */ class LinkAccountViewModel( private val repository: RegistrationRepository, private val parentState: StateFlow, @@ -27,20 +42,123 @@ class LinkAccountViewModel( companion object { private val TAG = Log.tag(LinkAccountViewModel::class) + private const val DEVICE_NAME = "Android" } private val _state = MutableStateFlow(LinkAccountScreenState()) - val state: StateFlow = _state.asStateFlow() + val state: StateFlow = _state + .onEach { Log.d(TAG, "[State] $it") } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LinkAccountScreenState()) - // TODO [regv5] - load qr code data + private var provisioningJob: Job? = null + + init { + startProvisioning() + } override suspend fun processEvent(event: LinkAccountScreenEvent) { - when (event) { + applyEvent(_state.value, event) { _state.value = it } + } + + @VisibleForTesting + fun applyEvent(state: LinkAccountScreenState, event: LinkAccountScreenEvent, stateEmitter: (LinkAccountScreenState) -> Unit) { + val result = when (event) { LinkAccountScreenEvent.GetHelpClick -> error("This event is handled in the nav-entry.") - LinkAccountScreenEvent.CreateAccountClick -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry)) - LinkAccountScreenEvent.DisplayOverlayClick -> _state.update { it.copy(displayQrOverlay = true) } - LinkAccountScreenEvent.HideOverlayClick -> _state.update { it.copy(displayQrOverlay = false) } + LinkAccountScreenEvent.CreateAccountClick -> { + parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry)) + state + } + LinkAccountScreenEvent.DisplayOverlayClick -> state.copy(displayQrOverlay = true) + LinkAccountScreenEvent.HideOverlayClick -> state.copy(displayQrOverlay = false) + LinkAccountScreenEvent.RetryQrCode, LinkAccountScreenEvent.DismissError -> { + startProvisioning() + state.copy(qrCodeState = QrState.Loading, showError = false) + } } + stateEmitter(result) + } + + private fun startProvisioning() { + provisioningJob?.cancel() + provisioningJob = viewModelScope.launch { + repository.startLinkDeviceProvisioning().collect { event -> + when (event) { + is NetworkController.LinkDeviceProvisioningEvent.QrCodeReady -> { + Log.d(TAG, "[Provisioning] QR code ready") + _state.update { + it.copy(qrCodeState = QrState.Loaded(qrCodeData = QrCodeData.forData(data = event.url, supportIconOverlay = false))) + } + } + is NetworkController.LinkDeviceProvisioningEvent.MessageReceived -> { + Log.i(TAG, "[Provisioning] Message received from primary device") + handleProvisioningMessage(event.message) + } + is NetworkController.LinkDeviceProvisioningEvent.Error -> { + Log.w(TAG, "[Provisioning] Error", event.cause) + _state.update { it.copy(qrCodeState = QrState.Failed) } + } + } + } + } + } + + private suspend fun handleProvisioningMessage(message: NetworkController.LinkDeviceProvisioningMessage) { + _state.update { it.copy(isRegistering = true, qrCodeState = QrState.Scanned) } + + when (val result = repository.registerAsLinkedDevice(message, DEVICE_NAME)) { + is RequestResult.Success -> { + Log.i(TAG, "[Register] Success! hasLinkAndSyncBackup: ${result.result.hasLinkAndSyncBackup}") + if (result.result.hasLinkAndSyncBackup) { + // Wait here until the primary actually makes the backup available or tells us not to expect one + _state.update { it.copy(isRegistering = false, isWaitingForPrimary = true) } + val waitResult = repository.awaitLinkAndSyncArchive() + _state.update { it.copy(isWaitingForPrimary = false) } + + when (waitResult) { + is LinkAndSyncWaitResult.ArchiveAvailable -> { + // Backup is ready: hand off to the MessageSync screen to download + apply it + parentEventEmitter.navigateTo(RegistrationRoute.MessageSync) + } + LinkAndSyncWaitResult.ContinueWithoutBackup -> { + // The primary declined to sync or never delivered a backup. The device is still linked, so restore from storage service and finish. + Log.w(TAG, "[Register] Link-and-sync offered but continuing without a backup.") + repository.restoreLinkedDeviceFromStorageService() + parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete) + } + LinkAndSyncWaitResult.RelinkRequired -> { + // The primary asked us to re-link, which leaves our partial local registration invalid. Wipe everything and restart. + Log.w(TAG, "[Register] Primary requested re-link; wiping local data and restarting.") + repository.clearLocalDataAndRestart() + } + } + } else { + // No link-and-sync backup, restore from storage service immediately, then finish + repository.restoreLinkedDeviceFromStorageService() + _state.update { it.copy(isRegistering = false) } + parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete) + } + } + is RequestResult.NonSuccess -> { + Log.w(TAG, "[Register] Failed: ${result.error::class.simpleName}") + showRegistrationError() + } + is RequestResult.RetryableNetworkError -> { + Log.w(TAG, "[Register] Network error.", result.networkError) + showRegistrationError() + } + is RequestResult.ApplicationError -> { + Log.w(TAG, "[Register] Application error.", result.cause) + showRegistrationError() + } + } + } + + private fun showRegistrationError() { + _state.update { it.copy(isRegistering = false, showError = true) } + } + + override fun onCleared() { + provisioningJob?.cancel() } class Factory( diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/LinkAndSyncProgress.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/LinkAndSyncProgress.kt new file mode 100644 index 0000000000..387a088f21 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/LinkAndSyncProgress.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.messagesync + +import org.signal.core.util.ByteSize + +/** + * Progress events emitted while restoring a link-and-sync message backup from the primary device. + * Each value maps to a UI state for the [MessageSyncScreen]. + */ +sealed interface LinkAndSyncProgress { + /** Waiting for the primary device to make the backup available (no byte progress yet). */ + data object Waiting : LinkAndSyncProgress + + /** Downloading the backup from the primary/CDN. */ + data class Downloading(val bytesDownloaded: ByteSize, val totalBytes: ByteSize) : LinkAndSyncProgress + + /** Importing/restoring messages from the downloaded backup. */ + data object Restoring : LinkAndSyncProgress + + /** The link-and-sync restore completed (or there was nothing to restore). */ + data object Complete : LinkAndSyncProgress + + /** The link-and-sync restore failed. Linking still succeeded, so callers may choose to continue. */ + data class Failed(val cause: Throwable? = null) : LinkAndSyncProgress + + /** + * The primary asked this device to re-link, which invalidates the partial local registration. Callers must + * wipe local data and restart rather than finishing registration. + */ + data object RelinkRequired : LinkAndSyncProgress +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt index 1b93d17cc3..fab50a2519 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreen.kt @@ -91,6 +91,7 @@ private fun OnePane(params: RegistrationScaffold.Params.OnePane, state: MessageS FooterContent( params = params, isElevated = scrollState.canScrollForward, + canCancel = !state.isFinishing, onEvent = onEvent ) } @@ -126,6 +127,7 @@ private fun TwoPane(params: RegistrationScaffold.Params.TwoPane, state: MessageS FooterContent( params = params, isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward, + canCancel = !state.isFinishing, onEvent = onEvent ) } @@ -153,28 +155,33 @@ private fun FirstPaneContent( modifier = Modifier.padding(top = 16.dp) ) - LinearProgressIndicator( - progress = { - if (state.totalBytes.bytes > 0) { - state.downloadedBytes.bytes.toFloat() / state.totalBytes.bytes.toFloat() - } else { - 0f - } - }, - drawStopIndicator = {}, - gapSize = 0.dp, - modifier = Modifier - .padding(top = 48.dp, bottom = 16.dp) - .widthIn(max = 415.dp) - .fillMaxWidth() - ) + val showDownloadProgress = state.totalBytes.bytes > 0 && !state.isFinishing + val progressModifier = Modifier + .padding(top = 48.dp, bottom = 16.dp) + .widthIn(max = 415.dp) + .fillMaxWidth() + + if (showDownloadProgress) { + LinearProgressIndicator( + progress = { state.downloadedBytes.percentageOf(state.totalBytes) }, + drawStopIndicator = {}, + gapSize = 0.dp, + modifier = progressModifier + ) + } else { + LinearProgressIndicator(modifier = progressModifier) + } Text( - text = stringResource( - R.string.MessageSyncScreen__downloading_s_of_s, - state.downloadedBytes.toUnitString(), - state.totalBytes.toUnitString() - ), + text = when { + state.isFinishing -> stringResource(R.string.MessageSyncScreen__finishing) + showDownloadProgress -> stringResource( + R.string.MessageSyncScreen__downloading_s_of_s, + state.downloadedBytes.toUnitString(), + state.totalBytes.toUnitString() + ) + else -> stringResource(R.string.MessageSyncScreen__preparing) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -197,6 +204,7 @@ private fun SecondPaneContent( private fun FooterContent( params: RegistrationScaffold.Params, isElevated: Boolean, + canCancel: Boolean, onEvent: (MessageSyncScreenEvent) -> Unit, modifier: Modifier = Modifier ) { @@ -206,8 +214,8 @@ private fun FooterContent( isElevated = isElevated ) { when (breakpoint) { - is WindowBreakpoint.Small, is WindowBreakpoint.Medium -> StackedFooter(params, modifier, onEvent) - is WindowBreakpoint.Large -> InlineFooter(params, modifier, onEvent) + is WindowBreakpoint.Small, is WindowBreakpoint.Medium -> StackedFooter(params, canCancel, modifier, onEvent) + is WindowBreakpoint.Large -> InlineFooter(params, canCancel, modifier, onEvent) } } } @@ -215,6 +223,7 @@ private fun FooterContent( @Composable private fun StackedFooter( params: RegistrationScaffold.Params, + canCancel: Boolean, modifier: Modifier, onEvent: (MessageSyncScreenEvent) -> Unit ) { @@ -229,6 +238,7 @@ private fun StackedFooter( modifier = Modifier.padding(bottom = 16.dp) ) Cancel( + enabled = canCancel, onEvent = onEvent, modifier = Modifier .widthIn(max = params.maxButtonWidth) @@ -240,6 +250,7 @@ private fun StackedFooter( @Composable private fun InlineFooter( params: RegistrationScaffold.Params, + canCancel: Boolean, modifier: Modifier, onEvent: (MessageSyncScreenEvent) -> Unit ) { @@ -257,6 +268,7 @@ private fun InlineFooter( contentAlignment = Alignment.CenterEnd ) { Cancel( + enabled = canCancel, onEvent = onEvent, modifier = Modifier .widthIn(max = params.maxButtonWidth) @@ -316,9 +328,10 @@ private fun Notice( } @Composable -private fun Cancel(onEvent: (MessageSyncScreenEvent) -> Unit, modifier: Modifier = Modifier) { +private fun Cancel(onEvent: (MessageSyncScreenEvent) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { Buttons.LargeTonal( onClick = { onEvent(MessageSyncScreenEvent.CancelClick) }, + enabled = enabled, modifier = modifier.testTag(TestTags.MESSAGE_SYNC_CANCEL_BUTTON) ) { Text(text = stringResource(R.string.MessageSyncScreen__cancel)) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt index 58877271a2..a6505eb077 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenState.kt @@ -10,5 +10,6 @@ import org.signal.core.util.bytes data class MessageSyncScreenState( val downloadedBytes: ByteSize = 0.bytes, - val totalBytes: ByteSize = 0.bytes + val totalBytes: ByteSize = 0.bytes, + val isFinishing: Boolean = false ) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt index b48b6e0d8f..b2a86eba6a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt @@ -5,17 +5,31 @@ package org.signal.registration.screens.messagesync +import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.signal.core.util.logging.Log import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowState import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute import org.signal.registration.screens.EventDrivenViewModel +import org.signal.registration.screens.util.navigateTo +/** + * Drives the link-and-sync message backup restore that runs after this device is registered as a + * linked device, surfacing download progress and completing registration when finished. + */ class MessageSyncViewModel( private val repository: RegistrationRepository, private val parentState: StateFlow, @@ -27,15 +41,79 @@ class MessageSyncViewModel( } private val _state = MutableStateFlow(MessageSyncScreenState()) - val state: StateFlow = _state.asStateFlow() + val state: StateFlow = _state + .onEach { Log.d(TAG, "[State] $it") } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MessageSyncScreenState()) - // TODO [regv5] wire in message restoration state + private var restoreJob: Job? = null + private var finishJob: Job? = null + + init { + startRestore() + } + + private fun startRestore() { + restoreJob?.cancel() + restoreJob = viewModelScope.launch { + repository.restoreLinkAndSyncBackup().collect { progress -> + when (progress) { + is LinkAndSyncProgress.Waiting -> Unit + is LinkAndSyncProgress.Downloading -> _state.update { + it.copy(downloadedBytes = progress.bytesDownloaded, totalBytes = progress.totalBytes) + } + is LinkAndSyncProgress.Restoring -> _state.update { it.copy(isFinishing = true) } + is LinkAndSyncProgress.Complete -> { + Log.i(TAG, "[MessageSync] Link-and-sync complete; restoring from storage service then completing.") + finish() + } + is LinkAndSyncProgress.Failed -> { + Log.w(TAG, "[MessageSync] Link-and-sync failed; restoring from storage service then completing (still linked).", progress.cause) + finish() + } + is LinkAndSyncProgress.RelinkRequired -> { + Log.w(TAG, "[MessageSync] Primary requested re-link; wiping local data and restarting.") + repository.clearLocalDataAndRestart() + } + } + } + } + } + + private fun finish(cancelDownload: Boolean = false) { + if (finishJob != null) { + return + } + + finishJob = viewModelScope.launch { + _state.update { it.copy(isFinishing = true) } + if (cancelDownload) { + restoreJob?.cancelAndJoin() + } + repository.restoreLinkedDeviceFromStorageService() + parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete) + } + } override suspend fun processEvent(event: MessageSyncScreenEvent) { - when (event) { + applyEvent(_state.value, event) { _state.value = it } + } + + @VisibleForTesting + suspend fun applyEvent(state: MessageSyncScreenState, event: MessageSyncScreenEvent, stateEmitter: (MessageSyncScreenState) -> Unit) { + val result = when (event) { MessageSyncScreenEvent.LearnMoreClick -> error("This event is handled in the nav-entry.") - MessageSyncScreenEvent.CancelClick -> Unit // TODO [regv5] wire in to actually cancel the download. + MessageSyncScreenEvent.CancelClick -> { + Log.i(TAG, "[MessageSync] User cancelled message sync; awaiting restore cancellation, then restoring from storage service and completing.") + stateEmitter(state.copy(isFinishing = true)) + finish(cancelDownload = true) + state.copy(isFinishing = true) + } } + stateEmitter(result) + } + + override fun onCleared() { + restoreJob?.cancel() } class Factory( diff --git a/feature/registration/src/main/protowire/Registration.proto b/feature/registration/src/main/protowire/Registration.proto index c3db3ef149..6b87ac9b34 100644 --- a/feature/registration/src/main/protowire/Registration.proto +++ b/feature/registration/src/main/protowire/Registration.proto @@ -52,6 +52,11 @@ message RegistrationData { // JSON-serialized flow state snapshot (from saveFlowState/restoreFlowState) string flowStateJson = 21; + + // Registration data provided by the primary+server to register as a linked device + LinkedDeviceData linkedDeviceData = 25; + + // Next: 26 } message SvrCredential { @@ -79,3 +84,11 @@ message ProvisioningData { int64 backupSizeBytes = 5; int64 backupVersion = 6; } + +message LinkedDeviceData { + uint32 deviceId = 1; + string deviceName = 2; + optional bytes ephemeralBackupKey = 3; + optional bytes mediaRootBackupKey = 4; + optional bool readReceipts = 5; +} diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index f34853f66f..eef019d5aa 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -393,6 +393,10 @@ This may take a few minutes… Downloading %1$s of %2$s + + Preparing… + + Finishing… Messages and chat info are protected by end-to-end encryption, including the syncing process. @@ -422,6 +426,16 @@ Don\'t have Signal on another device? Create account + + Retry + + Linking device… + + Waiting for your other device… + + An error occurred while linking this device + + Close QR code Transfer your account diff --git a/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt new file mode 100644 index 0000000000..96755aba97 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt @@ -0,0 +1,229 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.linkaccount + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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.registration.LinkAndSyncWaitResult +import org.signal.registration.LinkedDeviceResult +import org.signal.registration.NetworkController +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.quickrestore.QrState + +@OptIn(ExperimentalCoroutinesApi::class) +class LinkAccountViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var mockRepository: RegistrationRepository + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + private lateinit var stateEmitter: (LinkAccountScreenState) -> Unit + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockRepository = mockk(relaxed = true) + every { mockRepository.startLinkDeviceProvisioning() } returns emptyFlow() + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + emittedStates = mutableListOf() + stateEmitter = { state -> emittedStates.add(state) } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun TestScope.createViewModel(): LinkAccountViewModel { + val viewModel = LinkAccountViewModel( + repository = mockRepository, + parentState = MutableStateFlow(RegistrationFlowState()), + parentEventEmitter = parentEventEmitter + ) + // Keep the WhileSubscribed state flow hot so state.value reflects updates during the test. + backgroundScope.launch { viewModel.state.collect {} } + return viewModel + } + + @Test + fun `initial state has qrState Loading`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + assertThat(viewModel.state.value.qrCodeState).isEqualTo(QrState.Loading) + assertThat(viewModel.state.value.isRegistering).isFalse() + assertThat(viewModel.state.value.showError).isFalse() + } + + @Test + fun `QrCodeReady provisioning event updates state to Loaded`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.QrCodeReady("sgnl://linkdevice")) + + assertThat(viewModel.state.value.qrCodeState).isInstanceOf() + } + + @Test + fun `Error provisioning event updates state to Failed`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.Error(RuntimeException("boom"))) + + assertThat(viewModel.state.value.qrCodeState).isEqualTo(QrState.Failed) + } + + @Test + fun `successful registration without link-and-sync navigates to FullyComplete`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + val message = mockk(relaxed = true) + coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = false)) + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message)) + + assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + } + + @Test + fun `successful registration with link-and-sync navigates to MessageSync`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + val message = mockk(relaxed = true) + coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = true)) + coEvery { mockRepository.awaitLinkAndSyncArchive() } returns LinkAndSyncWaitResult.ArchiveAvailable(cdn = 3, key = "archive-key") + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message)) + + assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.MessageSync)) + } + + @Test + fun `link-and-sync offered but archive never arrives navigates to FullyComplete`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + val message = mockk(relaxed = true) + coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = true)) + coEvery { mockRepository.awaitLinkAndSyncArchive() } returns LinkAndSyncWaitResult.ContinueWithoutBackup + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message)) + + assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + } + + @Test + fun `link-and-sync relink requested wipes local data and restarts`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + val message = mockk(relaxed = true) + coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.Success(LinkedDeviceResult(hasLinkAndSyncBackup = true)) + coEvery { mockRepository.awaitLinkAndSyncArchive() } returns LinkAndSyncWaitResult.RelinkRequired + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message)) + + coVerify { mockRepository.clearLocalDataAndRestart() } + } + + @Test + fun `failed registration shows error`() = runTest(testDispatcher) { + val flow = givenProvisioningFlow() + val message = mockk(relaxed = true) + coEvery { mockRepository.registerAsLinkedDevice(message, any()) } returns RequestResult.NonSuccess(NetworkController.RegisterAsLinkedDeviceError.MaxLinkedDevices) + + val viewModel = createViewModel() + flow.emit(NetworkController.LinkDeviceProvisioningEvent.MessageReceived(message)) + + assertThat(viewModel.state.value.showError).isTrue() + assertThat(viewModel.state.value.isRegistering).isFalse() + } + + @Test + fun `applyEvent DisplayOverlayClick shows the QR overlay`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.DisplayOverlayClick, stateEmitter) + + assertThat(emittedStates.last().displayQrOverlay).isTrue() + } + + @Test + fun `applyEvent HideOverlayClick hides the QR overlay`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.applyEvent(LinkAccountScreenState(displayQrOverlay = true), LinkAccountScreenEvent.HideOverlayClick, stateEmitter) + + assertThat(emittedStates.last().displayQrOverlay).isFalse() + } + + @Test + fun `applyEvent RetryQrCode resets to Loading and clears error`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val errored = LinkAccountScreenState(qrCodeState = QrState.Failed, showError = true) + + viewModel.applyEvent(errored, LinkAccountScreenEvent.RetryQrCode, stateEmitter) + + assertThat(emittedStates.last().qrCodeState).isEqualTo(QrState.Loading) + assertThat(emittedStates.last().showError).isFalse() + } + + @Test + fun `applyEvent DismissError resets to Loading and clears error`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val errored = LinkAccountScreenState(qrCodeState = QrState.Failed, showError = true) + + viewModel.applyEvent(errored, LinkAccountScreenEvent.DismissError, stateEmitter) + + assertThat(emittedStates.last().qrCodeState).isEqualTo(QrState.Loading) + assertThat(emittedStates.last().showError).isFalse() + } + + @Test + fun `applyEvent CreateAccountClick navigates to Permissions`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.CreateAccountClick, stateEmitter) + + assertThat(emittedParentEvents).contains( + RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry)) + ) + } + + private fun givenProvisioningFlow(): MutableSharedFlow { + val flow = MutableSharedFlow(replay = 1) + every { mockRepository.startLinkDeviceProvisioning() } returns flow + return flow + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt new file mode 100644 index 0000000000..0def7decf5 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.messagesync + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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.core.util.bytes +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute + +@OptIn(ExperimentalCoroutinesApi::class) +class MessageSyncViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var mockRepository: RegistrationRepository + private lateinit var emittedParentEvents: MutableList + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + mockRepository = mockk(relaxed = true) + every { mockRepository.restoreLinkAndSyncBackup() } returns MutableSharedFlow() + emittedParentEvents = mutableListOf() + parentEventEmitter = { event -> emittedParentEvents.add(event) } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `restore Complete restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) { + every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Complete) + + createViewModel() + + coVerify { mockRepository.restoreLinkedDeviceFromStorageService() } + assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + } + + @Test + fun `restore Failed still restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) { + every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed()) + + createViewModel() + + coVerify { mockRepository.restoreLinkedDeviceFromStorageService() } + assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + } + + @Test + fun `restore RelinkRequired wipes local data and does not finish registration`() = runTest(testDispatcher) { + every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.RelinkRequired) + + createViewModel() + + coVerify { mockRepository.clearLocalDataAndRestart() } + coVerify(exactly = 0) { mockRepository.restoreLinkedDeviceFromStorageService() } + assertThat(emittedParentEvents).doesNotContain(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + } + + @Test + fun `applyEvent CancelClick restores from storage service then navigates to FullyComplete`() = runTest(testDispatcher) { + // A never-emitting flow keeps the restore job active so cancelAndJoin has something to wait on. + every { mockRepository.restoreLinkAndSyncBackup() } returns MutableSharedFlow() + + val viewModel = createViewModel() + viewModel.applyEvent(MessageSyncScreenState(), MessageSyncScreenEvent.CancelClick) {} + + coVerify { mockRepository.restoreLinkedDeviceFromStorageService() } + assertThat(emittedParentEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete)) + } + + @Test + fun `restore Downloading shows byte progress and is not finishing`() = runTest(testDispatcher) { + every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Downloading(bytesDownloaded = 5.bytes, totalBytes = 10.bytes)) + + val viewModel = createViewModel() + + assertThat(viewModel.state.value.isFinishing).isFalse() + } + + @Test + fun `restore Restoring switches to indeterminate finishing state`() = runTest(testDispatcher) { + every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf( + LinkAndSyncProgress.Downloading(bytesDownloaded = 5.bytes, totalBytes = 10.bytes), + LinkAndSyncProgress.Restoring + ) + + val viewModel = createViewModel() + + assertThat(viewModel.state.value.isFinishing).isTrue() + } + + private fun TestScope.createViewModel(): MessageSyncViewModel { + val viewModel = MessageSyncViewModel( + repository = mockRepository, + parentState = MutableStateFlow(RegistrationFlowState()), + parentEventEmitter = parentEventEmitter + ) + // Keep the WhileSubscribed state flow hot so state.value reflects updates during the test. + backgroundScope.launch { viewModel.state.collect {} } + return viewModel + } +} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/DeviceAttributes.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/DeviceAttributes.kt new file mode 100644 index 0000000000..e75437e4b6 --- /dev/null +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/DeviceAttributes.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.signalservice.api.account + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Attributes sent when registering as a linked (secondary) device via `PUT /v1/devices/link`. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +class DeviceAttributes @JsonCreator constructor( + @JsonProperty val fetchesMessages: Boolean, + @JsonProperty val registrationId: Int, + @JsonProperty val pniRegistrationId: Int, + @JsonProperty val name: String?, + @JsonProperty val capabilities: AccountAttributes.Capabilities? +) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/TransferArchiveResponse.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/TransferArchiveResponse.kt index 07edf5afc3..95dfc7fd7d 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/TransferArchiveResponse.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/TransferArchiveResponse.kt @@ -9,9 +9,23 @@ import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty /** - * Data from primary on where to find link+sync backup file. + * Response from the `transfer_archive` long-poll. The primary either provides where to find the link+sync + * backup file ([cdn] + [key]), or reports an [error] indicating it will not provide one. */ data class TransferArchiveResponse @JsonCreator constructor( - @JsonProperty val cdn: Int, - @JsonProperty val key: String -) + @JsonProperty("cdn") val cdn: Int? = null, + @JsonProperty("key") val key: String? = null, + @JsonProperty("error") val error: String? = null +) { + companion object { + /** The primary is requesting that this device re-link; no backup will be provided. */ + const val ERROR_RELINK_REQUESTED = "RELINK_REQUESTED" + + /** The primary has decided to abort the sync; continue linking without a backup. */ + const val ERROR_CONTINUE_WITHOUT_UPLOAD = "CONTINUE_WITHOUT_UPLOAD" + } + + /** True if the primary provided a backup location (rather than an error). */ + val hasArchive: Boolean + get() = cdn != null && key != null +} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/WaitForLinkedDeviceResponse.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/WaitForLinkedDeviceResponse.kt index 57408788d4..9b6cc6f33d 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/WaitForLinkedDeviceResponse.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/WaitForLinkedDeviceResponse.kt @@ -12,7 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty */ data class WaitForLinkedDeviceResponse( @JsonProperty val id: Int, - @JsonProperty val name: String, + @JsonProperty val name: String?, @JsonProperty val lastSeen: Long, @JsonProperty val registrationId: Int, @JsonProperty val createdAtCiphertext: String? diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt index 43ad0029e1..f1ec01ffff 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt @@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.registration import org.signal.network.NetworkResult import org.whispersystems.signalservice.api.account.AccountAttributes +import org.whispersystems.signalservice.api.account.DeviceAttributes import org.whispersystems.signalservice.api.account.PreKeyCollection import org.whispersystems.signalservice.api.messages.multidevice.RegisterAsSecondaryDeviceResponse import org.whispersystems.signalservice.api.provisioning.RestoreMethod @@ -150,7 +151,7 @@ class RegistrationApi( * - 422: Request is invalid * - 429: Rate limited */ - fun registerAsSecondaryDevice(verificationCode: String, attributes: AccountAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?): NetworkResult { + fun registerAsSecondaryDevice(verificationCode: String, attributes: DeviceAttributes, aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?): NetworkResult { val request = RegisterAsSecondaryDeviceRequest( verificationCode = verificationCode, accountAttributes = attributes, diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 6262f3171d..d14dcb7ba1 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -398,10 +398,6 @@ public class PushServiceSocket { return makeServiceRequestWithoutValidation(path, "PUT", jsonRequestBody(JsonUtil.toJson(body)), NO_HEADERS, SealedSenderAccess.NONE, false); } - /** - * V2 API: Submits registration request and returns the raw Response for manual handling. - * Caller is responsible for closing the response. - */ /** * V2 API: Checks SVR2 auth credentials and returns the raw Response for manual handling. * Caller is responsible for closing the response. @@ -486,6 +482,18 @@ public class PushServiceSocket { return JsonUtil.fromJson(responseText, RegisterAsSecondaryDeviceResponse.class); } + /** + * V2 API: Registers as a secondary device, authenticating via basic auth built from the given {@code e164} and {@code password} + * rather than the socket's credentials provider + * Caller is responsible for closing the response. + */ + public Response registerAsSecondaryDevice(String e164, String password, RegisterAsSecondaryDeviceRequest request) throws IOException { + String authHeader = "Basic " + Base64.encodeWithPadding((e164 + ":" + password).getBytes("UTF-8")); + Map headers = Collections.singletonMap("Authorization", authHeader); + + return makeServiceRequestWithoutValidation("/v1/devices/link", "PUT", jsonRequestBody(JsonUtil.toJson(request)), headers, SealedSenderAccess.NONE, false); + } + public SendMessageResponse sendMessage(OutgoingPushMessageList bundle, @Nullable SealedSenderAccess sealedSenderAccess, boolean story) throws IOException { diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/RegisterAsSecondaryDeviceRequest.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/RegisterAsSecondaryDeviceRequest.kt index dd2615f66d..2ffdfca68c 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/RegisterAsSecondaryDeviceRequest.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/RegisterAsSecondaryDeviceRequest.kt @@ -7,12 +7,12 @@ package org.whispersystems.signalservice.internal.push import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty -import org.whispersystems.signalservice.api.account.AccountAttributes +import org.whispersystems.signalservice.api.account.DeviceAttributes import org.whispersystems.signalservice.api.push.SignedPreKeyEntity class RegisterAsSecondaryDeviceRequest @JsonCreator constructor( @JsonProperty val verificationCode: String, - @JsonProperty val accountAttributes: AccountAttributes, + @JsonProperty val accountAttributes: DeviceAttributes, @JsonProperty val aciSignedPreKey: SignedPreKeyEntity, @JsonProperty val pniSignedPreKey: SignedPreKeyEntity, @JsonProperty val aciPqLastResortPreKey: KyberPreKeyEntity,