mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-04 20:34:14 +01:00
Retry/resume link+sync backup upload improvements.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user