Retry/resume link+sync backup upload improvements.

This commit is contained in:
Cody Henthorne
2026-07-20 16:34:45 -04:00
parent b9012e2820
commit 1be2aeaf2b
2 changed files with 149 additions and 36 deletions
@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.linkdevice
import android.net.Uri
import androidx.annotation.VisibleForTesting
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.Stopwatch
@@ -8,7 +9,6 @@ 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
import org.signal.core.util.logging.logI
import org.signal.core.util.logging.logW
import org.signal.core.util.toByteArray
@@ -33,6 +33,7 @@ import org.whispersystems.signalservice.api.link.TransferArchiveError
import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import java.io.File
import java.io.FileInputStream
@@ -384,22 +385,9 @@ object LinkDeviceRepository {
return LinkUploadArchiveResult.BackupCreationCancelled
}
Log.d(TAG, "[createAndUploadArchive] Fetching an upload form...")
val uploadForm = when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(tempBackupFile.length())) {
is RequestResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
is RequestResult.ApplicationError -> throw result.cause
is RequestResult.RetryableNetworkError -> return LinkUploadArchiveResult.NetworkError(result.networkError).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.networkError)
is RequestResult.NonSuccess -> return LinkUploadArchiveResult.BadRequest(result.error).logW(TAG, "[createAndUploadArchive] Upload too large when fetching form.", result.error)
}
if (cancellationSignal()) {
Log.i(TAG, "[createAndUploadArchive] Backup was cancelled.")
sendTransferArchiveError(deviceId, deviceRegistrationId, TransferArchiveError.RELINK_REQUESTED)
return LinkUploadArchiveResult.BackupCreationCancelled
}
when (val result = uploadArchive(tempBackupFile, uploadForm)) {
is NetworkResult.Success -> Log.i(TAG, "[createAndUploadArchive] Successfully uploaded backup.")
Log.d(TAG, "[createAndUploadArchive] Uploading the archive...")
val uploadedForm = when (val result = uploadArchive(tempBackupFile)) {
is NetworkResult.Success -> result.result.logI(TAG, "[createAndUploadArchive] Successfully uploaded backup.")
is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Network error when uploading archive.", result.exception)
is NetworkResult.StatusCodeError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Status code error when uploading archive.", result.exception)
is NetworkResult.ApplicationError -> throw result.throwable
@@ -417,8 +405,8 @@ object LinkDeviceRepository {
SignalNetwork.linkDevice.setTransferArchive(
destinationDeviceId = deviceId,
destinationDeviceRegistrationId = deviceRegistrationId,
cdn = uploadForm.cdn,
cdnKey = uploadForm.key
cdn = uploadedForm.cdn,
cdnKey = uploadedForm.key
)
}
@@ -440,37 +428,45 @@ object LinkDeviceRepository {
}
/**
* Handles uploading the archive for [createAndUploadArchive]. Handles resumable uploads and making multiple upload attempts.
* Fetches an upload form and uploads the archive for [createAndUploadArchive], resuming and retrying as needed.
*
* Returns the [AttachmentUploadForm] that was actually used, so the caller can point the linked device at the uploaded object. If the
* resume location becomes invalid, we drop the form so the next attempt fetches a fresh one (new CDN key) rather than re-creating the
* existing object, which the CDN rejects with a 409.
*/
private fun uploadArchive(backupFile: File, uploadForm: AttachmentUploadForm): NetworkResult<Unit> {
@VisibleForTesting
internal fun uploadArchive(backupFile: File): NetworkResult<AttachmentUploadForm> {
val checksumSha256 = FileInputStream(backupFile).use { AttachmentUploadUtil.computeRawChecksum(it) }
var uploadForm: AttachmentUploadForm? = null
var resumeUrl: String? = null
val uploadResult = NetworkResult.withRetry(
return NetworkResult.withRetry(
logAttempt = { attempt, maxAttempts -> Log.i(TAG, "Starting upload attempt ${attempt + 1}/$maxAttempts") }
) {
val form = uploadForm ?: when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(backupFile.length())) {
is RequestResult.Success -> result.result.also { uploadForm = it }
is RequestResult.RetryableNetworkError -> return@withRetry NetworkResult.NetworkError<Unit>(result.networkError)
is RequestResult.NonSuccess -> return@withRetry NetworkResult.NetworkError<Unit>(result.error)
is RequestResult.ApplicationError -> return@withRetry NetworkResult.ApplicationError<Unit>(result.cause)
}
FileInputStream(backupFile).use {
val result = SignalNetwork.archive.uploadBackupFile(
uploadForm = uploadForm,
SignalNetwork.archive.uploadBackupFile(
uploadForm = form,
data = it,
dataLength = backupFile.length(),
checksumSha256 = checksumSha256,
existingResumeUrl = resumeUrl,
onResumeUrlCreated = { url -> resumeUrl = url }
)
if (result !is NetworkResult.Success) {
resumeUrl = null
).also { result ->
if (result is NetworkResult.NetworkError && result.exception is ResumeLocationInvalidException) {
Log.w(TAG, "Resume location invalid; dropping the form so the retry fetches a fresh one with a new CDN key.")
uploadForm = null
resumeUrl = null
}
}
result
}
}
return when (uploadResult) {
is NetworkResult.Success -> uploadResult
is NetworkResult.NetworkError -> uploadResult.logW(TAG, "Network error while uploading.", uploadResult.exception)
is NetworkResult.StatusCodeError -> uploadResult.logW(TAG, "Status code error when uploading archive.", uploadResult.exception)
is NetworkResult.ApplicationError -> throw uploadResult.throwable
}
}.map { uploadForm!! }
}
/**
@@ -0,0 +1,117 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.linkdevice
import android.app.Application
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.network.NetworkResult
import org.signal.network.api.ArchiveApi
import org.signal.network.api.AttachmentApi
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testutil.SystemOutLogger
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import java.io.File
import java.io.IOException
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class LinkDeviceRepositoryTest {
private val attachments = mockk<AttachmentApi>()
private val archive = mockk<ArchiveApi>()
@Before
fun setUp() {
Log.initialize(SystemOutLogger())
mockkObject(SignalNetwork)
every { SignalNetwork.attachments } returns attachments
every { SignalNetwork.archive } returns archive
}
@After
fun tearDown() {
unmockkObject(SignalNetwork)
}
@Test
fun `uploadArchive - invalid resume location fetches a fresh form with a new key`() {
val firstForm = uploadForm(key = "key-1")
val secondForm = uploadForm(key = "key-2")
var fetchCount = 0
every { attachments.getAttachmentV4UploadForm(any()) } answers {
fetchCount++
RequestResult.Success(if (fetchCount == 1) firstForm else secondForm)
}
every {
archive.uploadBackupFile(uploadForm = firstForm, data = any(), dataLength = any(), checksumSha256 = any(), progressListener = any(), existingResumeUrl = any(), onResumeUrlCreated = any())
} returns NetworkResult.NetworkError(ResumeLocationInvalidException())
every {
archive.uploadBackupFile(uploadForm = secondForm, data = any(), dataLength = any(), checksumSha256 = any(), progressListener = any(), existingResumeUrl = any(), onResumeUrlCreated = any())
} returns NetworkResult.Success(Unit)
val result = LinkDeviceRepository.uploadArchive(tempBackupFile())
assertTrue(result is NetworkResult.Success)
assertEquals(secondForm, (result as NetworkResult.Success).result)
verify(exactly = 2) { attachments.getAttachmentV4UploadForm(any()) }
verify(exactly = 1) {
archive.uploadBackupFile(uploadForm = secondForm, data = any(), dataLength = any(), checksumSha256 = any(), progressListener = any(), existingResumeUrl = null, onResumeUrlCreated = any())
}
}
@Test
fun `uploadArchive - ordinary network error resumes with the same form and resume url`() {
val form = uploadForm(key = "key-1")
every { attachments.getAttachmentV4UploadForm(any()) } returns RequestResult.Success(form)
every {
archive.uploadBackupFile(uploadForm = form, data = any(), dataLength = any(), checksumSha256 = any(), progressListener = any(), existingResumeUrl = null, onResumeUrlCreated = any())
} answers {
lastArg<((String) -> Unit)?>()?.invoke("resume-1")
NetworkResult.NetworkError(IOException("flaky connection"))
}
every {
archive.uploadBackupFile(uploadForm = form, data = any(), dataLength = any(), checksumSha256 = any(), progressListener = any(), existingResumeUrl = "resume-1", onResumeUrlCreated = any())
} returns NetworkResult.Success(Unit)
val result = LinkDeviceRepository.uploadArchive(tempBackupFile())
assertTrue(result is NetworkResult.Success)
assertEquals(form, (result as NetworkResult.Success).result)
verify(exactly = 1) { attachments.getAttachmentV4UploadForm(any()) }
verify(exactly = 1) {
archive.uploadBackupFile(uploadForm = form, data = any(), dataLength = any(), checksumSha256 = any(), progressListener = any(), existingResumeUrl = "resume-1", onResumeUrlCreated = any())
}
}
private fun uploadForm(key: String): AttachmentUploadForm {
return AttachmentUploadForm(cdn = 3, key = key, headers = emptyMap(), signedUploadLocation = "https://example.com/$key")
}
private fun tempBackupFile(): File {
return File.createTempFile("link-archive-test", ".bin").apply {
writeBytes(ByteArray(64) { it.toByte() })
deleteOnExit()
}
}
}