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 a9f615084c..c407db29da 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 @@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.backup.ArchiveUploadProgress import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.RestoreState import org.thoughtcrime.securesms.backup.v2.BackupRepository.copyAttachmentToArchive +import org.thoughtcrime.securesms.backup.v2.BackupRepository.exportForDebugging import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter import org.thoughtcrime.securesms.backup.v2.processor.AccountDataArchiveProcessor import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallArchiveProcessor @@ -141,6 +142,7 @@ import org.whispersystems.signalservice.api.backup.MediaName import org.whispersystems.signalservice.api.backup.MediaRootBackupKey import org.whispersystems.signalservice.api.backup.MessageBackupKey import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil +import org.whispersystems.signalservice.api.link.TransferArchiveResponse import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener import org.whispersystems.signalservice.api.push.ServiceId.ACI @@ -1092,6 +1094,30 @@ object BackupRepository { } } + /** + * Imports a link and sync backup stored on the transit CDN. + * + * @param backupKey The key used to encrypt the backup. If `null`, we assume that the file is plaintext. + */ + fun importLinkAndSyncSignalBackup( + length: Long, + inputStreamFactory: () -> InputStream, + selfData: SelfData, + backupKey: MessageBackupKey, + cancellationSignal: () -> Boolean = { false } + ): ImportResult { + val frameReader = EncryptedBackupReader.createForLocalOrLinking( + key = backupKey, + aci = selfData.aci, + length = length, + dataStream = inputStreamFactory + ) + + return frameReader.use { reader -> + import(reader, selfData, cancellationSignal) + } + } + /** * Imports a backup that was exported via [exportForDebugging]. */ @@ -2078,6 +2104,82 @@ object BackupRepository { return RemoteRestoreResult.Success } + suspend fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey) { + val context = AppDependencies.application + SignalStore.backup.restoreState = RestoreState.PENDING + + try { + DataRestoreConstraint.isRestoringData = true + return withContext(Dispatchers.IO) { + return@withContext BackupProgressService.start(context, context.getString(R.string.BackupProgressService_title)).use { + restoreLinkAndSyncBackup(response, ephemeralBackupKey, controller = it, cancellationSignal = { !isActive }) + } + } + } finally { + DataRestoreConstraint.isRestoringData = false + } + } + + private fun restoreLinkAndSyncBackup(response: TransferArchiveResponse, ephemeralBackupKey: MessageBackupKey, controller: BackupProgressService.Controller, cancellationSignal: () -> Boolean): RemoteRestoreResult { + SignalStore.backup.restoreState = RestoreState.RESTORING_DB + + val progressListener = object : ProgressListener { + override fun onAttachmentProgress(progress: AttachmentTransferProgress) { + controller.update( + title = AppDependencies.application.getString(R.string.BackupProgressService_title_downloading), + progress = progress.value, + indeterminate = false + ) + EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress.transmitted, progress.total)) + } + + override fun shouldCancel() = cancellationSignal() + } + + Log.i(TAG, "[restoreLinkAndSyncBackup] Downloading backup") + val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) + when (val result = AppDependencies.signalServiceMessageReceiver.retrieveLinkAndSyncBackup(response.cdn, response.key, tempBackupFile, progressListener)) { + is NetworkResult.Success -> Log.i(TAG, "[restoreLinkAndSyncBackup] Download successful") + else -> { + Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to download backup file", result.getCause()) + return RemoteRestoreResult.NetworkError + } + } + + if (cancellationSignal()) { + return RemoteRestoreResult.Canceled + } + + controller.update( + title = AppDependencies.application.getString(R.string.BackupProgressService_title), + progress = 0f, + indeterminate = true + ) + + val self = Recipient.self() + val selfData = SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey)) + Log.i(TAG, "[restoreLinkAndSyncBackup] Importing backup") + val result = importLinkAndSyncSignalBackup( + length = tempBackupFile.length(), + inputStreamFactory = tempBackupFile::inputStream, + selfData = selfData, + backupKey = ephemeralBackupKey, + cancellationSignal = cancellationSignal + ) + + if (result == ImportResult.Failure) { + Log.w(TAG, "[restoreLinkAndSyncBackup] Failed to import backup") + return RemoteRestoreResult.Failure + } + + SignalStore.backup.restoreState = RestoreState.RESTORING_MEDIA + + AppDependencies.jobManager.add(BackupRestoreMediaJob()) + + Log.i(TAG, "[restoreLinkAndSyncBackup] Restore successful") + return RemoteRestoreResult.Success + } + private fun buildDebugInfo(): ByteString { if (!RemoteConfig.internalUser) { return ByteString.EMPTY diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveBackupIdReservationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveBackupIdReservationJob.kt index 084baede1f..d30bc3f7aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveBackupIdReservationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveBackupIdReservationJob.kt @@ -52,6 +52,11 @@ class ArchiveBackupIdReservationJob private constructor(parameters: Parameters) return Result.success() } + if (SignalStore.account.isLinkedDevice) { + Log.i(TAG, "Linked device. Skipping.") + return Result.success() + } + return when (val result = BackupRepository.triggerBackupIdReservation()) { is NetworkResult.Success -> Result.success() is NetworkResult.NetworkError -> Result.retry(defaultBackoff()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt index 3076216a84..d870e62db7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt @@ -80,6 +80,11 @@ class BackupMessagesJob private constructor( false } + SignalStore.account.isLinkedDevice -> { + Log.i(TAG, "Backup not allowed: linked device.") + false + } + else -> true } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt index b228a4518e..8037d2408b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt @@ -66,6 +66,11 @@ class PostRegistrationBackupRedemptionJob : CoroutineJob { return Result.success() } + if (SignalStore.account.isLinkedDevice) { + info("Linked device. Exiting.") + return Result.success() + } + if (SignalStore.backup.deletionState != DeletionState.NONE) { info("User is in the process of or has delete their backup. Exiting.") return Result.success() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt index c0f33f5168..499cd0562f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt @@ -12,6 +12,8 @@ import androidx.annotation.WorkerThread import androidx.core.app.NotificationManagerCompat import com.google.android.gms.auth.api.phone.SmsRetriever import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -40,6 +42,7 @@ import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob import org.thoughtcrime.securesms.jobs.RotateCertificateJob import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.net.SignalNetwork import org.thoughtcrime.securesms.notifications.NotificationIds import org.thoughtcrime.securesms.pin.Svr3Migration import org.thoughtcrime.securesms.pin.SvrRepository @@ -76,6 +79,7 @@ import org.whispersystems.signalservice.api.backup.MediaRootBackupKey import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess import org.whispersystems.signalservice.api.kbs.MasterKey import org.whispersystems.signalservice.api.kbs.PinHashUtil +import org.whispersystems.signalservice.api.link.TransferArchiveResponse import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI @@ -93,6 +97,8 @@ import java.util.Locale import java.util.Optional import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** @@ -253,6 +259,7 @@ object RegistrationRepository { // TODO [linked-device] May want to have a different opt out mechanism for linked devices SvrRepository.optOutOfPin() + SignalStore.account.isMultiDevice = true SignalStore.registration.hasUploadedProfile = true jobManager.runJobBlocking(RefreshOwnProfileJob(), 30.seconds) @@ -676,6 +683,51 @@ object RegistrationRepository { return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id) } + suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 60.seconds): TransferArchiveResponse? { + val startTime = System.currentTimeMillis() + var timeRemaining = maxWaitTime.inWholeMilliseconds + + while (timeRemaining > 0 && coroutineContext.isActive) { + Log.d(TAG, "[waitForLinkAndSyncBackupDetails] Willing to wait for $timeRemaining ms...") + + when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) { + is NetworkResult.Success -> { + Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Transfer archive data provided by primary") + return result.result + } + is NetworkResult.ApplicationError -> { + Log.e(TAG, "[waitForLinkAndSyncBackupDetails] Application error!", result.throwable) + throw result.throwable + } + is NetworkResult.NetworkError -> { + Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit a network error while waiting for linking. Will try to wait again.", result.exception) + } + is NetworkResult.StatusCodeError -> { + when (result.code) { + 400 -> { + Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Invalid timeout!") + return null + } + 429 -> { + Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit a rate-limit. Will try to wait again after delay: ${result.retryAfter()}.") + result.retryAfter()?.let { retryAfter -> + delay(retryAfter) + } + } + else -> { + Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit an unknown status code of ${result.code}. Will try to wait again.") + } + } + } + } + + timeRemaining = maxWaitTime.inWholeMilliseconds - (System.currentTimeMillis() - startTime) + } + + Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Failed to get transfer archive data from primary") + return null + } + fun interface MasterKeyProducer { @Throws(IOException::class, SvrWrongPinException::class, SvrNoDataException::class) fun produceMasterKey(): MasterKey diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt index 09c9e5d14e..d700b61d2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt @@ -85,6 +85,7 @@ import org.thoughtcrime.securesms.util.dualsim.MccMncProducer import org.whispersystems.signalservice.api.AccountEntropyPool import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.SvrNoDataException +import org.whispersystems.signalservice.api.backup.MessageBackupKey import org.whispersystems.signalservice.api.kbs.MasterKey import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage @@ -1134,6 +1135,19 @@ class RegistrationViewModel : ViewModel() { refreshRemoteConfig() + if (message.ephemeralBackupKey != null) { + Log.i(TAG, "Primary has given Linked device an ephemeral backup key, waiting for backup...") + val result = RegistrationRepository.waitForLinkAndSyncBackupDetails() + if (result != null) { + BackupRepository.restoreLinkAndSyncBackup(result, MessageBackupKey(message.ephemeralBackupKey!!.toByteArray())) + } else { + Log.w(TAG, "Unable to get transfer archive data, continuing with linking process") + } + + // TODO [linked-device] Reapply opt-out, backup restore sets pin, may want to have a different opt out mechanism for linked devices + SvrRepository.optOutOfPin() + } + for (type in SyncMessage.Request.Type.entries) { if (type == SyncMessage.Request.Type.UNKNOWN) { continue diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java index b16ca723e7..121b9879f9 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java @@ -7,7 +7,6 @@ package org.whispersystems.signalservice.api; import org.signal.core.util.StreamUtil; -import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.backup.MediaRootBackupKey; @@ -17,6 +16,7 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil; import org.whispersystems.signalservice.api.crypto.ProfileCipherInputStream; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifest; import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; @@ -38,6 +38,8 @@ import java.util.Map; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import kotlin.Unit; + /** * The primary interface for receiving Signal Service messages. * @@ -200,6 +202,16 @@ public class SignalServiceMessageReceiver { socket.retrieveBackup(cdnNumber, headers, cdnPath, destination, 1_000_000_000L, listener); } + /** + * Retrieves a link+sync backup file. The data is written to @{code destination}. + */ + public @Nonnull NetworkResult retrieveLinkAndSyncBackup(int cdn, @Nonnull String key, @Nonnull File destination, @Nullable ProgressListener listener) { + return NetworkResult.fromFetch(() -> { + socket.retrieveAttachment(cdn, Collections.emptyMap(), new SignalServiceAttachmentRemoteId.V4(key), destination, 1_000_000_000L, listener); + return Unit.INSTANCE; + }); + } + public @Nonnull ZonedDateTime getCdnLastModifiedTime(int cdnNumber, Map headers, String cdnPath) throws MissingConfigurationException, IOException { return socket.getCdnLastModifiedTime(cdnNumber, headers, cdnPath); } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt index dba3b59a47..fe2957d374 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/LinkDeviceApi.kt @@ -212,4 +212,27 @@ class LinkDeviceApi( val request = WebSocketRequestMessage.put("/v1/accounts/name?deviceId=$deviceId", SetDeviceNameRequest(encryptedDeviceName)) return NetworkResult.fromWebSocketRequest(authWebSocket, request) } + + /** + * A "long-polling" endpoint that will return once the primary device has successfully sent sync data. + * + * @param timeout The max amount of time to wait. Capped at 30 seconds. + * + * GET /v1/devices/transfer_archive?timeout=[timeout] + * + * - 200: Success, the primary device was sent backup sync data. + * - 204: The primary didn't provide data before the max waiting time elapsed. + * - 400: Invalid timeout. + * - 429: Rate-limited. + */ + fun waitForPrimaryDevice(timeout: Duration = 30.seconds): NetworkResult { + val request = WebSocketRequestMessage.get("/v1/devices/transfer_archive?timeout=${timeout.inWholeSeconds}") + return NetworkResult + .fromWebSocketRequest( + signalWebSocket = authWebSocket, + request = request, + timeout = timeout, + webSocketResponseConverter = NetworkResult.LongPollingWebSocketConverter(TransferArchiveResponse::class) + ) + } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/TransferArchiveResponse.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/TransferArchiveResponse.kt new file mode 100644 index 0000000000..07edf5afc3 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/link/TransferArchiveResponse.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.link + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Data from primary on where to find link+sync backup file. + */ +data class TransferArchiveResponse @JsonCreator constructor( + @JsonProperty val cdn: Int, + @JsonProperty val key: String +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt index 3e9fde064d..be67a119ff 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/provisioning/ProvisioningSocket.kt @@ -249,7 +249,7 @@ class ProvisioningSocket private constructor( private fun generateProvisioningUrl(deviceAddress: String): String { val encodedDeviceId = URLEncoder.encode(deviceAddress, "UTF-8") val encodedPubKey: String = URLEncoder.encode(Base64.encodeWithoutPadding(cipher.secondaryDevicePublicKey.serialize()), "UTF-8") - return "sgnl://${mode.host}?uuid=$encodedDeviceId&pub_key=$encodedPubKey" + return "sgnl://${mode.host}?uuid=$encodedDeviceId&pub_key=$encodedPubKey${mode.params}" } private suspend fun keepAlive(webSocket: WebSocket) { @@ -288,9 +288,9 @@ class ProvisioningSocket private constructor( } } - enum class Mode(val host: String) { - REREG("rereg"), - LINK("linkdevice") + enum class Mode(val host: String, val params: String) { + REREG("rereg", ""), + LINK("linkdevice", "&capabilities=backup4") } fun interface ProvisioningSocketExceptionHandler {