From 1be2aeaf2b800e3e1f33c3a5a391e85e37fd771c Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Mon, 20 Jul 2026 16:34:45 -0400 Subject: [PATCH] Retry/resume link+sync backup upload improvements. --- .../linkdevice/LinkDeviceRepository.kt | 68 +++++----- .../linkdevice/LinkDeviceRepositoryTest.kt | 117 ++++++++++++++++++ 2 files changed, 149 insertions(+), 36 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepositoryTest.kt 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 875ff5f516..e5ad8ccd3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepository.kt @@ -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 { + @VisibleForTesting + internal fun uploadArchive(backupFile: File): NetworkResult { 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(result.networkError) + is RequestResult.NonSuccess -> return@withRetry NetworkResult.NetworkError(result.error) + is RequestResult.ApplicationError -> return@withRetry NetworkResult.ApplicationError(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!! } } /** diff --git a/app/src/test/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepositoryTest.kt new file mode 100644 index 0000000000..fb9eaab574 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceRepositoryTest.kt @@ -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() + private val archive = mockk() + + @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() + } + } +}