mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-20 19:18:37 +00:00
Add rudimentary link+sync support.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -80,6 +80,11 @@ class BackupMessagesJob private constructor(
|
||||
false
|
||||
}
|
||||
|
||||
SignalStore.account.isLinkedDevice -> {
|
||||
Log.i(TAG, "Backup not allowed: linked device.")
|
||||
false
|
||||
}
|
||||
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Unit> 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<String, String> headers, String cdnPath) throws MissingConfigurationException, IOException {
|
||||
return socket.getCdnLastModifiedTime(cdnNumber, headers, cdnPath);
|
||||
}
|
||||
|
||||
@@ -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<TransferArchiveResponse> {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -249,7 +249,7 @@ class ProvisioningSocket<T> 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<T> 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 {
|
||||
|
||||
Reference in New Issue
Block a user