Refactor archive requests into ArchiveService.

This commit is contained in:
Greyson Parrelli
2026-08-01 00:11:30 -04:00
committed by Alex Hart
parent 1911bf7df7
commit 34648c2c04
45 changed files with 3171 additions and 1692 deletions
@@ -9,7 +9,9 @@ import org.signal.core.util.billing.BillingApi
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.network.api.ArchiveApi
import org.signal.network.api.ArchiveApiV2
import org.signal.network.config.SignalServiceConfiguration
import org.signal.network.service.ArchiveService
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
@@ -37,6 +39,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
private var signalServiceMessageSender: SignalServiceMessageSender? = null
private var billingApi: BillingApi = mockk()
private var accountApi: AccountApi = mockk()
private var archiveService: ArchiveService = mockk(relaxed = true)
init {
recipientCache = LiveRecipientCache(application) { r -> r.run() }
@@ -50,10 +53,16 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return recipientCache
}
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
override fun provideArchiveApi(pushServiceSocket: PushServiceSocket): ArchiveApi {
return mockk()
}
override fun provideArchiveApiV2(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApiV2 {
return mockk()
}
override fun provideArchiveService(archiveApi: ArchiveApiV2): ArchiveService = archiveService
/**
* Adds the Stripe-matching [ResponderInterceptor] on top of the default client (which supplies the
* user agent + DNS), so `api.stripe.com` requests made by [org.signal.donations.StripeApi] are
@@ -6,11 +6,13 @@
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import arrow.core.right
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockkObject
@@ -24,7 +26,8 @@ import org.junit.runner.RunWith
import org.signal.core.models.backup.MediaName
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.network.NetworkResult
import org.signal.network.api.ArchiveApiV2
import org.signal.network.service.ArchiveService
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -33,12 +36,11 @@ import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.MediaEntry
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.io.ByteArrayInputStream
@@ -52,6 +54,8 @@ class ArchiveAttachmentReconciliationJobTest {
@get:Rule
val harness = SignalActivityRule()
private val archiveService: ArchiveService = AppDependencies.archiveService
@Before
fun setUp() {
SignalStore.backup.backupTier = MessageBackupTier.PAID
@@ -61,7 +65,7 @@ class ArchiveAttachmentReconciliationJobTest {
mockkObject(BackupRepository)
mockkObject(ArchiveCommitAttachmentDeletesJob)
every { ArchiveCommitAttachmentDeletesJob.deleteMediaObjectsFromCdn(any(), any(), any(), any()) } returns null
coEvery { ArchiveCommitAttachmentDeletesJob.deleteMediaObjectsFromCdn(any(), any(), any(), any()) } returns null
}
@After
@@ -221,20 +225,14 @@ class ArchiveAttachmentReconciliationJobTest {
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(
storedMediaObjects = listOf(StoredMediaObject(cdn = cdn, mediaId = mediaId, objectLength = attachment.size)),
backupDir = null,
mediaDir = null,
cursor = null
)
)
coEvery { archiveService.listRemoteMediaObjects(any(), any()) } returns ArchiveApiV2.MediaItemsPage(
storedMediaObjects = listOf(ArchiveApiV2.StoredMediaObject(cdn = cdn, mediaId = mediaId, objectLength = attachment.size)),
cursor = null
).right()
}
private fun fakeCdnEmpty() {
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(storedMediaObjects = emptyList(), backupDir = null, mediaDir = null, cursor = null)
)
coEvery { archiveService.listRemoteMediaObjects(any(), any()) } returns ArchiveApiV2.MediaItemsPage(storedMediaObjects = emptyList(), cursor = null).right()
}
private fun createIncomingMessage(serverTime: Duration, attachment: Attachment): IncomingMessage {
@@ -5,11 +5,15 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.left
import arrow.core.right
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
@@ -21,8 +25,9 @@ import org.junit.Rule
import org.junit.Test
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.service.ArchiveError
import org.signal.network.service.ArchiveService
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.DeletionState
@@ -30,6 +35,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Flag
@@ -49,12 +55,14 @@ class BackupDeleteJobTest {
@get:Rule
val harness = SignalActivityRule()
private val archiveService: ArchiveService = AppDependencies.archiveService
@Before
fun setUp() {
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } returns NetworkResult.Success(MessageBackupTier.PAID)
every { BackupRepository.deleteBackup() } returns NetworkResult.Success(Unit)
every { BackupRepository.deleteMediaBackup() } returns NetworkResult.Success(Unit)
every { BackupRepository.getBackupTier() } returns MessageBackupTier.PAID.right()
coEvery { archiveService.deleteMessageBackup() } returns Unit.right()
coEvery { archiveService.deleteMediaBackup() } returns Unit.right()
}
@After
@@ -193,9 +201,11 @@ class BackupDeleteJobTest {
val result = job.run()
coVerify {
archiveService.deleteMessageBackup()
archiveService.deleteMediaBackup()
}
verify {
BackupRepository.deleteBackup()
BackupRepository.deleteMediaBackup()
BackupRepository.resetInitializedStateAndAuthCredentials()
}
@@ -205,7 +215,7 @@ class BackupDeleteJobTest {
@Test
fun givenNetworkErrorDuringMessageBackupDeletion_whenIRun_thenIExpectRetry() {
every { BackupRepository.deleteBackup() } returns NetworkResult.NetworkError(IOException())
coEvery { archiveService.deleteMessageBackup() } returns ArchiveError.NetworkError(IOException()).left()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -218,7 +228,7 @@ class BackupDeleteJobTest {
@Test
fun givenNetworkErrorDuringMediaBackupDeletion_whenIRun_thenIExpectRetry() {
every { BackupRepository.deleteMediaBackup() } returns NetworkResult.NetworkError(IOException())
coEvery { archiveService.deleteMediaBackup() } returns ArchiveError.NetworkError(IOException()).left()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -231,7 +241,7 @@ class BackupDeleteJobTest {
@Test
fun givenRateLimitedDuringMessageBackupDeletion_whenIRun_thenIExpectRetry() {
every { BackupRepository.deleteBackup() } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(429))
coEvery { archiveService.deleteMessageBackup() } returns ArchiveError.CredentialError.RateLimited(null, NonSuccessfulResponseCodeException(429)).left()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -244,7 +254,7 @@ class BackupDeleteJobTest {
@Test
fun givenRateLimitedDuringMediaBackupDeletion_whenIRun_thenIExpectRetry() {
every { BackupRepository.deleteMediaBackup() } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(429))
coEvery { archiveService.deleteMediaBackup() } returns ArchiveError.CredentialError.RateLimited(null, NonSuccessfulResponseCodeException(429)).left()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -6,6 +6,9 @@
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import assertk.assertThat
import assertk.assertions.isFalse
import assertk.assertions.isTrue
@@ -29,6 +32,7 @@ import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -91,23 +95,9 @@ class BackupSubscriptionCheckJobTest {
every { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } returns Unit
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } answers {
val tier = SignalStore.backup.backupTier
if (tier != null) {
NetworkResult.Success(tier)
} else {
NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(404))
}
}
every { BackupRepository.getBackupTier() } answers { currentTierResult() }
every { BackupRepository.getBackupTierWithoutDowngrade() } answers {
val tier = SignalStore.backup.backupTier
if (tier != null) {
NetworkResult.Success(tier)
} else {
NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(404))
}
}
every { BackupRepository.getBackupTierWithoutDowngrade() } answers { currentTierResult() }
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
@@ -464,7 +454,7 @@ class BackupSubscriptionCheckJobTest {
// Set up mismatched state: local tier is PAID but ZK tier is FREE
SignalStore.backup.backupTier = MessageBackupTier.PAID
every { BackupRepository.getBackupTierWithoutDowngrade() } returns NetworkResult.Success(MessageBackupTier.FREE)
every { BackupRepository.getBackupTierWithoutDowngrade() } returns MessageBackupTier.FREE.right()
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
val job = BackupSubscriptionCheckJob.create()
@@ -485,7 +475,7 @@ class BackupSubscriptionCheckJobTest {
// Set up synced state: both local and ZK tiers are PAID
SignalStore.backup.backupTier = MessageBackupTier.PAID
every { BackupRepository.getBackupTierWithoutDowngrade() } returns NetworkResult.Success(MessageBackupTier.PAID)
every { BackupRepository.getBackupTierWithoutDowngrade() } returns MessageBackupTier.PAID.right()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
@@ -504,7 +494,7 @@ class BackupSubscriptionCheckJobTest {
SignalStore.backup.backupTier = MessageBackupTier.PAID
// ZK credential fetch fails, should trigger refresh
every { BackupRepository.getBackupTierWithoutDowngrade() } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(500))
every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.NetworkError(IOException("Server error: 500")).left()
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
val job = BackupSubscriptionCheckJob.create()
@@ -669,4 +659,8 @@ class BackupSubscriptionCheckJobTest {
isAutoRenewing = false // Not auto-renewing means canceled
)
}
private fun currentTierResult(): Either<ArchiveError.CredentialError, MessageBackupTier> {
return SignalStore.backup.backupTier?.right() ?: ArchiveError.CredentialError.NotFound(NonSuccessfulResponseCodeException(404)).left()
}
}
@@ -6,9 +6,11 @@
package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.coEvery
import io.mockk.every
import org.json.JSONObject
import org.junit.rules.ExternalResource
import org.signal.libsignal.net.RequestResult
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
@@ -94,8 +96,8 @@ class InAppPaymentsRule : ExternalResource() {
}
private fun initialiseSetArchiveBackupId() {
AppDependencies.archiveApi.apply {
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
AppDependencies.archiveApiV2.apply {
coEvery { triggerBackupIdReservation(any(), any(), any()) } returns RequestResult.Success(Unit)
}
}
File diff suppressed because it is too large Load Diff
@@ -6,14 +6,17 @@
package org.thoughtcrime.securesms.backup.v2
import android.text.TextUtils
import kotlinx.coroutines.runBlocking
import org.signal.core.models.backup.MediaName
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decodeBase64
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.core.util.Util
import org.signal.network.service.successOrThrow
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.InvalidAttachmentException
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
@@ -110,7 +113,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
return try {
val (remoteId, cdnNumber) = if (useArchiveCdn) {
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
val mediaCdnPath = BackupRepository.getArchivedMediaCdnPath().successOrThrow()
val mediaCdnPath = runBlocking { AppDependencies.archiveService.getArchivedMediaCdnPath() }.successOrThrow()
val id = SignalServiceAttachmentRemoteId.Backup(
mediaCdnPath = mediaCdnPath,
@@ -166,7 +169,7 @@ fun DatabaseAttachment.createArchiveThumbnailPointer(): SignalServiceAttachmentP
}
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
val mediaCdnPath = BackupRepository.getArchivedMediaCdnPath().successOrThrow()
val mediaCdnPath = runBlocking { AppDependencies.archiveService.getArchivedMediaCdnPath() }.successOrThrow()
return try {
val key = mediaRootBackupKey.deriveThumbnailTransitKey(requireThumbnailMediaName())
val mediaId = mediaRootBackupKey.deriveMediaId(requireThumbnailMediaName()).encode()
@@ -0,0 +1,93 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.logging.Log
import org.signal.network.service.ArchiveCacheStore
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import kotlin.time.Duration
/**
* Backs [ArchiveCacheStore] with [SignalStore], which is where all of this state lived before [org.signal.network.service.ArchiveService] existed.
*/
object SignalStoreArchiveCacheStore : ArchiveCacheStore {
private val TAG = Log.tag(SignalStoreArchiveCacheStore::class)
override val aci: ACI
get() = SignalStore.account.requireAci()
override val messageBackupKey: MessageBackupKey
get() = SignalStore.backup.messageBackupKey
override val mediaRootBackupKey: MediaRootBackupKey
get() = SignalStore.backup.mediaRootBackupKey
override val messageCredentials: ArchiveCacheStore.CredentialCache
get() = CredentialCache(SignalStore.backup.messageCredentials)
override val mediaCredentials: ArchiveCacheStore.CredentialCache
get() = CredentialCache(SignalStore.backup.mediaCredentials)
override var backupsInitialized: Boolean
get() = SignalStore.backup.backupsInitialized
set(value) {
SignalStore.backup.backupsInitialized = value
}
override var cachedMediaCdnPath: String?
get() = SignalStore.backup.cachedMediaCdnPath
set(value) {
SignalStore.backup.cachedMediaCdnPath = value
}
override val isLinkedDevice: Boolean
get() = SignalStore.account.isLinkedDevice
override val isPreRestoreDuringRegistration: Boolean
get() = !SignalStore.registration.isRegistrationComplete && SignalStore.registration.restoreDecisionState.isDecisionPending
override fun onNotEntitled() {
if (SignalStore.backup.backupTierInternalOverride != null) {
Log.w(TAG, "Received status 403, but the internal override is set, so not doing anything.")
return
}
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.")
if (SignalStore.backup.backupTier == MessageBackupTier.PAID) {
Log.w(TAG, "Local device thought it was on PAID tier. Downgrading to FREE tier.")
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.backup.backupExpiredAndDowngraded = true
BackupRepository.scheduleSyncForAccountChange()
}
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
}
private class CredentialCache(private val store: BackupValues.CredentialStore) : ArchiveCacheStore.CredentialCache {
override fun getForTime(currentTime: Duration): ArchiveServiceCredential? = store.byDay.getForCurrentTime(currentTime)
override fun add(credentials: List<ArchiveServiceCredential>) = store.add(credentials)
override fun clearOlderThan(startOfDayInSeconds: Long) = store.clearOlderThan(startOfDayInSeconds)
override fun clearAll() = store.clearAll()
override var cdnReadCredentials: GetArchiveCdnCredentialsResponse?
get() = store.cdnReadCredentials
set(value) {
store.cdnReadCredentials = value
}
}
}
@@ -85,19 +85,18 @@ class MessageBackupsFlowViewModel(
}
viewModelScope.launch {
val result = withContext(SignalDispatchers.IO) {
BackupRepository.triggerBackupIdReservation()
}
result.runIfSuccessful {
Log.d(TAG, "Successfully triggered backup id reservation.")
internalStateFlow.update { it.copy(paymentReadyState = MessageBackupsFlowState.PaymentReadyState.READY) }
}
result.runOnStatusCodeError { code ->
Log.w(TAG, "Failed to trigger backup id reservation. ($code)")
internalStateFlow.update { it.copy(paymentReadyState = MessageBackupsFlowState.PaymentReadyState.FAILED) }
}
AppDependencies.archiveService
.triggerBackupIdReservation()
.onRight {
Log.d(TAG, "Successfully triggered backup id reservation.")
internalStateFlow.update { it.copy(paymentReadyState = MessageBackupsFlowState.PaymentReadyState.READY) }
}
.onLeft { error ->
if (error.isServerRejection) {
Log.w(TAG, "Failed to trigger backup id reservation. (${error::class.simpleName})")
internalStateFlow.update { it.copy(paymentReadyState = MessageBackupsFlowState.PaymentReadyState.FAILED) }
}
}
}
viewModelScope.launch {
@@ -16,7 +16,6 @@ import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -74,16 +73,14 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
fun getKeyRotationLimit() {
viewModelScope.launch(SignalDispatchers.IO) {
val result = BackupRepository.getKeyRotationLimit()
if (result is NetworkResult.Success) {
internalUiState.update {
it.copy(
canRotateKey = result.result.hasPermitsRemaining ?: true
)
AppDependencies.archiveService
.getKeyRotationLimit()
.onRight { limit ->
internalUiState.update { it.copy(canRotateKey = limit.hasPermitsRemaining ?: true) }
}
.onLeft { error ->
Log.w(TAG, "Error while getting rotation limit: ${error::class.simpleName}. Default to allowing key rotations.")
}
} else {
Log.w(TAG, "Error while getting rotation limit: $result. Default to allowing key rotations.")
}
}
}
@@ -276,13 +276,15 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
fun getKeyRotationLimit() {
viewModelScope.launch(SignalDispatchers.IO) {
val result = BackupRepository.getKeyRotationLimit()
val canRotateKey = if (result is NetworkResult.Success) {
result.result.hasPermitsRemaining!!
} else {
Log.w(TAG, "Error while getting rotation limit: $result. Default to allowing key rotations.")
true
}
val canRotateKey = AppDependencies.archiveService
.getKeyRotationLimit()
.fold(
ifRight = { it.hasPermitsRemaining!! },
ifLeft = { error ->
Log.w(TAG, "Error while getting rotation limit: ${error::class.simpleName}. Default to allowing key rotations.")
true
}
)
if (!canRotateKey) {
requestDialog(RemoteBackupsSettingsState.Dialog.KEY_ROTATION_LIMIT_REACHED)
@@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.Either
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
@@ -22,6 +23,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.signal.archive.stream.EncryptedBackupReader
import org.signal.archive.stream.EncryptedBackupReader.Companion.MAC_SIZE
@@ -38,14 +40,14 @@ import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.roundedString
import org.signal.core.util.stream.LimitedInputStream
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.NetworkResult
import org.signal.network.api.SvrBApi
import org.signal.network.service.ArchiveError
import org.signal.network.service.ArchiveService.DebugBackupMetadata
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.LocalExportProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.DebugBackupMetadata
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -209,10 +211,10 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = BackupRepository.downloadBackupFile(tempBackupFile)) {
is NetworkResult.Success -> Log.i(TAG, "Download successful")
else -> {
Log.w(TAG, "Failed to download backup file", result.getCause())
throw IOException(result.getCause())
is Either.Right -> Log.i(TAG, "Download successful")
is Either.Left -> {
Log.w(TAG, "Failed to download backup file", result.value.cause)
throw IOException("Failed to download backup file: ${result.value}", result.value.cause)
}
}
@@ -221,9 +223,9 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
throw IOException("Failed to read forward secrecy metadata!")
}
val svrBAuth = when (val result = BackupRepository.getSvrBAuth()) {
is NetworkResult.Success -> result.result
else -> throw IOException("Failed to read forward secrecy metadata!")
val svrBAuth = when (val result = runBlocking { AppDependencies.archiveService.getSvrBAuth() }) {
is Either.Right -> result.value
is Either.Left -> throw IOException("Failed to read forward secrecy metadata!")
}
val forwardSecrecyToken = when (val result = SignalNetwork.svrB.restore(svrBAuth, SignalStore.backup.messageBackupKey, forwardSecrecyMetadata)) {
@@ -261,23 +263,24 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
disposables += Single
.fromCallable {
BackupRepository.restoreBackupFileTimestamp()
BackupRepository.debugGetRemoteBackupState()
runBlocking { AppDependencies.archiveService.debugGetRemoteBackupState() }
}
.subscribeOn(Schedulers.io())
.subscribe { result ->
when {
result is NetworkResult.Success -> {
result is Either.Right -> {
val metadata = result.value
_state.value = _state.value.copy(
statusMessage = "Remote backup exists. ${result.result.mediaCount} media items, using ${result.result.usedSpace} bytes (${result.result.usedSpace.bytes.inMebiBytes.roundedString(3)} MiB)"
statusMessage = "Remote backup exists. ${metadata.mediaCount} media items, using ${metadata.usedSpace} bytes (${metadata.usedSpace.bytes.inMebiBytes.roundedString(3)} MiB)"
)
}
result is NetworkResult.StatusCodeError && result.code == 404 -> {
result is Either.Left && result.value is ArchiveError.CredentialError.NotFound -> {
_state.value = _state.value.copy(statusMessage = "Remote backup does not exists.")
}
else -> {
Log.w(TAG, "Error checking remote backup state", result.getCause())
Log.w(TAG, "Error checking remote backup state: ${(result as Either.Left).value}", result.value.cause)
_state.value = _state.value.copy(statusMessage = "Failed to fetch remote backup state.")
}
}
@@ -365,9 +368,9 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
launch {
statsState.update { it.copy(loadingRemoteStats = true) }
val (remoteState: DebugBackupMetadata?, errorMsg: String?) = when (val result = BackupRepository.debugGetRemoteBackupState()) {
is NetworkResult.Success -> result.result to null
else -> null to result.toString()
val (remoteState: DebugBackupMetadata?, errorMsg: String?) = when (val result = AppDependencies.archiveService.debugGetRemoteBackupState()) {
is Either.Right -> result.value to null
is Either.Left -> null to result.value.toString()
}
statsState.update { it.copy(remoteState = remoteState, remoteFailureMsg = errorMsg, loadingRemoteStats = false) }
}
@@ -376,15 +379,15 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
suspend fun deleteRemoteBackupData(): Boolean = withContext(Dispatchers.IO) {
when (val result = BackupRepository.debugDeleteAllArchivedMedia()) {
is NetworkResult.Success -> Log.i(TAG, "Remote data deleted")
else -> {
Log.w(TAG, "Unable to delete media", result.getCause())
is Either.Right -> Log.i(TAG, "Remote data deleted")
is Either.Left -> {
Log.w(TAG, "Unable to delete media", result.value.cause)
return@withContext false
}
}
when (val result = BackupRepository.deleteBackup()) {
is NetworkResult.Success -> {
when (val result = AppDependencies.archiveService.deleteMessageBackup()) {
is Either.Right -> {
SignalStore.backup.backupsInitialized = false
SignalStore.backup.messageCredentials.clearAll()
SignalStore.backup.mediaCredentials.clearAll()
@@ -392,7 +395,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
return@withContext true
}
else -> Log.w(TAG, "Unable to delete remote data", result.getCause())
is Either.Left -> Log.w(TAG, "Unable to delete remote data", result.value.cause)
}
return@withContext false
@@ -21,6 +21,7 @@ import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.mediasend.MediaSendDependencies
import org.signal.network.api.ArchiveApi
import org.signal.network.api.ArchiveApiV2
import org.signal.network.api.AttachmentApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
@@ -38,6 +39,7 @@ import org.signal.network.api.UsernameApi
import org.signal.network.config.HttpProxy
import org.signal.network.config.SignalServiceConfiguration
import org.signal.network.rest.SignalRestClient
import org.signal.network.service.ArchiveService
import org.signal.network.service.MessageService
import org.signal.video.exo.ExoPlayerPool
import org.thoughtcrime.securesms.BuildConfig
@@ -351,6 +353,14 @@ object AppDependencies {
val archiveApi: ArchiveApi
get() = networkModule.archiveApi
@JvmStatic
val archiveApiV2: ArchiveApiV2
get() = networkModule.archiveApiV2
@JvmStatic
val archiveService: ArchiveService
get() = networkModule.archiveService
@JvmStatic
val keysApi: KeysApi
get() = networkModule.keysApi
@@ -470,6 +480,8 @@ object AppDependencies {
fun provideSignalServiceAccountManager(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, accountApi: AccountApi, pushServiceSocket: PushServiceSocket, groupsV2Operations: GroupsV2Operations): SignalServiceAccountManager
fun provideSignalServiceMessageSender(protocolStore: SignalServiceDataStore, pushServiceSocket: PushServiceSocket, messageApi: MessageApi, keysApi: KeysApi): SignalServiceMessageSender
fun provideMessageService(protocolStore: SignalServiceDataStore, messageApiV2: MessageApiV2, keysApiV2: KeysApiV2): MessageService
fun provideArchiveApiV2(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApiV2
fun provideArchiveService(archiveApi: ArchiveApiV2): ArchiveService
fun provideSignalServiceMessageReceiver(pushServiceSocket: PushServiceSocket): SignalServiceMessageReceiver
fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess
fun provideRecipientCache(): LiveRecipientCache
@@ -508,7 +520,7 @@ object AppDependencies {
fun providePinnedMessageManager(): PinnedMessageManager
fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network
fun provideBillingApi(): BillingApi
fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi
fun provideArchiveApi(pushServiceSocket: PushServiceSocket): ArchiveApi
fun provideKeysApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): KeysApi
fun provideAttachmentApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi
fun provideLinkDeviceApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): LinkDeviceApi
@@ -32,6 +32,7 @@ import org.signal.libsignal.zkgroup.ServerPublicParams;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.network.api.ArchiveApi;
import org.signal.network.api.ArchiveApiV2;
import org.signal.network.api.AttachmentApi;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
@@ -47,8 +48,10 @@ import org.signal.network.api.RemoteConfigApi;
import org.signal.network.api.SvrBApi;
import org.signal.network.api.UsernameApi;
import org.signal.network.rest.SignalRestClient;
import org.signal.network.service.ArchiveService;
import org.signal.network.service.MessageService;
import org.signal.video.exo.ExoPlayerPool;
import org.thoughtcrime.securesms.backup.v2.SignalStoreArchiveCacheStore;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender;
@@ -558,14 +561,24 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
}
@Override
public @NonNull ArchiveApi provideArchiveApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull PushServiceSocket pushServiceSocket, @NonNull SignalServiceConfiguration signalServiceConfiguration) {
public @NonNull ArchiveApiV2 provideArchiveApiV2(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull SignalServiceConfiguration signalServiceConfiguration) {
try {
return new ArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket, new GenericServerPublicParams(signalServiceConfiguration.getBackupServerPublicParams()));
return new ArchiveApiV2(authWebSocket, unauthWebSocket, new GenericServerPublicParams(signalServiceConfiguration.getBackupServerPublicParams()));
} catch (InvalidInputException e) {
throw new RuntimeException(e);
}
}
@Override
public @NonNull ArchiveService provideArchiveService(@NonNull ArchiveApiV2 archiveApi) {
return new ArchiveService(archiveApi, SignalStoreArchiveCacheStore.INSTANCE);
}
@Override
public @NonNull ArchiveApi provideArchiveApi(@NonNull PushServiceSocket pushServiceSocket) {
return new ArchiveApi(pushServiceSocket);
}
@Override
public @NonNull KeysApi provideKeysApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket) {
return new KeysApi(authWebSocket, unauthWebSocket);
@@ -17,6 +17,7 @@ import org.signal.core.util.resettableLazy
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.network.api.ArchiveApi
import org.signal.network.api.ArchiveApiV2
import org.signal.network.api.AttachmentApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
@@ -33,6 +34,7 @@ import org.signal.network.api.SvrBApi
import org.signal.network.api.UsernameApi
import org.signal.network.config.TrustStore
import org.signal.network.rest.SignalRestClient
import org.signal.network.service.ArchiveService
import org.signal.network.service.MessageService
import org.signal.network.util.Tls12SocketFactory
import org.signal.network.util.TlsProxySocketFactory
@@ -164,9 +166,13 @@ class NetworkDependenciesModule(
}
val archiveApi: ArchiveApi by lazy {
provider.provideArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket, signalServiceNetworkAccess.getConfiguration())
provider.provideArchiveApi(pushServiceSocket)
}
val archiveApiV2: ArchiveApiV2 by lazy { provider.provideArchiveApiV2(authWebSocket, unauthWebSocket, signalServiceNetworkAccess.getConfiguration()) }
val archiveService: ArchiveService by lazy { provider.provideArchiveService(archiveApiV2) }
val keysApi: KeysApi by lazy {
provider.provideKeysApi(authWebSocket, unauthWebSocket)
}
@@ -13,6 +13,7 @@ import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import arrow.core.Either
import org.signal.core.models.backup.MediaId
import org.signal.core.util.Base64.decodeBase64
import org.signal.core.util.EventTimer
@@ -21,14 +22,15 @@ import org.signal.core.util.Stopwatch
import org.signal.core.util.forEach
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
import org.signal.network.NetworkResult
import org.signal.network.api.ArchiveApiV2
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.protos.ArchiveAttachmentReconciliationJobData
@@ -38,7 +40,6 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.wallpaper.WallpaperStorage
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
@@ -58,7 +59,7 @@ class ArchiveAttachmentReconciliationJob private constructor(
private var serverCursor: String?,
private val forced: Boolean,
parameters: Parameters
) : Job(parameters) {
) : CoroutineJob(parameters) {
companion object {
@@ -118,7 +119,7 @@ class ArchiveAttachmentReconciliationJob private constructor(
override fun getFactoryKey(): String = KEY
override fun run(): Result {
override suspend fun doRun(): Result {
if (!SignalStore.backup.hasBackupBeenUploaded) {
Log.w(TAG, "No backup has been uploaded yet! Skipping.")
return Result.success()
@@ -181,7 +182,7 @@ class ArchiveAttachmentReconciliationJob private constructor(
* (2) We ensure that our local store has the correct CDN for any attachments on the CDN (they should only really fall out of sync when you restore a backup
* that was made before all of the attachments had been uploaded).
*/
private fun syncDataFromCdn(snapshotVersion: Long): Result? {
private suspend fun syncDataFromCdn(snapshotVersion: Long): Result? {
val stopwatch = Stopwatch("sync")
val eventTimer = EventTimer()
val pendingRemoteDeletes: MutableSet<ArchivedMediaObject> = mutableSetOf()
@@ -337,7 +338,7 @@ class ArchiveAttachmentReconciliationJob private constructor(
*
* @return A list of media objects that should be deleted (after being verified)
*/
private fun syncCdnPage(archivedItemPage: ArchiveGetMediaItemsResponse, currentSnapshotVersion: Long): Set<ArchivedMediaObject> {
private fun syncCdnPage(archivedItemPage: ArchiveApiV2.MediaItemsPage, currentSnapshotVersion: Long): Set<ArchivedMediaObject> {
val mediaObjects = archivedItemPage.storedMediaObjects.map {
ArchivedMediaObject(
mediaId = it.mediaId,
@@ -375,25 +376,32 @@ class ArchiveAttachmentReconciliationJob private constructor(
* Fetches a page of archived media items from the CDN.
*
* @param cursor The cursor to use for pagination, or null to start from the beginning.
* @return The [ArchiveGetMediaItemsResponse] if successful, or null with a [Result] indicating the failure reason.
* @return The [ArchiveApiV2.MediaItemsPage] if successful, or null with a [Result] indicating the failure reason.
*/
private fun getRemoteArchiveItemPage(cursor: String?): Pair<ArchiveGetMediaItemsResponse?, Result?> {
return when (val result = BackupRepository.listRemoteMediaObjects(CDN_FETCH_LIMIT, cursor)) {
is NetworkResult.Success -> result.result to null
is NetworkResult.NetworkError -> return null to Result.retry(defaultBackoff())
is NetworkResult.StatusCodeError -> {
if (result.code == 429) {
Log.w(TAG, "Rate limited while attempting to list media objects. Retrying later.", true)
return null to Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
} else {
Log.w(TAG, "Failed to list remote media objects with code: ${result.code}. Unable to proceed.", result.getCause(), true)
return null to Result.failure()
}
}
private suspend fun getRemoteArchiveItemPage(cursor: String?): Pair<ArchiveApiV2.MediaItemsPage?, Result?> {
return when (val result = AppDependencies.archiveService.listRemoteMediaObjects(CDN_FETCH_LIMIT, cursor)) {
is Either.Right -> result.value to null
is Either.Left -> when (val error = result.value) {
is ArchiveError.NetworkError -> null to Result.retry(defaultBackoff())
is NetworkResult.ApplicationError -> {
Log.w(TAG, "Failed to list remote media objects due to a crash.", result.getCause(), true)
return null to Result.fatalFailure(RuntimeException(result.getCause()))
is ArchiveError.CredentialError.RateLimited -> {
Log.w(TAG, "Rate limited while attempting to list media objects. Retrying later.", true)
null to Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
}
is ArchiveError.ApplicationError -> {
Log.w(TAG, "Failed to list remote media objects due to a crash.", error.exception, true)
null to Result.fatalFailure(RuntimeException(error.exception))
}
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.EntitlementError.NotEntitled,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(TAG, "Failed to list remote media objects: ${error::class.simpleName}. Unable to proceed.", error.cause, true)
null to Result.failure()
}
}
}
}
@@ -407,7 +415,7 @@ class ArchiveAttachmentReconciliationJob private constructor(
*
* @return A non-successful [Result] in the case of failure, otherwise null for success.
*/
private fun validateAndDeleteFromRemote(deletes: Set<ArchivedMediaObject>): Result? {
private suspend fun validateAndDeleteFromRemote(deletes: Set<ArchivedMediaObject>): Result? {
if (RemoteConfig.internalUser) {
val mediaIds = deletes.take(250).map { MediaId(it.mediaId.decodeBase64()!!) }
Log.w(TAG, "Want to delete (showing ${mediaIds.size}/${deletes.size}): ${mediaIds.take(250).joinToString() }")
@@ -5,9 +5,11 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.Either
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -20,7 +22,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
*
* Calling this repeatedly is a no-op from the server's perspective, so no need to be careful around retries or anything.
*/
class ArchiveBackupIdReservationJob private constructor(parameters: Parameters) : Job(parameters) {
class ArchiveBackupIdReservationJob private constructor(parameters: Parameters) : CoroutineJob(parameters) {
companion object {
private val TAG = Log.tag(ArchiveBackupIdReservationJob::class)
@@ -41,7 +43,7 @@ class ArchiveBackupIdReservationJob private constructor(parameters: Parameters)
override fun getFactoryKey(): String = KEY
override fun run(): Result {
override suspend fun doRun(): Result {
if (!SignalStore.account.isRegistered) {
Log.w(TAG, "Not registered. Skipping.")
return Result.success()
@@ -57,18 +59,26 @@ class ArchiveBackupIdReservationJob private constructor(parameters: Parameters)
return Result.success()
}
return when (val result = BackupRepository.triggerBackupIdReservation()) {
is NetworkResult.Success -> Result.success()
is NetworkResult.NetworkError -> Result.retry(defaultBackoff())
is NetworkResult.ApplicationError -> Result.fatalFailure(RuntimeException(result.throwable))
is NetworkResult.StatusCodeError -> {
when (result.code) {
429 -> Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
else -> {
Log.w(TAG, "Failed to reserve backupId with status: ${result.code}. This should only happen on a malformed request or server error. Reducing backoff interval to be safe.")
return when (val result = AppDependencies.archiveService.triggerBackupIdReservation()) {
is Either.Right -> Result.success()
is Either.Left -> when (val error = result.value) {
is ArchiveError.NetworkError -> {
if (error.isServerSide) {
Log.w(TAG, "Server error while reserving backupId. Backing off hard.", error.exception)
Result.retry(RemoteConfig.serverErrorMaxBackoff)
} else {
Result.retry(defaultBackoff())
}
}
is ArchiveError.ApplicationError -> Result.fatalFailure(RuntimeException(error.exception))
is ArchiveError.CredentialError.RateLimited -> Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(TAG, "Failed to reserve backupId: ${error::class.simpleName}. This should only happen on a malformed request. Reducing backoff interval to be safe.")
Result.retry(RemoteConfig.serverErrorMaxBackoff)
}
}
}
}
@@ -5,14 +5,17 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.Either
import org.signal.core.models.backup.MediaId
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -25,7 +28,7 @@ import kotlin.time.Duration.Companion.hours
* Instead, we have to do it after a backup is taken. This job looks at [BackupMediaSnapshotTable] in order to determine which media objects
* can be safely deleted from the archive service.
*/
class ArchiveCommitAttachmentDeletesJob private constructor(parameters: Parameters) : Job(parameters) {
class ArchiveCommitAttachmentDeletesJob private constructor(parameters: Parameters) : CoroutineJob(parameters) {
companion object {
private val TAG = Log.tag(ArchiveCommitAttachmentDeletesJob::class.java)
@@ -40,7 +43,7 @@ class ArchiveCommitAttachmentDeletesJob private constructor(parameters: Paramete
*
* @return Null if successful, or a [Result] indicating the failure.
*/
fun deleteMediaObjectsFromCdn(tag: String, attachmentsToDelete: Set<ArchivedMediaObject>, backoffGenerator: () -> Long, cancellationSignal: () -> Boolean): Result? {
suspend fun deleteMediaObjectsFromCdn(tag: String, attachmentsToDelete: Set<ArchivedMediaObject>, backoffGenerator: () -> Long, cancellationSignal: () -> Boolean): Result? {
if (RemoteConfig.internalUser) {
val mediaIds = attachmentsToDelete.take(250).map { MediaId(Base64.decode(it.mediaId)) }
Log.w(TAG, "Deleting MediaIds (showing ${mediaIds.size}/${attachmentsToDelete.size}): ${mediaIds.joinToString() }")
@@ -52,37 +55,40 @@ class ArchiveCommitAttachmentDeletesJob private constructor(parameters: Paramete
return Result.failure()
}
when (val result = BackupRepository.deleteAbandonedMediaObjects(chunk)) {
is NetworkResult.Success -> {
val mediaToDelete = chunk.filter { it.cdn == Cdn.CDN_3.cdnNumber }.map { it.toDeleteBackupMediaItem() }
when (val result = AppDependencies.archiveService.deleteArchivedMedia(mediaToDelete)) {
is Either.Right -> {
Log.i(tag, "Successfully deleted ${chunk.size} attachments off of the CDN. (Note: Count includes thumbnails)", true)
}
is NetworkResult.NetworkError -> {
return Result.retry(backoffGenerator())
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
429 -> {
Log.w(tag, "Rate limited while attempting to delete media objects. Retrying later.", true)
return Result.retry(result.retryAfter()?.inWholeMilliseconds ?: backoffGenerator())
}
in 500..599 -> {
Log.w(tag, "Failed to delete attachments from CDN with code: ${result.code}. Retrying with a larger backoff.", result.getCause(), true)
return Result.retry(1.hours.inWholeMilliseconds)
}
else -> {
Log.w(tag, "Failed to delete attachments from CDN with code: ${result.code}. Considering this a terminal failure.", result.getCause(), true)
return Result.failure()
is Either.Left -> when (val error = result.value) {
is ArchiveError.NetworkError -> {
return if (error.isServerSide) {
Log.w(tag, "Server error while deleting attachments from the CDN. Retrying with a larger backoff.", error.exception, true)
Result.retry(1.hours.inWholeMilliseconds)
} else {
Result.retry(backoffGenerator())
}
}
}
is NetworkResult.ApplicationError -> {
Log.w(tag, "Crash when trying to delete attachments from the CDN", result.getCause(), true)
Result.fatalFailure(RuntimeException(result.getCause()))
is ArchiveError.CredentialError.RateLimited -> {
Log.w(tag, "Rate limited while attempting to delete media objects. Retrying later.", true)
return Result.retry(error.retryAfter?.inWholeMilliseconds ?: backoffGenerator())
}
is ArchiveError.ApplicationError -> {
Log.w(tag, "Crash when trying to delete attachments from the CDN", error.exception, true)
return Result.fatalFailure(RuntimeException(error.exception))
}
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(tag, "Failed to delete attachments from CDN: ${error::class.simpleName}. Considering this a terminal failure.", error.cause, true)
return Result.failure()
}
}
}
}
@@ -104,7 +110,7 @@ class ArchiveCommitAttachmentDeletesJob private constructor(parameters: Paramete
override fun getFactoryKey(): String = KEY
override fun run(): Result {
override suspend fun doRun(): Result {
if (!SignalStore.backup.backsUpMedia) {
Log.w(TAG, "This user doesn't back up media! Skipping.")
return Result.success()
@@ -5,22 +5,24 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.Either
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.glide.decryptableuri.DecryptableUri
import org.signal.network.NetworkResult
import org.signal.network.api.AttachmentUploadResult
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.ArchiveDatabaseExecutor
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.UploadedThumbnailInfo
import org.thoughtcrime.securesms.backup.v2.hadIntegrityCheckPerformed
import org.thoughtcrime.securesms.backup.v2.requireThumbnailMediaName
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.BackupMessagesConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NoRemoteArchiveGarbageCollectionPendingConstraint
@@ -48,7 +50,7 @@ import kotlin.time.Duration.Companion.days
class ArchiveThumbnailUploadJob private constructor(
params: Parameters,
val attachmentId: AttachmentId
) : Job(params) {
) : CoroutineJob(params) {
companion object {
const val KEY = "ArchiveThumbnailUploadJob"
@@ -108,12 +110,7 @@ class ArchiveThumbnailUploadJob private constructor(
}
}
override fun run(): Result {
// TODO [cody] Remove after a few releases as we migrate to the correct constraint
if (!BackupMessagesConstraint.isMet(context)) {
return Result.failure()
}
override suspend fun doRun(): Result {
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)
if (attachment == null) {
Log.w(TAG, "$attachmentId not found, assuming this job is no longer necessary.")
@@ -180,33 +177,34 @@ class ArchiveThumbnailUploadJob private constructor(
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(thumbnailResult.data.size.toLong()))
val form: AttachmentUploadForm = when (val formResult = BackupRepository.getAttachmentUploadForm(ciphertextLength)) {
is NetworkResult.Success -> formResult.result
is NetworkResult.ApplicationError -> {
Log.w(TAG, "Failed to get upload form due to an application error. Retrying.", formResult.throwable)
return Result.retry(defaultBackoff())
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "Encountered a transient network error when getting upload form. Retrying.")
return Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
return when (formResult.code) {
429 -> {
Log.w(TAG, "Rate limited when getting upload form.")
Result.retry(formResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
413 -> {
Log.w(TAG, "Thumbnail is too large to upload to the archive. Marking as a permanent failure.")
ArchiveDatabaseExecutor.runBlocking {
SignalDatabase.attachments.setArchiveThumbnailTransferState(attachmentId, AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE)
}
Result.failure()
}
else -> {
Log.w(TAG, "Failed to get upload form with status code ${formResult.code}")
Result.retry(defaultBackoff())
val form: AttachmentUploadForm = when (val formResult = AppDependencies.archiveService.getMediaUploadForm(ciphertextLength)) {
is Either.Right -> formResult.value
is Either.Left -> return when (val error = formResult.value) {
is ArchiveError.ApplicationError -> {
Log.w(TAG, "Failed to get upload form due to an application error. Retrying.", error.exception)
Result.retry(defaultBackoff())
}
is ArchiveError.NetworkError -> {
Log.w(TAG, "Encountered a transient network error when getting upload form. Retrying.")
Result.retry(defaultBackoff())
}
is ArchiveError.CredentialError.RateLimited -> {
Log.w(TAG, "Rate limited when getting upload form.")
Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
}
is ArchiveError.UploadFormError.TooLarge -> {
Log.w(TAG, "Thumbnail is too large to upload to the archive. Marking as a permanent failure.")
ArchiveDatabaseExecutor.runBlocking {
SignalDatabase.attachments.setArchiveThumbnailTransferState(attachmentId, AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE)
}
Result.failure()
}
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(TAG, "Failed to get upload form: ${error::class.simpleName}")
Result.retry(defaultBackoff())
}
}
}
@@ -250,8 +248,15 @@ class ArchiveThumbnailUploadJob private constructor(
return Result.failure()
}
return when (val result = BackupRepository.copyThumbnailToArchive(attachmentPointer, attachment)) {
is NetworkResult.Success -> {
val copyResult = AppDependencies.archiveService.copyToArchive(
cdnNumber = attachmentPointer.cdnNumber,
remoteLocation = attachmentPointer.remoteLocation,
plaintextSize = attachmentPointer.size,
mediaName = attachment.requireThumbnailMediaName()
)
return when (copyResult) {
is Either.Right -> {
// save attachment thumbnail
ArchiveDatabaseExecutor.runBlocking {
SignalDatabase.attachments.finalizeAttachmentThumbnailAfterUpload(
@@ -267,25 +272,36 @@ class ArchiveThumbnailUploadJob private constructor(
Result.success()
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "Hit a network error when trying to archive thumbnail for $attachmentId", result.exception)
Result.retry(defaultBackoff())
}
is Either.Left -> when (val error = copyResult.value) {
is ArchiveError.NetworkError -> {
Log.w(TAG, "Hit a network error when trying to archive thumbnail for $attachmentId", error.exception)
Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
429 -> {
Log.w(TAG, "Rate limited when trying to archive thumbnail for $attachmentId")
Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
else -> {
Log.w(TAG, "Hit a status code error of ${result.code} when trying to archive thumbnail for $attachmentId")
Result.retry(defaultBackoff())
}
is ArchiveError.CredentialError.RateLimited -> {
Log.w(TAG, "Rate limited when trying to archive thumbnail for $attachmentId")
Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
}
is ArchiveError.ApplicationError -> {
Result.fatalFailure(RuntimeException(error.exception))
}
is ArchiveError.CopyMediaError.OutOfRemoteSpace -> {
Log.w(TAG, "Out of remote storage space when trying to archive thumbnail for $attachmentId. Giving up until the next backfill.")
Result.failure()
}
is ArchiveError.CopyMediaError.SourceNotFound,
is ArchiveError.CopyMediaError.WrongSourceLength,
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(TAG, "Hit ${error::class.simpleName} when trying to archive thumbnail for $attachmentId")
Result.retry(defaultBackoff())
}
}
is NetworkResult.ApplicationError -> Result.fatalFailure(RuntimeException(result.throwable))
}
}
@@ -5,8 +5,9 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.Either
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.Recurring
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.DeletionNotAwaitingMediaDownloadConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
@@ -30,7 +32,7 @@ import kotlin.time.Duration.Companion.seconds
class BackupDeleteJob private constructor(
private var backupDeleteJobData: BackupDeleteJobData,
parameters: Parameters
) : Job(parameters) {
) : CoroutineJob(parameters) {
companion object {
const val KEY = "BackupDeleteJob"
@@ -51,7 +53,7 @@ class BackupDeleteJob private constructor(
override fun getFactoryKey(): String = KEY
override fun run(): Result {
override suspend fun doRun(): Result {
if (!SignalStore.account.isRegistered) {
Log.w(TAG, "User not registered. Exiting without local cleanup.")
return Result.failure()
@@ -67,7 +69,7 @@ class BackupDeleteJob private constructor(
return Result.failure()
}
val result = doRun()
val result = runStages()
if (result.isFailure) {
clearLocalBackupStateOnFailure()
@@ -77,7 +79,7 @@ class BackupDeleteJob private constructor(
return result
}
private fun doRun(): Result {
private suspend fun runStages(): Result {
if (SignalStore.backup.deletionState == DeletionState.AWAITING_MEDIA_DOWNLOAD) {
Log.i(TAG, "Awaiting media download. Scheduling retry.")
return Result.retry(5.seconds.inWholeMilliseconds)
@@ -201,37 +203,37 @@ class BackupDeleteJob private constructor(
return Result.success()
}
private fun deleteMessageBackup(): Result {
private suspend fun deleteMessageBackup(): Result {
if (backupDeleteJobData.completed.contains(BackupDeleteJobData.Stage.DELETE_MESSAGES)) {
Log.d(TAG, "Already deleted messages.")
return Result.success()
}
val deleteMessageBackupResult: NetworkResult<Unit> = BackupRepository.deleteBackup()
if (deleteMessageBackupResult.getCause() != null) {
Log.w(TAG, "Failed to delete message backup", deleteMessageBackupResult.getCause())
return handleNetworkError(deleteMessageBackupResult)
} else {
Log.d(TAG, "Deleted message backup.")
when (val result = AppDependencies.archiveService.deleteMessageBackup()) {
is Either.Right -> Log.d(TAG, "Deleted message backup.")
is Either.Left -> {
Log.w(TAG, "Failed to delete message backup", result.value.cause)
return handleArchiveError(result.value)
}
}
addStageToCompletions(BackupDeleteJobData.Stage.DELETE_MESSAGES)
return Result.success()
}
private fun deleteMediaBackup(): Result {
private suspend fun deleteMediaBackup(): Result {
if (backupDeleteJobData.completed.contains(BackupDeleteJobData.Stage.DELETE_MEDIA)) {
Log.d(TAG, "Already deleted media.")
return Result.success()
}
if (backupDeleteJobData.tier == BackupDeleteJobData.Tier.PAID) {
val deleteMediaBackupResult: NetworkResult<Unit> = BackupRepository.deleteMediaBackup()
if (deleteMediaBackupResult.getCause() != null) {
Log.w(TAG, "Failed to delete media backup", deleteMediaBackupResult.getCause())
return handleNetworkError(deleteMediaBackupResult)
} else {
Log.d(TAG, "Deleted media backup.")
when (val result = AppDependencies.archiveService.deleteMediaBackup()) {
is Either.Right -> Log.d(TAG, "Deleted media backup.")
is Either.Left -> {
Log.w(TAG, "Failed to delete media backup", result.value.cause)
return handleArchiveError(result.value)
}
}
}
@@ -246,12 +248,10 @@ class BackupDeleteJob private constructor(
}
Log.d(TAG, "Loading backup tier from service.")
val backupTierResult: NetworkResult<MessageBackupTier> = BackupRepository.getBackupTier()
if (backupTierResult.getCause() != null) {
return handleNetworkError(backupTierResult)
val backupTier: MessageBackupTier = when (val result = BackupRepository.getBackupTier()) {
is Either.Right -> result.value
is Either.Left -> return handleArchiveError(result.value)
}
val backupTier: MessageBackupTier = backupTierResult.successOrThrow()
Log.d(TAG, "Network request returned $backupTier")
backupDeleteJobData = backupDeleteJobData.newBuilder().tier(
when (backupTier) {
@@ -290,28 +290,21 @@ class BackupDeleteJob private constructor(
.build()
}
private fun <T> handleNetworkError(networkResult: NetworkResult<T>): Result {
Log.d(TAG, "An error occurred.", networkResult.getCause())
private fun handleArchiveError(error: ArchiveError.CredentialError): Result {
Log.d(TAG, "An error occurred: $error", error.cause)
if (networkResult.getCause() is org.signal.libsignal.zkgroup.VerificationFailedException) {
Log.i(TAG, "ZK Verification failed. Retrying.")
return Result.retry(defaultBackoff())
}
return when (error) {
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.i(TAG, "ZK Verification failed. Retrying.")
Result.retry(defaultBackoff())
}
is ArchiveError.ApplicationError -> (error.exception as? RuntimeException)?.let { Result.fatalFailure(it) } ?: Result.failure()
is ArchiveError.NetworkError -> Result.retry(defaultBackoff())
is ArchiveError.CredentialError.RateLimited -> Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
return when (networkResult) {
is NetworkResult.ApplicationError<*> -> (networkResult.getCause() as? RuntimeException)?.let { Result.fatalFailure(it) } ?: Result.failure()
is NetworkResult.NetworkError<*> -> Result.retry(defaultBackoff())
is NetworkResult.StatusCodeError<*> -> handleStatusCodeError(networkResult)
is NetworkResult.Success<*> -> error("Success.")
}
}
private fun handleStatusCodeError(statusCodeError: NetworkResult.StatusCodeError<*>): Result {
Log.d(TAG, "Status code error: ${statusCodeError.code}")
return when (statusCodeError.code) {
429 -> Result.retry(statusCodeError.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
else -> Result.failure()
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest -> Result.failure()
}
}
@@ -13,6 +13,7 @@ import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import arrow.core.Either
import okio.IOException
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.util.PendingIntentFlags
@@ -22,9 +23,9 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logW
import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.net.SvrBStoreResponse
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.network.NetworkResult
import org.signal.network.api.SvrBApi
import org.signal.network.service.ArchiveError
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
@@ -38,6 +39,7 @@ import org.thoughtcrime.securesms.backup.v2.util.getAllReferencedArchiveAttachme
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.BackupMessagesConstraint
import org.thoughtcrime.securesms.jobs.protos.BackupMessagesJobData
@@ -72,7 +74,7 @@ class BackupMessagesJob private constructor(
private var dataFile: String,
private var resumableMessagesBackupUploadSpec: ResumableMessagesBackupUploadSpec?,
parameters: Parameters
) : Job(parameters) {
) : CoroutineJob(parameters) {
companion object {
private val TAG = Log.tag(BackupMessagesJob::class.java)
@@ -150,7 +152,7 @@ class BackupMessagesJob private constructor(
}
}
override fun run(): Result {
override suspend fun doRun(): Result {
val result = doWork()
if (result.isSuccess && !isCanceled && SignalStore.backup.optimizeStorage && SignalStore.backup.backsUpMedia) {
AppDependencies.jobManager.add(OptimizeMediaJob())
@@ -158,7 +160,7 @@ class BackupMessagesJob private constructor(
return result
}
private fun doWork(): Result {
private suspend fun doWork(): Result {
if (!isBackupAllowed()) {
Log.d(TAG, "Skip running BackupMessagesJob.", true)
return Result.success()
@@ -166,41 +168,51 @@ class BackupMessagesJob private constructor(
val stopwatch = Stopwatch("BackupMessagesJob")
val auth = when (val result = BackupRepository.getSvrBAuth()) {
is NetworkResult.Success -> result.result
is NetworkResult.NetworkError -> return Result.retry(defaultBackoff()).logW(TAG, "Network error when getting SVRB auth.", result.getCause(), true)
is NetworkResult.StatusCodeError -> {
return when (result.code) {
429 -> Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff()).logW(TAG, "Rate limited when getting SVRB auth.", result.getCause(), true)
else -> Result.retry(defaultBackoff()).logW(TAG, "Status code error when getting SVRB auth.", result.getCause(), true)
val auth = when (val result = AppDependencies.archiveService.getSvrBAuth()) {
is Either.Right -> result.value
is Either.Left -> when (val error = result.value) {
is ArchiveError.CredentialError.RateLimited -> {
return Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff()).logW(TAG, "Rate limited when getting SVRB auth.", error.cause, true)
}
is ArchiveError.NetworkError,
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
return Result.retry(defaultBackoff()).logW(TAG, "Failed to get SVRB auth: ${error::class.simpleName}", error.cause, true)
}
is ArchiveError.ApplicationError -> {
throw error.exception
}
}
is NetworkResult.ApplicationError -> throw result.throwable
}
if (SignalStore.backup.backupSecretRestoreRequired) {
Log.i(TAG, "[svrb-restore] First backup of re-registered account without remote restore, read remote data if available to re-init")
val forwardSecrecyMetadata: ByteArray? = when (val result = BackupRepository.getRemoteBackupForwardSecrecyMetadata()) {
is NetworkResult.Success -> result.result
is NetworkResult.NetworkError -> return Result.retry(defaultBackoff()).logW(TAG, "[svrb-restore] Network error getting remote forward secrecy metadata.", result.getCause(), true)
is NetworkResult.StatusCodeError -> {
if (result.code == 401 || result.code == 403 || result.code == 404) {
is Either.Right -> result.value
is Either.Left -> when (val error = result.value) {
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.EntitlementError.NotEntitled,
is ArchiveError.CredentialError.NotFound -> {
Log.i(TAG, "[svrb-restore] No backup data found, continuing.", true)
null
} else {
return when (result.code) {
429 -> Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff()).logW(TAG, "[svrb-restore] Rate limited when getting remote forward secrecy metadata.", result.getCause(), true)
else -> Result.retry(defaultBackoff()).logW(TAG, "[svrb-restore] Status code error when getting remote forward secrecy metadata.", result.getCause(), true)
}
}
}
is NetworkResult.ApplicationError -> {
if (result.getCause() is VerificationFailedException) {
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(TAG, "[svrb-restore] zkverification failed getting backup info, continuing.", true)
null
} else {
throw result.throwable
}
is ArchiveError.CredentialError.RateLimited -> {
return Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff()).logW(TAG, "[svrb-restore] Rate limited when getting remote forward secrecy metadata.", error.cause, true)
}
is ArchiveError.NetworkError,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.BackupFileError.UnexpectedResponse -> {
return Result.retry(defaultBackoff()).logW(TAG, "[svrb-restore] Failed to get remote forward secrecy metadata: ${error::class.simpleName}", error.cause, true)
}
is ArchiveError.ApplicationError -> {
throw error.exception
}
}
}
@@ -297,40 +309,41 @@ class BackupMessagesJob private constructor(
val existingSpec = resumableMessagesBackupUploadSpec
val form: AttachmentUploadForm = if (existingSpec == null) {
when (val result = BackupRepository.getMessageBackupUploadForm(tempBackupFile.length())) {
is NetworkResult.Success -> result.result
is NetworkResult.NetworkError -> {
Log.i(TAG, "Network failure", result.getCause(), true)
return Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
413 -> {
Log.i(TAG, "Backup file is too large! Size: ${tempBackupFile.length()} bytes. Current threshold: ${SignalStore.backup.messageCuttoffDuration}", result.getCause(), true)
tempBackupFile.delete()
this.dataFile = ""
BackupRepository.markBackupCreationFailed(BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE)
backupErrorHandled = true
when (val result = AppDependencies.archiveService.getMessageBackupUploadForm(tempBackupFile.length())) {
is Either.Right -> result.value
is Either.Left -> when (val error = result.value) {
is ArchiveError.NetworkError -> {
Log.i(TAG, "Network failure", error.exception, true)
return Result.retry(defaultBackoff())
}
is ArchiveError.UploadFormError.TooLarge -> {
Log.i(TAG, "Backup file is too large! Size: ${tempBackupFile.length()} bytes. Current threshold: ${SignalStore.backup.messageCuttoffDuration}", error.cause, true)
tempBackupFile.delete()
this.dataFile = ""
BackupRepository.markBackupCreationFailed(BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE)
backupErrorHandled = true
if (SignalStore.backup.messageCuttoffDuration == null) {
Log.i(TAG, "Setting message cuttoff duration to $TOO_LARGE_MESSAGE_CUTTOFF_DURATION", true)
SignalStore.backup.messageCuttoffDuration = TOO_LARGE_MESSAGE_CUTTOFF_DURATION
return Result.retry(defaultBackoff())
} else {
return Result.failure()
}
}
429 -> {
Log.i(TAG, "Rate limited when getting upload form.", result.getCause(), true)
return Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
}
else -> {
Log.i(TAG, "Status code failure", result.getCause(), true)
if (SignalStore.backup.messageCuttoffDuration == null) {
Log.i(TAG, "Setting message cuttoff duration to $TOO_LARGE_MESSAGE_CUTTOFF_DURATION", true)
SignalStore.backup.messageCuttoffDuration = TOO_LARGE_MESSAGE_CUTTOFF_DURATION
return Result.retry(defaultBackoff())
} else {
return Result.failure()
}
}
is ArchiveError.CredentialError.RateLimited -> {
Log.i(TAG, "Rate limited when getting upload form.", error.cause, true)
return Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
}
is ArchiveError.ApplicationError -> throw error.exception
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.i(TAG, "Failed to get upload form: ${error::class.simpleName}", error.cause, true)
return Result.retry(defaultBackoff())
}
}
is NetworkResult.ApplicationError -> throw result.throwable
}
} else {
existingSpec.attachmentUploadForm
@@ -5,10 +5,11 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.Either
import org.signal.core.util.logging.Log
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -20,7 +21,7 @@ import kotlin.time.Duration.Companion.milliseconds
*/
class BackupRefreshJob private constructor(
parameters: Parameters
) : Job(parameters) {
) : CoroutineJob(parameters) {
companion object {
private val TAG = Log.tag(BackupRefreshJob::class)
@@ -68,34 +69,37 @@ class BackupRefreshJob private constructor(
}
}
override fun run(): Result {
override suspend fun doRun(): Result {
if (!canExecuteJob()) {
return Result.success()
}
val result = BackupRepository.refreshBackup()
return when (result) {
is NetworkResult.Success -> {
return when (val result = AppDependencies.archiveService.refreshBackup()) {
is Either.Right -> {
SignalStore.backup.lastCheckInMillis = System.currentTimeMillis()
SignalStore.backup.lastCheckInSnoozeMillis = 0
Result.success()
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "Network error when refreshing backup.", result.getCause())
Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
Log.w(TAG, "Status code error (${result.code}) when refreshing backup.", result.getCause())
if (result.code == 429) {
Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
} else {
is Either.Left -> when (val error = result.value) {
is ArchiveError.NetworkError -> {
Log.w(TAG, "Network error when refreshing backup.", error.exception)
Result.retry(defaultBackoff())
}
is ArchiveError.CredentialError.RateLimited -> {
Log.w(TAG, "Rate limited when refreshing backup.", error.cause)
Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
}
is ArchiveError.ApplicationError -> {
Log.w(TAG, "Application error when refreshing backup.", error.exception)
Result.failure()
}
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(TAG, "Error when refreshing backup: ${error::class.simpleName}", error.cause)
Result.failure()
}
}
is NetworkResult.ApplicationError -> {
Log.w(TAG, "Application error when refreshing backup.", result.throwable)
Result.failure()
}
}
}
@@ -226,10 +226,7 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C
private fun checkAndSynchronizeZkCredentialTierWithStoredLocalTier() {
Log.i(TAG, "Detected an active, non-failed, non-canceled signal subscription. Synchronizing backup tier with value from server.", true)
val zkTier: MessageBackupTier? = when (val result = BackupRepository.getBackupTierWithoutDowngrade()) {
is NetworkResult.Success -> result.result
else -> null
}
val zkTier: MessageBackupTier? = BackupRepository.getBackupTierWithoutDowngrade().getOrNull()
if (zkTier == SignalStore.backup.backupTier) {
Log.i(TAG, "ZK credential tier is in sync with our stored backup tier.", true)
@@ -237,7 +234,7 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C
Log.w(TAG, "ZK credential tier is not in sync with our stored backup tier, flushing credentials and retrying.", true)
BackupRepository.resetInitializedStateAndAuthCredentials()
BackupRepository.getBackupTier().runIfSuccessful {
BackupRepository.getBackupTier().onRight {
Log.i(TAG, "Refreshed credentials. Synchronizing stored backup tier with ZK result.")
SignalStore.backup.backupTier = it
}
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.Either
import kotlinx.coroutines.runBlocking
import org.signal.core.models.backup.MediaName
import org.signal.core.models.database.AttachmentId
@@ -8,8 +9,7 @@ import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.logW
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.network.NetworkResult
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.backup.v2.hadIntegrityCheckPerformed
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NoRemoteArchiveGarbageCollectionPendingConstraint
@@ -35,7 +36,7 @@ import java.util.concurrent.TimeUnit
* This job runs at high priority within its queue, which it shares with [UploadAttachmentToArchiveJob]. Therefore, copies are given priority over new uploads,
* which allows the two-part archive upload process to finish quickly.
*/
class CopyAttachmentToArchiveJob private constructor(private val attachmentId: AttachmentId, parameters: Parameters) : Job(parameters) {
class CopyAttachmentToArchiveJob private constructor(private val attachmentId: AttachmentId, parameters: Parameters) : CoroutineJob(parameters) {
companion object {
private val TAG = Log.tag(CopyAttachmentToArchiveJob::class.java)
@@ -74,7 +75,7 @@ class CopyAttachmentToArchiveJob private constructor(private val attachmentId: A
}
}
override fun run(): Result {
override suspend fun doRun(): Result {
if (SignalStore.account.isLinkedDevice) {
Log.w(TAG, "[$attachmentId] Linked devices don't backup media. Skipping.")
setArchiveTransferStateWithDelayedNotification(attachmentId, AttachmentTable.ArchiveTransferState.NONE)
@@ -171,35 +172,30 @@ class CopyAttachmentToArchiveJob private constructor(private val attachmentId: A
}
val result = when (val archiveResult = BackupRepository.copyAttachmentToArchive(attachment)) {
is NetworkResult.Success -> {
is Either.Right -> {
Log.i(TAG, "[$attachmentId]$mediaIdLog Successfully copied the archive tier.")
Result.success()
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Encountered a retryable network error.", archiveResult.exception)
Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
when (archiveResult.code) {
400 -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Something is invalid about our request. Possibly the length. Scheduling a re-upload. Body: ${archiveResult.exception.stringBody}")
is Either.Left -> {
when (val error = archiveResult.value) {
is ArchiveError.NetworkError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Encountered a retryable network error.", error.exception)
Result.retry(defaultBackoff())
}
is ArchiveError.CopyMediaError.WrongSourceLength -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Something is invalid about our request. Possibly the length. Scheduling a re-upload.")
setArchiveTransferStateWithDelayedNotification(attachmentId, AttachmentTable.ArchiveTransferState.NONE)
AppDependencies.jobManager.add(UploadAttachmentToArchiveJob(attachmentId, canReuseUpload = false))
Result.success()
}
403 -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Insufficient permissions to upload. Handled in parent handler.")
Result.success()
}
410 -> {
is ArchiveError.CopyMediaError.SourceNotFound -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog The attachment no longer exists on the transit tier. Scheduling a re-upload.")
setArchiveTransferStateWithDelayedNotification(attachmentId, AttachmentTable.ArchiveTransferState.NONE)
AppDependencies.jobManager.add(UploadAttachmentToArchiveJob(attachmentId, canReuseUpload = false))
Result.success()
}
413 -> {
is ArchiveError.CopyMediaError.OutOfRemoteSpace -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Insufficient storage space! Can't upload!")
val remoteStorageQuota = getServerQuota() ?: return Result.retry(defaultBackoff()).logW(TAG, "[$attachmentId] Failed to fetch server quota! Retrying.")
@@ -214,24 +210,26 @@ class CopyAttachmentToArchiveJob private constructor(private val attachmentId: A
Result.retry(defaultBackoff())
}
429 -> {
is ArchiveError.CredentialError.RateLimited -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Rate limit exceeded. Retrying.")
Result.retry(archiveResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
}
else -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Got back a non-2xx status code: ${archiveResult.code}. Retrying.")
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Encountered a verification failure when trying to upload! Retrying.")
Result.retry(defaultBackoff())
}
is ArchiveError.ApplicationError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Encountered a fatal error when trying to upload!")
Result.fatalFailure(RuntimeException(error.exception))
}
// Note that a copy is anonymous, so libsignal reports a lack of entitlement as an unauthorized credential too. Either way a retry will re-establish
// the credential, and the parent handler is what reacts to a downgraded tier.
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Got back ${error::class.simpleName}. Retrying.")
Result.retry(defaultBackoff())
}
}
}
is NetworkResult.ApplicationError -> {
if (archiveResult.throwable is VerificationFailedException) {
Log.w(TAG, "[$attachmentId]$mediaIdLog Encountered a verification failure when trying to upload! Retrying.")
Result.retry(defaultBackoff())
} else {
Log.w(TAG, "[$attachmentId]$mediaIdLog Encountered a fatal error when trying to upload!")
Result.fatalFailure(RuntimeException(archiveResult.throwable))
}
}
}
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.getOrElse
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
@@ -13,7 +14,6 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredential
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -239,12 +239,9 @@ class InAppPaymentRecurringContextJob private constructor(
return false
}
val tier = when (val result = BackupRepository.getBackupTier()) {
is NetworkResult.Success -> result.result
else -> {
warning("Failed to get backup tier via zk check.")
MessageBackupTier.FREE
}
val tier = BackupRepository.getBackupTier().getOrElse {
warning("Failed to get backup tier via zk check.")
MessageBackupTier.FREE
}
if (tier != MessageBackupTier.PAID) {
@@ -12,6 +12,7 @@ import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.runBlocking
import org.greenrobot.eventbus.EventBus
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64.decodeBase64OrThrow
@@ -22,6 +23,8 @@ import org.signal.libsignal.protocol.InvalidMacException
import org.signal.libsignal.protocol.InvalidMessageException
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.exceptions.PushNetworkException
import org.signal.network.service.ArchiveService
import org.signal.network.service.successOrThrow
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.InvalidAttachmentException
@@ -377,7 +380,7 @@ class RestoreAttachmentJob private constructor(
ArchiveRestoreProgress.onDownloadStart(attachmentId)
val decryptingStream = if (useArchiveCdn) {
val cdnCredentials = BackupRepository.getCdnReadCredentials(BackupRepository.CredentialType.MEDIA, attachment.archiveCdn ?: RemoteConfig.backupFallbackArchiveCdn).successOrThrow().headers
val cdnCredentials = runBlocking { AppDependencies.archiveService.getCdnReadCredentials(ArchiveService.CredentialType.MEDIA, attachment.archiveCdn ?: RemoteConfig.backupFallbackArchiveCdn) }.successOrThrow().headers
messageReceiver
.retrieveArchivedAttachment(
@@ -4,10 +4,13 @@
*/
package org.thoughtcrime.securesms.jobs
import kotlinx.coroutines.runBlocking
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.InvalidMessageException
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.service.ArchiveService
import org.signal.network.service.successOrThrow
import org.thoughtcrime.securesms.attachments.InvalidAttachmentException
import org.thoughtcrime.securesms.backup.v2.ArchiveDatabaseExecutor
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -127,7 +130,7 @@ class RestoreAttachmentThumbnailJob private constructor(
override fun shouldCancel(): Boolean = this@RestoreAttachmentThumbnailJob.isCanceled
}
val cdnCredentials = BackupRepository.getCdnReadCredentials(BackupRepository.CredentialType.MEDIA, attachment.archiveCdn ?: RemoteConfig.backupFallbackArchiveCdn).successOrThrow().headers
val cdnCredentials = runBlocking { AppDependencies.archiveService.getCdnReadCredentials(ArchiveService.CredentialType.MEDIA, attachment.archiveCdn ?: RemoteConfig.backupFallbackArchiveCdn) }.successOrThrow().headers
val pointer = attachment.createArchiveThumbnailPointer()
Log.i(TAG, "Downloading thumbnail for $attachmentId")
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.Either
import org.signal.core.models.backup.MediaName
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64
@@ -16,16 +17,17 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.readLength
import org.signal.network.NetworkResult
import org.signal.network.api.AttachmentUploadResult
import org.signal.network.service.ArchiveError
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveDatabaseExecutor
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.BackupMessagesConstraint
import org.thoughtcrime.securesms.jobs.protos.UploadAttachmentToArchiveJobData
@@ -35,7 +37,6 @@ import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.service.AttachmentProgressService
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.archive.ArchiveMediaUploadFormStatusCodes
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
@@ -58,7 +59,7 @@ class UploadAttachmentToArchiveJob private constructor(
private var uploadSpec: ResumableUpload?,
private val canReuseUpload: Boolean,
parameters: Parameters
) : Job(parameters) {
) : CoroutineJob(parameters) {
companion object {
private val TAG = Log.tag(UploadAttachmentToArchiveJob::class)
@@ -113,12 +114,7 @@ class UploadAttachmentToArchiveJob private constructor(
}
}
override fun run(): Result {
// TODO [cody] Remove after a few releases as we migrate to the correct constraint
if (!BackupMessagesConstraint.isMet(context)) {
return Result.failure()
}
override suspend fun doRun(): Result {
if (SignalStore.account.isLinkedDevice) {
Log.w(TAG, "[$attachmentId] Linked devices don't backup media. Skipping.")
ArchiveDatabaseExecutor.runBlocking {
@@ -235,31 +231,34 @@ class UploadAttachmentToArchiveJob private constructor(
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size))
val form: AttachmentUploadForm? = if (existingSpec == null) {
when (val formResult = BackupRepository.getAttachmentUploadForm(ciphertextLength)) {
is NetworkResult.Success -> formResult.result
is NetworkResult.ApplicationError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Failed to get upload form due to an application error.", formResult.throwable)
return Result.retry(defaultBackoff())
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Encountered a transient network error getting upload form.")
return Result.retry(defaultBackoff())
}
is NetworkResult.StatusCodeError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Failed to get upload form with status code ${formResult.code}")
return when (ArchiveMediaUploadFormStatusCodes.from(formResult.code)) {
ArchiveMediaUploadFormStatusCodes.RateLimited -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Rate limited when getting upload form.")
Result.retry(formResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
when (val formResult = AppDependencies.archiveService.getMediaUploadForm(ciphertextLength)) {
is Either.Right -> formResult.value
is Either.Left -> return when (val error = formResult.value) {
is ArchiveError.ApplicationError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Failed to get upload form due to an application error.", error.exception)
Result.retry(defaultBackoff())
}
is ArchiveError.NetworkError -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Encountered a transient network error getting upload form.")
Result.retry(defaultBackoff())
}
is ArchiveError.CredentialError.RateLimited -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Rate limited when getting upload form.")
Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff())
}
is ArchiveError.UploadFormError.TooLarge -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Media is too large to upload to the archive. Marking as a permanent failure.")
ArchiveDatabaseExecutor.runBlocking {
setArchiveTransferStateWithDelayedNotification(attachmentId, AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE)
}
ArchiveMediaUploadFormStatusCodes.MediaTooLarge -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Media is too large to upload to the archive. Marking as a permanent failure.")
ArchiveDatabaseExecutor.runBlocking {
setArchiveTransferStateWithDelayedNotification(attachmentId, AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE)
}
Result.failure()
}
else -> Result.retry(defaultBackoff())
Result.failure()
}
is ArchiveError.CredentialError.Unauthorized,
is ArchiveError.CredentialError.NotFound,
is ArchiveError.CredentialError.InvalidRequest,
is ArchiveError.CredentialError.ZkVerificationFailed -> {
Log.w(TAG, "[$attachmentId]$mediaIdLog Failed to get upload form: ${error::class.simpleName}")
Result.retry(defaultBackoff())
}
}
}
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.net
import org.signal.network.api.ArchiveApi
import org.signal.network.api.ArchiveApiV2
import org.signal.network.api.AttachmentApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
@@ -37,6 +38,9 @@ object SignalNetwork {
val archive: ArchiveApi
get() = AppDependencies.archiveApi
val archiveV2: ArchiveApiV2
get() = AppDependencies.archiveApiV2
val attachments: AttachmentApi
get() = AppDependencies.attachmentApi
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.registration.ui.restore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.Either
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
import org.thoughtcrime.securesms.backup.v2.RestoreTimestampResult
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
@@ -61,13 +63,13 @@ class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
if (result is RestoreTimestampResult.VerificationFailure && SignalStore.account.restoredAccountEntropyPool) {
Log.w(TAG, "Resetting backup id reservation due to zk verification failure with restored AEP")
result = when (val triggerResult = BackupRepository.triggerBackupIdReservationForRestore()) {
is NetworkResult.Success -> {
result = when (val triggerResult = AppDependencies.archiveService.triggerBackupIdReservation(includeMedia = false)) {
is Either.Right -> {
Log.i(TAG, "Reset successful, trying to restore timestamp")
BackupRepository.restoreBackupFileTimestamp()
}
else -> {
Log.w(TAG, "Reset unsuccessful, failing", triggerResult.getCause())
is Either.Left -> {
Log.w(TAG, "Reset unsuccessful, failing", triggerResult.value.cause)
result
}
}
@@ -102,6 +102,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes
/**
@@ -349,22 +350,22 @@ class AppRegistrationNetworkController(
val access = if (messageCredential != null) {
ArchiveServiceAccess(messageCredential, messageBackupKey)
} else {
when (val credResult = SignalNetwork.archive.getServiceCredentials(currentTime)) {
is NetworkResult.Success -> {
when (val credResult = SignalNetwork.archiveV2.getServiceCredentials(currentTime)) {
is RequestResult.Success -> {
SignalStore.backup.messageCredentials.add(credResult.result.messageCredentials)
SignalStore.backup.messageCredentials.clearOlderThan(currentTime)
val credential = SignalStore.backup.messageCredentials.byDay.getForCurrentTime(currentTime.milliseconds)
?: return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to obtain backup credentials after fetch"))
ArchiveServiceAccess(credential, messageBackupKey)
}
is NetworkResult.StatusCodeError -> return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to fetch backup credentials: ${credResult.code}"))
is NetworkResult.NetworkError -> return@withContext RequestResult.RetryableNetworkError(credResult.exception)
is NetworkResult.ApplicationError -> return@withContext RequestResult.ApplicationError(credResult.throwable)
is RequestResult.NonSuccess -> return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to fetch backup credentials: ${credResult.error}"))
is RequestResult.RetryableNetworkError -> return@withContext RequestResult.RetryableNetworkError(credResult.networkError)
is RequestResult.ApplicationError -> return@withContext RequestResult.ApplicationError(credResult.cause)
}
}
when (val result = SignalNetwork.archive.getMessageBackupInfo(aci, access)) {
is NetworkResult.Success -> {
when (val result = SignalNetwork.archiveV2.getMessageBackupInfo(aci, access)) {
is RequestResult.Success -> {
val info = result.result
RequestResult.Success(
NetworkController.GetBackupInfoResponse(
@@ -378,19 +379,14 @@ class AppRegistrationNetworkController(
)
)
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
401 -> {
// The server doesn't distinguish an invalid credential from a backup-id that was never provisioned, so libsignal's guidance is to treat this as
// "backups aren't set up" rather than an auth failure.
RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup)
}
429 -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(result.retryAfter() ?: Duration.ZERO))
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
}
// The server doesn't distinguish an invalid credential from a backup-id that was never provisioned, so libsignal's guidance is to treat this as
// "backups aren't set up" rather than an auth failure.
is RequestResult.NonSuccess -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.NoBackup)
is RequestResult.RetryableNetworkError -> when (val retryAfter = result.retryAfter) {
null -> RequestResult.RetryableNetworkError(result.networkError)
else -> RequestResult.NonSuccess(NetworkController.GetBackupInfoError.RateLimited(retryAfter.toKotlinDuration()))
}
is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception)
is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable)
is RequestResult.ApplicationError -> RequestResult.ApplicationError(result.cause)
}
}
@@ -471,11 +467,11 @@ class AppRegistrationNetworkController(
val access = ArchiveServiceAccess(messageCredential, aep.deriveMessageBackupKey())
val cdnCredentials = when (val cdnResult = SignalNetwork.archive.getCdnReadCredentials(cdn, aci, access)) {
is NetworkResult.Success -> cdnResult.result.headers
is NetworkResult.StatusCodeError -> return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to get CDN credentials: ${cdnResult.code}"))
is NetworkResult.NetworkError -> return@withContext RequestResult.RetryableNetworkError(cdnResult.exception)
is NetworkResult.ApplicationError -> return@withContext RequestResult.ApplicationError(cdnResult.throwable)
val cdnCredentials = when (val cdnResult = SignalNetwork.archiveV2.getCdnReadCredentials(cdn, aci, access)) {
is RequestResult.Success -> cdnResult.result.headers
is RequestResult.NonSuccess -> return@withContext RequestResult.ApplicationError(IllegalStateException("Failed to get CDN credentials: ${cdnResult.error}"))
is RequestResult.RetryableNetworkError -> return@withContext RequestResult.RetryableNetworkError(cdnResult.networkError)
is RequestResult.ApplicationError -> return@withContext RequestResult.ApplicationError(cdnResult.cause)
}
try {
@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms
import arrow.core.Either
import kotlinx.coroutines.runBlocking
import okio.ByteString
import org.signal.archive.proto.AccountData
import org.signal.archive.proto.BackupDebugInfo
@@ -14,7 +16,6 @@ import org.signal.core.util.bytes
import org.signal.core.util.decodeOrNull
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.NetworkResult
import org.signal.network.api.SvrBApi
import org.signal.spinner.Plugin
import org.signal.spinner.PluginResult
@@ -58,10 +59,10 @@ class BackupPlugin : Plugin {
val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application)
when (val result = BackupRepository.downloadBackupFile(tempBackupFile)) {
is NetworkResult.Success -> Log.i(TAG, "Download successful")
else -> {
Log.w(TAG, "Failed to download backup file", result.getCause())
return result.getCause().toString()
is Either.Right -> Log.i(TAG, "Download successful")
is Either.Left -> {
Log.w(TAG, "Failed to download backup file", result.value.cause)
return result.value.toString()
}
}
@@ -70,9 +71,9 @@ class BackupPlugin : Plugin {
return "Failed to read forward secrecy metadata!"
}
val svrBAuth = when (val result = BackupRepository.getSvrBAuth()) {
is NetworkResult.Success -> result.result
else -> return "Failed to read forward secrecy metadata!"
val svrBAuth = when (val result = runBlocking { AppDependencies.archiveService.getSvrBAuth() }) {
is Either.Right -> result.value
is Either.Left -> return "Failed to read forward secrecy metadata!"
}
val forwardSecrecyToken = when (val result = SignalNetwork.svrB.restore(svrBAuth, SignalStore.backup.messageBackupKey, forwardSecrecyMetadata)) {
@@ -103,6 +103,18 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
return mockk(relaxed = true)
}
override fun provideArchiveApiV2(
authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket,
signalServiceConfiguration: SignalServiceConfiguration
): org.signal.network.api.ArchiveApiV2 {
return mockk(relaxed = true)
}
override fun provideArchiveService(archiveApi: org.signal.network.api.ArchiveApiV2): org.signal.network.service.ArchiveService {
return mockk(relaxed = true)
}
override fun provideMessageService(
protocolStore: SignalServiceDataStore,
messageApiV2: org.signal.network.api.MessageApiV2,
@@ -265,7 +277,7 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
return mockk(relaxed = true)
}
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
override fun provideArchiveApi(pushServiceSocket: PushServiceSocket): ArchiveApi {
return mockk(relaxed = true)
}
@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.jobs
import android.app.Application
import arrow.core.right
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
@@ -22,7 +23,6 @@ import org.robolectric.annotation.Config
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSourceType
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
@@ -451,7 +451,7 @@ class InAppPaymentRecurringContextJobTest {
}
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } returns NetworkResult.Success(MessageBackupTier.PAID)
every { BackupRepository.getBackupTier() } returns MessageBackupTier.PAID.right()
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
val iap = insertInAppPayment(
@@ -1,24 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Response body for getting the media items stored in the user's archive.
*/
class ArchiveGetMediaItemsResponse(
@JsonProperty val storedMediaObjects: List<StoredMediaObject>,
@JsonProperty val backupDir: String?,
@JsonProperty val mediaDir: String?,
@JsonProperty val cursor: String?
) {
data class StoredMediaObject(
@JsonProperty val cdn: Int,
@JsonProperty val mediaId: String,
@JsonProperty val objectLength: Long
)
}
@@ -1,11 +0,0 @@
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents the response when fetching the archive backup key rotation limits
*/
data class ArchiveKeyRotationLimitResponse(
@JsonProperty val hasPermitsRemaining: Boolean?,
@JsonProperty val retryAfterSeconds: Long?
)
@@ -1,26 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
/**
* Status codes for the ArchiveMediaUploadForm endpoint.
*
* Kept in a separate class because [AttachmentUploadForm] (the model the request returns) is used for multiple endpoints with different status codes.
*/
enum class ArchiveMediaUploadFormStatusCodes(val code: Int) {
BadArguments(400),
InvalidPresentationOrSignature(401),
InsufficientPermissions(403),
MediaTooLarge(413),
RateLimited(429),
Unknown(-1);
companion object {
fun from(code: Int): ArchiveMediaUploadFormStatusCodes {
return entries.firstOrNull { it.code == code } ?: Unknown
}
}
}
@@ -1,39 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
import okio.IOException
/**
* Represents the result of fetching archive credentials.
* See [ArchiveServiceCredential].
*/
class ArchiveServiceCredentialsResponse(
@JsonProperty
val credentials: Map<String, List<ArchiveServiceCredential>>
) {
companion object {
private const val KEY_MESSAGES = "messages"
private const val KEY_MEDIA = "media"
}
init {
if (!credentials.containsKey(KEY_MESSAGES)) {
throw IOException("Missing key '$KEY_MESSAGES'")
}
if (!credentials.containsKey(KEY_MEDIA)) {
throw IOException("Missing key '$KEY_MEDIA'")
}
}
val messageCredentials: List<ArchiveServiceCredential>
get() = credentials[KEY_MESSAGES]!!
val mediaCredentials: List<ArchiveServiceCredential>
get() = credentials[KEY_MEDIA]!!
}
@@ -1,32 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.archive
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import org.signal.core.util.Base64
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest
/**
* Represents the request body when setting the archive backupId.
*/
class ArchiveSetBackupIdRequest(
@JsonProperty
@JsonSerialize(using = BackupAuthCredentialRequestSerializer::class)
val messagesBackupAuthCredentialRequest: BackupAuthCredentialRequest?,
@JsonProperty
@JsonSerialize(using = BackupAuthCredentialRequestSerializer::class)
val mediaBackupAuthCredentialRequest: BackupAuthCredentialRequest?
) {
class BackupAuthCredentialRequestSerializer : JsonSerializer<BackupAuthCredentialRequest>() {
override fun serialize(value: BackupAuthCredentialRequest, gen: JsonGenerator, serializers: SerializerProvider) {
gen.writeString(Base64.encodeWithPadding(value.serialize()))
}
}
}
@@ -5,205 +5,26 @@
package org.signal.network.api
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.libsignal.internal.CompletableFuture
import org.signal.libsignal.net.BackupAuth
import org.signal.libsignal.net.BadRequestError
import org.signal.libsignal.net.CopyBackupMediaItem
import org.signal.libsignal.net.CopyBackupMediaOutcome
import org.signal.libsignal.net.DeleteBackupMediaItem
import org.signal.libsignal.net.GetUploadFormError
import org.signal.libsignal.net.MediaBackupInfo
import org.signal.libsignal.net.MessageBackupInfo
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RequestUnauthorizedException
import org.signal.libsignal.net.UnauthBackupsService
import org.signal.libsignal.net.UnauthenticatedChatConnection
import org.signal.libsignal.net.UploadForm
import org.signal.libsignal.net.UploadTooLargeException
import org.signal.libsignal.net.toRequestResult
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.zkgroup.GenericServerPublicParams
import org.signal.libsignal.zkgroup.backups.BackupAuthCredential
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.get
import org.signal.network.websocket.put
import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse
import org.whispersystems.signalservice.api.archive.ArchiveSetBackupIdRequest
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import org.whispersystems.signalservice.api.fromWebSocketRequest
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.io.InputStream
import java.time.Instant
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
/**
* Class to interact with various archive-related endpoints.
* Why is it called archive instead of backup? Because SVR took the "backup" endpoint namespace first :)
* What's left of the original archive API after [ArchiveApiV2] took over the endpoints.
*
* Backup file upload still lives here because it isn't a chat-connection request at all -- it's a long-running, resumable CDN transfer driven by
* [PushServiceSocket], with progress reporting and crash-recovery semantics that [org.signal.libsignal.net.RequestResult] doesn't model. Everything else has
* moved; prefer [ArchiveApiV2] (or [org.signal.network.service.ArchiveService]) for new work.
*/
class ArchiveApi(
private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket,
private val pushServiceSocket: PushServiceSocket,
private val backupServerPublicParams: GenericServerPublicParams
) {
class ArchiveApi(private val pushServiceSocket: PushServiceSocket) {
companion object {
private val TAG = Log.tag(ArchiveApi::class)
}
/**
* Retrieves a set of credentials one can use to authorize other requests.
*
* You'll receive a set of credentials spanning 7 days. Cache them and store them for later use.
* It's important that (at least in the common case) you do not request credentials on-the-fly.
* Instead, request them in advance on a regular schedule. This is because the purpose of these
* credentials is to keep the caller anonymous, but that doesn't help if this authenticated request
* happens right before all of the unauthenticated ones, as that would make it easier to correlate
* traffic.
*
* GET /v1/archives/auth
*
* - 200: Success
* - 400: Bad start/end times
* - 404: BackupId could not be found
* - 429: Rate-limited
*/
fun getServiceCredentials(currentTime: Long): NetworkResult<ArchiveServiceCredentialsResponse> {
val roundedToNearestDay = currentTime.milliseconds.inWholeDays.days
val endTime = roundedToNearestDay + 7.days
val request = WebSocketRequestMessage.get("/v1/archives/auth?redemptionStartSeconds=${roundedToNearestDay.inWholeSeconds}&redemptionEndSeconds=${endTime.inWholeSeconds}")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ArchiveServiceCredentialsResponse::class)
}
/**
* Gets credentials needed to read from the CDN. Make sure you use the right [archiveServiceAccess] depending on whether you're doing a message or media
* operation.
*
* - 401: Bad presentation, invalid public key signature, no matching backupId on the server, or the credential was of the wrong type (messages/media)
* - 429: Rate-limited
*/
fun getCdnReadCredentials(cdnNumber: Int, aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<GetArchiveCdnCredentialsResponse> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getCdnCredentials(auth, cdnNumber)
}.toNetworkResult().map { GetArchiveCdnCredentialsResponse(it.headers) }
}
/**
* Ensures that you reserve backupIds for both messages and media on the service. This must be done before any other
* backup-related calls. You only need to do it once, but repeated calls are safe.
*
* Passing null for either key will skip reserving for that backup and not cost a rate limit permit.
*
* PUT /v1/archives/backupid
*
* - 204: Success
* - 400: Invalid credential
* - 429: Rate-limited
*/
fun triggerBackupIdReservation(messageBackupKey: MessageBackupKey?, mediaRootBackupKey: MediaRootBackupKey?, aci: ACI): NetworkResult<Unit> {
val messageBackupRequestContext = messageBackupKey?.let { BackupAuthCredentialRequestContext.create(messageBackupKey.value, aci.rawUuid) }
val mediaBackupRequestContext = mediaRootBackupKey?.let { BackupAuthCredentialRequestContext.create(mediaRootBackupKey.value, aci.rawUuid) }
val request = WebSocketRequestMessage.put(
"/v1/archives/backupid",
ArchiveSetBackupIdRequest(messageBackupRequestContext?.request, mediaBackupRequestContext?.request)
)
return NetworkResult.fromWebSocketRequest(authWebSocket, request)
}
/**
* Sets a public key on the service derived from your [MessageBackupKey]. This key is used to prevent
* unauthorized users from changing your backup data. You only need to do it once, but repeated
* calls are safe.
*
* - 401: The credential in particular is invalid, since the key is being updated
* - 429: Rate-limited
*/
fun setPublicKey(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).setPublicKey(auth) }.toNetworkResult()
}
/**
* Fetches an upload form you can use to upload your main message backup file to cloud storage.
*
* - 401: Authorization failed
* - 413: The backup is too large
* - 429: Rate-limited
*/
fun getMessageBackupUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>, backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getUploadForm(auth, backupFileSize)
}.toUploadFormNetworkResult().map { it.toAttachmentUploadForm() }
}
/**
* Fetches metadata about the currently-stored message backup.
*
* - 401: Authorization failed. Note that the server does not distinguish an invalid credential from a backup-id that was never provisioned, so callers using
* this to check whether a backup exists should treat a 401 as "backups not set up" rather than a fatal error.
* - 429: Rate-limited
*/
fun getMessageBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>): NetworkResult<MessageBackupInfo> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).getMessageBackupInfo(auth) }.toNetworkResult()
}
/**
* Fetches metadata about the currently-stored media backup, including how much space it uses.
*
* - 401: Authorization failed. Note that the server does not distinguish an invalid credential from a backup-id that was never provisioned, so callers using
* this to check whether a backup exists should treat a 401 as "backups not set up" rather than a fatal error.
* - 429: Rate-limited
*/
fun getMediaBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>): NetworkResult<MediaBackupInfo> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).getMediaBackupInfo(auth) }.toNetworkResult()
}
/**
* Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh. If a backup is not refreshed, after 30 days
* it may be deleted.
*
* - 401: Authorization failed
* - 429: Rate-limited
*/
fun refreshBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).refresh(auth) }.toNetworkResult()
}
/**
* Delete all backup metadata, objects, and stored public key. To use backups again, a public key must be resupplied.
*
* - 401: Authorization failed
* - 429: Rate-limited
*/
fun deleteBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Unit> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).deleteAll(auth) }.toNetworkResult()
}
/**
* Uploads a pre-encrypted backup file, automatically choosing the best upload strategy based on CDN version.
* For CDN3, uses TUS "Creation With Upload" (single POST). For other CDNs, falls back to the legacy
@@ -239,251 +60,4 @@ class ArchiveApi(
}
}
}
/**
* Retrieves an [AttachmentUploadForm] that can be used to upload pre-existing media to the archive.
*
* This is basically the same as [org.signal.network.api.AttachmentApi.getAttachmentV4UploadForm], but with a relaxed rate limit
* so we can request them more often (which is required for backfilling).
*
* After uploading, the media still needs to be copied via [copyMediaToArchive].
*
* - 401: Authorization failed
* - 413: The media is too large
* - 429: Rate-limited
*/
fun getMediaUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, uploadLength: Long): NetworkResult<AttachmentUploadForm> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getMediaUploadForm(auth, uploadLength)
}.toUploadFormNetworkResult().map { it.toAttachmentUploadForm() }
}
/**
* Retrieves all media items in the user's archive. Note that this could be a very large number of items, making this only suitable for debugging.
* Use [getArchiveMediaItemsPage] in production.
*/
fun debugGetUploadedMediaItemMetadata(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>): NetworkResult<List<StoredMediaObject>> {
return NetworkResult.fromFetch {
val mediaObjects: MutableList<StoredMediaObject> = ArrayList()
var cursor: String? = null
do {
val response: ArchiveGetMediaItemsResponse = getArchiveMediaItemsPage(aci, archiveServiceAccess, 10_000, cursor).successOrThrow()
mediaObjects += response.storedMediaObjects
cursor = response.cursor
} while (cursor != null)
mediaObjects
}
}
/**
* Retrieves a page of media items in the user's archive.
*
* GET /v1/archives/media?limit={limit}&cursor={cursor}
*
* - 200: Success
* - 400: Bad request, or made on authenticated channel
* - 403: Forbidden
* - 429: Rate-limited
*
* @param limit The maximum number of items to return.
* @param cursor A token that can be read from your previous response, telling the server where to start the next page.
*/
fun getArchiveMediaItemsPage(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, limit: Int, cursor: String?): NetworkResult<ArchiveGetMediaItemsResponse> {
return getCredentialPresentationHeaders(aci, archiveServiceAccess)
.then { headers ->
val request = WebSocketRequestMessage.get("/v1/archives/media?limit=$limit${if (cursor.isNotNullOrBlank()) "&cursor=$cursor" else ""}", headers)
NetworkResult.fromWebSocketRequest(unauthWebSocket, request, ArchiveGetMediaItemsResponse::class)
}
}
/**
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
*
* The copy operation is not atomic: each item gets its own [CopyBackupMediaOutcome], and there is no need to retry items that produced one. If the stream
* terminates early, the returned list only contains the outcomes received so far, so a partial success is reported as a failure carrying no outcomes.
*
* - 401: Authorization failed. Because large batches span multiple server requests, this can happen partway through.
* - 429: Rate-limited
*/
fun copyMediaToArchive(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>,
items: List<CopyBackupMediaItem>
): NetworkResult<List<CopyBackupMediaOutcome>> {
return collectBackupMediaStream(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).copyMedia(auth, items)
}.toNetworkResult()
}
/**
* Delete media from the backup cdn.
*
* Like [copyMediaToArchive], the operation is not atomic and a stream that terminates early reports failure rather than a partial result.
*
* - 401: Authorization failed
* - 429: Rate-limited
*/
fun deleteArchivedMedia(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>,
mediaToDelete: List<DeleteBackupMediaItem>
): NetworkResult<List<DeleteBackupMediaItem>> {
return collectBackupMediaStream(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).deleteMedia(auth, mediaToDelete)
}.toNetworkResult()
}
/**
* Retrieves auth credentials that can be used to perform SVR-B operations.
*
* - 401: Authorization failed
*/
fun getSvrBAuthorization(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>): NetworkResult<AuthCredentials> {
return runWithBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getSvrBCredentials(auth)
}.toNetworkResult().map { (username, password) -> AuthCredentials.create(username, password) }
}
/**
* Determine whether the backup-id can currently be rotated
*
* GET /v1/archives/backupid/limits
* - 200: Successfully retrieved backup-id rotation limits
* - 403: Invalid account authentication
*/
fun getKeyRotationLimit(): NetworkResult<ArchiveKeyRotationLimitResponse> {
val request = WebSocketRequestMessage.get("/v1/archives/backupid/limits")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ArchiveKeyRotationLimitResponse::class)
}
/**
* Issues an anonymous backup request over the unauthenticated chat connection, deriving the [BackupAuth] from [archiveServiceAccess]. Failing to derive it is
* a local programming error, so it surfaces as [RequestResult.ApplicationError].
*/
private fun <T, E : BadRequestError> runWithBackupAuth(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<*>,
block: (UnauthenticatedChatConnection, BackupAuth) -> CompletableFuture<RequestResult<T, E>>
): RequestResult<T, E> {
val auth = try {
getBackupAuth(aci, archiveServiceAccess)
} catch (e: Throwable) {
return RequestResult.ApplicationError(e)
}
return runBlocking {
unauthWebSocket.runCatchingWithChatConnection { connection -> block(connection, auth) }
}
}
/**
* Drains a per-item backup media stream into a list. A stream that terminates early throws, which we classify the same way the non-streaming endpoints do.
*/
private fun <T> collectBackupMediaStream(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<*>,
block: (UnauthenticatedChatConnection, BackupAuth) -> Flow<T>
): RequestResult<List<T>, RequestUnauthorizedException> {
val auth = try {
getBackupAuth(aci, archiveServiceAccess)
} catch (e: Throwable) {
return RequestResult.ApplicationError(e)
}
return runBlocking {
try {
val stream = unauthWebSocket.withChatConnection { connection -> block(connection, auth) }
RequestResult.Success(stream.toList())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.toRequestResult<RequestUnauthorizedException>()
}
}
}
/**
* Builds the anonymous-credential headers for the archive endpoints that still go out as hand-rolled websocket requests.
*/
private fun getCredentialPresentationHeaders(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<Map<String, String>> {
return NetworkResult.fromLocal {
val privateKey: ECPrivateKey = archiveServiceAccess.backupKey.deriveAnonymousCredentialPrivateKey(aci)
val presentation: ByteArray = getZkCredential(aci, archiveServiceAccess).present(backupServerPublicParams).serialize()
ArchiveCredentialPresentation(
presentation = presentation,
signedPresentation = privateKey.calculateSignature(presentation)
).toHeaders()
}
}
private fun getBackupAuth(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): BackupAuth {
return BackupAuth(
credential = getZkCredential(aci, archiveServiceAccess),
serverKeys = backupServerPublicParams,
signingKey = archiveServiceAccess.backupKey.deriveAnonymousCredentialPrivateKey(aci)
)
}
private fun UploadForm.toAttachmentUploadForm(): AttachmentUploadForm {
return AttachmentUploadForm(
cdn = cdn,
key = key,
headers = headers,
signedUploadLocation = signedUploadUrl.toString()
)
}
/**
* Converts a libsignal result into a [NetworkResult] for this class's callers, which still chain [NetworkResult].
*
* Note that libsignal reports both HTTP 401 and 403 as [RequestUnauthorizedException] ("incorrect or insufficient" authorization), so a caller that used to
* distinguish "bad credential" from "insufficient permissions" only ever sees the 401.
*/
private fun <T> RequestResult<T, RequestUnauthorizedException>.toNetworkResult(): NetworkResult<T> {
return when (this) {
is RequestResult.Success -> NetworkResult.Success(result)
is RequestResult.NonSuccess -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(401, "Unauthorized"))
is RequestResult.RetryableNetworkError -> toNetworkResult()
is RequestResult.ApplicationError -> NetworkResult.ApplicationError(cause)
}
}
/**
* [toNetworkResult] for the upload-form endpoints, which can additionally reject the requested size.
*/
private fun <T> RequestResult<T, GetUploadFormError>.toUploadFormNetworkResult(): NetworkResult<T> {
return when (this) {
is RequestResult.Success -> NetworkResult.Success(result)
is RequestResult.NonSuccess -> when (error) {
is UploadTooLargeException -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(413, "Upload too large"))
is RequestUnauthorizedException -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(401, "Unauthorized"))
}
is RequestResult.RetryableNetworkError -> toNetworkResult()
is RequestResult.ApplicationError -> NetworkResult.ApplicationError(cause)
}
}
/**
* A rate limit becomes a synthetic 429 carrying a `retry-after` header, since that's where [NetworkResult.StatusCodeError.retryAfter] looks for it.
*/
private fun <T> RequestResult.RetryableNetworkError.toNetworkResult(): NetworkResult<T> {
return when (val retryAfter = retryAfter) {
null -> NetworkResult.NetworkError(networkError)
else -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(429, "Rate limited", null as String?, mapOf("retry-after" to retryAfter.seconds.toString())))
}
}
fun getZkCredential(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): BackupAuthCredential {
val backupAuthResponse = BackupAuthCredentialResponse(archiveServiceAccess.credential.credential)
val backupRequestContext = BackupAuthCredentialRequestContext.create(archiveServiceAccess.backupKey.value, aci.rawUuid)
return backupRequestContext.receiveResponse(
backupAuthResponse,
Instant.ofEpochSecond(archiveServiceAccess.credential.redemptionTime),
backupServerPublicParams
)
}
}
@@ -0,0 +1,574 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network.api
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.serialization.Serializable
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Base64
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
import org.signal.core.util.serialization.SignalJson
import org.signal.libsignal.net.BackupAuth
import org.signal.libsignal.net.BadRequestError
import org.signal.libsignal.net.CopyBackupMediaItem
import org.signal.libsignal.net.CopyBackupMediaOutcome
import org.signal.libsignal.net.DeleteBackupMediaItem
import org.signal.libsignal.net.GetUploadFormError
import org.signal.libsignal.net.MediaBackupInfo
import org.signal.libsignal.net.MessageBackupInfo
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RequestUnauthorizedException
import org.signal.libsignal.net.ServerSideErrorException
import org.signal.libsignal.net.UnauthBackupsService
import org.signal.libsignal.net.UnauthenticatedChatConnection
import org.signal.libsignal.net.UploadForm
import org.signal.libsignal.net.toRequestResult
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.zkgroup.GenericServerPublicParams
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupAuthCredential
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
import org.signal.network.websocket.WebSocketRequestMessage
import org.signal.network.websocket.WebsocketResponse
import org.signal.network.websocket.get
import org.signal.network.websocket.put
import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
import java.io.IOException
import java.time.Instant
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
/**
* Collection of archive-related endpoints.
*/
class ArchiveApiV2(
private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket,
private val backupServerPublicParams: GenericServerPublicParams
) {
companion object {
private const val KEY_MESSAGES = "messages"
private const val KEY_MEDIA = "media"
}
/**
* Retrieves a set of credentials one can use to authorize other requests.
*
* You'll receive a set of credentials spanning 7 days. Cache them and store them for later use. It's important that (at least in the common case) you do not
* request credentials on-the-fly. Instead, request them in advance on a regular schedule. This is because the purpose of these credentials is to keep the
* caller anonymous, but that doesn't help if this authenticated request happens right before all of the unauthenticated ones, as that would make it easier to
* correlate traffic.
*
* GET /v1/archives/auth
*/
suspend fun getServiceCredentials(currentTime: Long): RequestResult<ArchiveCredentials, GetServiceCredentialsError> {
val roundedToNearestDay = currentTime.milliseconds.inWholeDays.days
val endTime = roundedToNearestDay + 7.days
val request = WebSocketRequestMessage.get("/v1/archives/auth?redemptionStartSeconds=${roundedToNearestDay.inWholeSeconds}&redemptionEndSeconds=${endTime.inWholeSeconds}")
return authWebSocket.requestResult(
request = request,
parseSuccess = { response ->
val body = SignalJson.decode(ArchiveCredentialsBody.serializer(), response.body).getOrNull()
?: throw IOException("Unparseable archive credentials response")
ArchiveCredentials(
messageCredentials = body.credentials[KEY_MESSAGES]?.map { it.toArchiveServiceCredential() } ?: throw IOException("Missing key '$KEY_MESSAGES'"),
mediaCredentials = body.credentials[KEY_MEDIA]?.map { it.toArchiveServiceCredential() } ?: throw IOException("Missing key '$KEY_MEDIA'")
)
},
mapError = { response ->
when (response.status) {
400 -> GetServiceCredentialsError.InvalidRedemptionTimes
401 -> GetServiceCredentialsError.Unauthorized
404 -> GetServiceCredentialsError.BackupIdNotFound
429 -> GetServiceCredentialsError.RateLimited(response.retryAfter())
else -> null
}
}
)
}
/**
* Ensures that you reserve backupIds for both messages and media on the service. This must be done before any other backup-related calls. You only need to do
* it once, but repeated calls are safe.
*
* Passing null for either key will skip reserving for that backup and not cost a rate limit permit.
*
* PUT /v1/archives/backupid
*/
suspend fun triggerBackupIdReservation(messageBackupKey: MessageBackupKey?, mediaRootBackupKey: MediaRootBackupKey?, aci: ACI): RequestResult<Unit, SetBackupIdError> {
val body = try {
SetBackupIdBody(
messagesBackupAuthCredentialRequest = messageBackupKey?.let { BackupAuthCredentialRequestContext.create(it.value, aci.rawUuid).request.serialize().toBase64() },
mediaBackupAuthCredentialRequest = mediaRootBackupKey?.let { BackupAuthCredentialRequestContext.create(it.value, aci.rawUuid).request.serialize().toBase64() }
).encode()
} catch (e: Throwable) {
return RequestResult.ApplicationError(e)
}
return authWebSocket.requestResult(
request = WebSocketRequestMessage.put("/v1/archives/backupid", body),
parseSuccess = { },
mapError = { response ->
when (response.status) {
400 -> SetBackupIdError.InvalidCredential
401 -> SetBackupIdError.Unauthorized
429 -> SetBackupIdError.RateLimited(response.retryAfter())
else -> null
}
}
)
}
/**
* Determine whether the backup-id can currently be rotated.
*
* GET /v1/archives/backupid/limits
*/
suspend fun getKeyRotationLimit(): RequestResult<KeyRotationLimit, GetKeyRotationLimitError> {
return authWebSocket.requestResult(
request = WebSocketRequestMessage.get("/v1/archives/backupid/limits"),
parseSuccess = { response ->
SignalJson.decode(KeyRotationLimit.serializer(), response.body).getOrNull()
?: throw IOException("Unparseable key rotation limit response")
},
mapError = { response ->
when (response.status) {
403 -> GetKeyRotationLimitError.Forbidden
else -> null
}
}
)
}
/**
* Retrieves a page of media items in the user's archive.
*
* GET /v1/archives/media?limit={limit}&cursor={cursor}
*
* @param limit The maximum number of items to return.
* @param cursor A token that can be read from your previous response, telling the server where to start the next page.
*/
suspend fun getArchiveMediaItemsPage(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, limit: Int, cursor: String?): RequestResult<MediaItemsPage, GetMediaItemsError> {
val headers = when (val result = getCredentialPresentationHeaders(aci, archiveServiceAccess)) {
is ZkCredentialResult.Success -> result.value
is ZkCredentialResult.Failure -> return RequestResult.ApplicationError(result.exception)
}
val request = WebSocketRequestMessage.get("/v1/archives/media?limit=$limit${if (cursor.isNotNullOrBlank()) "&cursor=$cursor" else ""}", headers)
return unauthWebSocket.requestResult(
request = request,
parseSuccess = { response ->
SignalJson.decode(MediaItemsPage.serializer(), response.body).getOrNull()
?: throw IOException("Unparseable media items response")
},
mapError = { response ->
when (response.status) {
400 -> GetMediaItemsError.InvalidRequest
401 -> GetMediaItemsError.Unauthorized
403 -> GetMediaItemsError.Forbidden
429 -> GetMediaItemsError.RateLimited(response.retryAfter())
else -> null
}
}
)
}
/**
* Gets credentials needed to read from the CDN. Make sure you use the right [archiveServiceAccess] depending on whether you're doing a message or media
* operation.
*/
suspend fun getCdnReadCredentials(cdnNumber: Int, aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): RequestResult<GetArchiveCdnCredentialsResponse, RequestUnauthorizedException> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getCdnCredentials(auth, cdnNumber)
}.map { GetArchiveCdnCredentialsResponse(it.headers) }
}
/**
* Sets a public key on the service derived from your [MessageBackupKey]. This key is used to prevent unauthorized users from changing your backup data. You
* only need to do it once, but repeated calls are safe.
*/
suspend fun setPublicKey(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): RequestResult<Unit, RequestUnauthorizedException> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth -> UnauthBackupsService(connection).setPublicKey(auth) }
}
/**
* Fetches an upload form you can use to upload your main message backup file to cloud storage.
*/
suspend fun getMessageBackupUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>, backupFileSize: Long): RequestResult<AttachmentUploadForm, GetUploadFormError> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getUploadForm(auth, backupFileSize)
}.map { it.toAttachmentUploadForm() }
}
/**
* Retrieves an upload form that can be used to upload pre-existing media to the archive.
*
* This is basically the same as [AttachmentApi.getAttachmentV4UploadForm], but with a relaxed rate limit so we can request them more often (which is required
* for backfilling). After uploading, the media still needs to be copied via [copyMediaToArchive].
*/
suspend fun getMediaUploadForm(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, uploadLength: Long): RequestResult<AttachmentUploadForm, GetUploadFormError> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getMediaUploadForm(auth, uploadLength)
}.map { it.toAttachmentUploadForm() }
}
/**
* Fetches metadata about the currently-stored message backup.
*
* Note that the server does not distinguish an invalid credential from a backup-id that was never provisioned, so callers using this to check whether a backup
* exists should treat [RequestUnauthorizedException] as "backups not set up" rather than a fatal error.
*/
suspend fun getMessageBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>): RequestResult<MessageBackupInfo, RequestUnauthorizedException> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getMessageBackupInfo(auth)
}
}
/**
* Fetches metadata about the currently-stored media backup, including how much space it uses. Carries the same 401 caveat as [getMessageBackupInfo].
*/
suspend fun getMediaBackupInfo(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>): RequestResult<MediaBackupInfo, RequestUnauthorizedException> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getMediaBackupInfo(auth)
}
}
/**
* Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh. If a backup is not refreshed, after 30 days
* it may be deleted.
*/
suspend fun refreshBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): RequestResult<Unit, RequestUnauthorizedException> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).refresh(auth)
}
}
/**
* Delete all backup metadata, objects, and stored public key. To use backups again, a public key must be resupplied.
*/
suspend fun deleteBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): RequestResult<Unit, RequestUnauthorizedException> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).deleteAll(auth)
}
}
/**
* Retrieves auth credentials that can be used to perform SVR-B operations.
*/
suspend fun getSvrBAuthorization(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MessageBackupKey>): RequestResult<AuthCredentials, RequestUnauthorizedException> {
return withBackupAuth(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).getSvrBCredentials(auth)
}.map { (username, password) -> AuthCredentials.create(username, password) }
}
/**
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
*
* The copy operation is not atomic: each item gets its own [CopyBackupMediaOutcome], and there is no need to retry items that produced one. If the stream
* terminates early, the returned list only contains the outcomes received so far, so a partial success is reported as a failure carrying no outcomes.
*/
suspend fun copyMediaToArchive(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, items: List<CopyBackupMediaItem>): RequestResult<List<CopyBackupMediaOutcome>, RequestUnauthorizedException> {
return collectBackupMediaStream(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).copyMedia(auth, items)
}
}
/**
* Delete media from the backup cdn. Like [copyMediaToArchive], the operation is not atomic and a stream that terminates early reports failure rather than a
* partial result.
*/
suspend fun deleteArchivedMedia(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<MediaRootBackupKey>, mediaToDelete: List<DeleteBackupMediaItem>): RequestResult<List<DeleteBackupMediaItem>, RequestUnauthorizedException> {
return collectBackupMediaStream(aci, archiveServiceAccess) { connection, auth ->
UnauthBackupsService(connection).deleteMedia(auth, mediaToDelete)
}
}
/**
* Derives the zkgroup credential backing [archiveServiceAccess]. Purely local, so it can only fail over the inputs it was handed.
*/
fun getZkCredential(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): ZkCredentialResult<BackupAuthCredential> {
val backupAuthResponse = try {
BackupAuthCredentialResponse(archiveServiceAccess.credential.credential)
} catch (e: InvalidInputException) {
return ZkCredentialResult.Failure.MalformedCredential(e)
}
val backupRequestContext = BackupAuthCredentialRequestContext.create(archiveServiceAccess.backupKey.value, aci.rawUuid)
return try {
ZkCredentialResult.Success(
backupRequestContext.receiveResponse(
backupAuthResponse,
Instant.ofEpochSecond(archiveServiceAccess.credential.redemptionTime),
backupServerPublicParams
)
)
} catch (e: VerificationFailedException) {
ZkCredentialResult.Failure.VerificationFailed(e)
}
}
/**
* Issues a hand-rolled REST-over-websocket request, classifying the outcome the same way libsignal classifies its own.
*
* A 5xx is reported as [RequestResult.RetryableNetworkError] rather than being offered to [mapError], because a server-side failure is transient no matter
* which endpoint produced it, and callers uniformly want to back off rather than treat it as a decision point.
*
* A null from [mapError] on any other code means the status isn't one this endpoint documents. That's also a [RequestResult.RetryableNetworkError], not an
* [RequestResult.ApplicationError]: the server can start returning a code we don't model yet without us shipping, so callers should back off rather than treat
* it as a local bug. Several callers escalate an application error to a crash, which is the wrong response to an unexpected status code.
*
* Both of those carry a [ServerSideErrorException], which is what libsignal uses for a server-side failure on the endpoints it owns. That lets a caller that
* wants a longer backoff for "the server is unhappy" than for "we couldn't reach the server" tell them apart, and get the same answer either way.
*/
private suspend fun <T, E : BadRequestError> SignalWebSocket.requestResult(
request: WebSocketRequestMessage,
parseSuccess: (WebsocketResponse) -> T,
mapError: (WebsocketResponse) -> E?
): RequestResult<T, E> {
return try {
val response = requestSuspend(request)
when {
response.status in 200..299 -> RequestResult.Success(parseSuccess(response))
response.status in 500..599 -> RequestResult.RetryableNetworkError(ServerSideErrorException("Server error: ${response.status}"))
else -> when (val error = mapError(response)) {
null -> RequestResult.RetryableNetworkError(ServerSideErrorException("Unexpected response code: ${response.status}"))
else -> RequestResult.NonSuccess(error)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Throwable) {
RequestResult.ApplicationError(e)
}
}
/**
* Issues an anonymous backup request over the unauthenticated chat connection, deriving the [BackupAuth] from [archiveServiceAccess]. Failing to derive it is
* a local programming error, so it surfaces as [RequestResult.ApplicationError].
*/
private suspend fun <T, E : BadRequestError> withBackupAuth(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<*>,
block: (UnauthenticatedChatConnection, BackupAuth) -> org.signal.libsignal.internal.CompletableFuture<RequestResult<T, E>>
): RequestResult<T, E> {
val auth = when (val result = getBackupAuth(aci, archiveServiceAccess)) {
is ZkCredentialResult.Success -> result.value
is ZkCredentialResult.Failure -> return RequestResult.ApplicationError(result.exception)
}
return unauthWebSocket.runCatchingWithChatConnection { connection -> block(connection, auth) }
}
/**
* Drains a per-item backup media stream into a list. A stream that terminates early throws, which we classify the same way the non-streaming endpoints do.
*/
private suspend fun <T> collectBackupMediaStream(
aci: ACI,
archiveServiceAccess: ArchiveServiceAccess<*>,
block: (UnauthenticatedChatConnection, BackupAuth) -> Flow<T>
): RequestResult<List<T>, RequestUnauthorizedException> {
val auth = when (val result = getBackupAuth(aci, archiveServiceAccess)) {
is ZkCredentialResult.Success -> result.value
is ZkCredentialResult.Failure -> return RequestResult.ApplicationError(result.exception)
}
return try {
val stream = unauthWebSocket.withChatConnection { connection -> block(connection, auth) }
RequestResult.Success(stream.toList())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.toRequestResult<RequestUnauthorizedException>()
}
}
/**
* Builds the anonymous-credential headers for the archive endpoints that still go out as hand-rolled websocket requests.
*/
private fun getCredentialPresentationHeaders(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): ZkCredentialResult<Map<String, String>> {
val credential = when (val result = getZkCredential(aci, archiveServiceAccess)) {
is ZkCredentialResult.Success -> result.value
is ZkCredentialResult.Failure -> return result
}
val privateKey: ECPrivateKey = archiveServiceAccess.backupKey.deriveAnonymousCredentialPrivateKey(aci)
val presentation: ByteArray = credential.present(backupServerPublicParams).serialize()
return ZkCredentialResult.Success(
ArchiveCredentialPresentation(
presentation = presentation,
signedPresentation = privateKey.calculateSignature(presentation)
).toHeaders()
)
}
private fun getBackupAuth(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): ZkCredentialResult<BackupAuth> {
val credential = when (val result = getZkCredential(aci, archiveServiceAccess)) {
is ZkCredentialResult.Success -> result.value
is ZkCredentialResult.Failure -> return result
}
return ZkCredentialResult.Success(
BackupAuth(
credential = credential,
serverKeys = backupServerPublicParams,
signingKey = archiveServiceAccess.backupKey.deriveAnonymousCredentialPrivateKey(aci)
)
)
}
private fun <T, E : BadRequestError, R> RequestResult<T, E>.map(transform: (T) -> R): RequestResult<R, E> {
return when (this) {
is RequestResult.Success -> RequestResult.Success(transform(result))
is RequestResult.NonSuccess -> this
is RequestResult.RetryableNetworkError -> this
is RequestResult.ApplicationError -> this
}
}
private fun UploadForm.toAttachmentUploadForm(): AttachmentUploadForm {
return AttachmentUploadForm(
cdn = cdn,
key = key,
headers = headers,
signedUploadLocation = signedUploadUrl.toString()
)
}
private fun ByteArray.toBase64(): String = Base64.encodeWithPadding(this)
/** The credentials for both archives, spanning the next several days. */
data class ArchiveCredentials(
val messageCredentials: List<ArchiveServiceCredential>,
val mediaCredentials: List<ArchiveServiceCredential>
)
/**
* A page of the media stored in the user's archive.
*
* The server also returns `backupDir`/`mediaDir` here, but [getMediaBackupInfo] is the authoritative source for those, so they're omitted.
*/
@Serializable
data class MediaItemsPage(
val storedMediaObjects: List<StoredMediaObject> = emptyList(),
val cursor: String? = null
)
@Serializable
data class StoredMediaObject(
val cdn: Int,
val mediaId: String,
val objectLength: Long
)
@Serializable
data class KeyRotationLimit(
val hasPermitsRemaining: Boolean? = null,
val retryAfterSeconds: Long? = null
)
@Serializable
private class ArchiveCredentialsBody(
val credentials: Map<String, List<WireCredential>> = emptyMap()
)
@Serializable
private class WireCredential(
@Serializable(with = ByteArrayToBase64Serializer::class)
val credential: ByteArray,
val redemptionTime: Long
) {
fun toArchiveServiceCredential(): ArchiveServiceCredential = ArchiveServiceCredential(credential, redemptionTime)
}
@Serializable
private class SetBackupIdBody(
val messagesBackupAuthCredentialRequest: String?,
val mediaBackupAuthCredentialRequest: String?
) {
fun encode(): String = SignalJson.encode(serializer(), this).getOrNull() ?: throw IllegalStateException("Unable to encode backupId request")
}
sealed interface GetServiceCredentialsError : BadRequestError {
/** The requested redemption window was rejected. */
data object InvalidRedemptionTimes : GetServiceCredentialsError
/** The account credentials this request was made with were rejected. Unlike the anonymous endpoints, this is about the account, not the archive credential. */
data object Unauthorized : GetServiceCredentialsError
/** No backupId has been reserved for this account yet. See [triggerBackupIdReservation]. */
data object BackupIdNotFound : GetServiceCredentialsError
data class RateLimited(val retryAfter: Duration?) : GetServiceCredentialsError
}
sealed interface SetBackupIdError : BadRequestError {
/** The zkgroup credential request was rejected. */
data object InvalidCredential : SetBackupIdError
/** See [GetServiceCredentialsError.Unauthorized]. */
data object Unauthorized : SetBackupIdError
data class RateLimited(val retryAfter: Duration?) : SetBackupIdError
}
sealed interface GetMediaItemsError : BadRequestError {
/** Malformed request, or made on the authenticated channel when it must be anonymous. */
data object InvalidRequest : GetMediaItemsError
/** The anonymous credential presentation was rejected. Unlike the libsignal-backed endpoints, this endpoint reports 401 and 403 separately. */
data object Unauthorized : GetMediaItemsError
data object Forbidden : GetMediaItemsError
data class RateLimited(val retryAfter: Duration?) : GetMediaItemsError
}
sealed interface GetKeyRotationLimitError : BadRequestError {
data object Forbidden : GetKeyRotationLimitError
}
/**
* The outcome of deriving a zkgroup credential. Not a [RequestResult] because nothing goes over the network -- a derivation can only fail over the inputs it
* was given, and either failure means a retry with the same inputs fails the same way.
*/
sealed interface ZkCredentialResult<out T> {
data class Success<T>(val value: T) : ZkCredentialResult<T>
sealed interface Failure : ZkCredentialResult<Nothing> {
val exception: Exception
/** The credential didn't verify, which most often means the backup key doesn't belong to the aci it was presented with. */
data class VerificationFailed(override val exception: VerificationFailedException) : Failure
/** The stored credential couldn't be parsed at all, so there's nothing to verify. */
data class MalformedCredential(override val exception: InvalidInputException) : Failure
}
}
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network.service
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import kotlin.time.Duration
/**
* The persisted state [ArchiveService] needs in order to avoid re-fetching things it already has.
*
* Archive credentials are deliberately fetched in advance on a schedule rather than on-demand, so almost every operation reads them from here rather than from
* the network. See [org.signal.network.api.ArchiveApiV2.getServiceCredentials] for why that matters.
*
* [onNotEntitled] exists because a 403 means more to the app than it does to this layer -- the implementor gets to decide what it says about the user's account
* state. A 401 needs no such hook, since resetting the caches below is the entirety of the response to one.
*/
interface ArchiveCacheStore {
val aci: ACI
val messageBackupKey: MessageBackupKey
val mediaRootBackupKey: MediaRootBackupKey
val messageCredentials: CredentialCache
val mediaCredentials: CredentialCache
/** Whether the backupId has been reserved and the public key set. See [ArchiveService.getArchiveServiceAccess]. */
var backupsInitialized: Boolean
/** The `{backupDir}/{mediaDir}` path media lives under on the cdn. Changes whenever the backup is reset. */
var cachedMediaCdnPath: String?
/** Linked devices never initialize backups themselves -- the primary does it. */
val isLinkedDevice: Boolean
/** True while registration is still waiting on the user's restore decision, before the real keys are known. */
val isPreRestoreDuringRegistration: Boolean
/**
* The server says this account isn't entitled to the operation (403), e.g. a free-tier user performing a media operation.
*
* Only the media-listing endpoint can reach this today, since it's the one anonymous archive request we still issue ourselves. libsignal collapses 401 and 403
* into a single "unauthorized" error, so everything routed through it reports [ArchiveError.CredentialError.Unauthorized] instead.
*/
fun onNotEntitled()
/**
* The credentials for a single archive type (messages or media), keyed by the day they're valid for.
*/
interface CredentialCache {
/** The credential valid for [currentTime], or null if we don't have one. */
fun getForTime(currentTime: Duration): ArchiveServiceCredential?
/** Adds the given credentials to the existing set. */
fun add(credentials: List<ArchiveServiceCredential>)
/** Trims out credentials for days older than [startOfDayInSeconds]. */
fun clearOlderThan(startOfDayInSeconds: Long)
/** Clears all credentials, including [cdnReadCredentials]. */
fun clearAll()
/** Short-lived headers for reading from the archive cdn. Null once expired. */
var cdnReadCredentials: GetArchiveCdnCredentialsResponse?
}
}
@@ -0,0 +1,859 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network.service
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.raise.recover
import arrow.core.right
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.backup.MediaName
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.logging.Log
import org.signal.core.util.urlEncode
import org.signal.libsignal.net.CopyBackupMediaItem
import org.signal.libsignal.net.CopyBackupMediaOutcome
import org.signal.libsignal.net.DeleteBackupMediaItem
import org.signal.libsignal.net.GetUploadFormError
import org.signal.libsignal.net.MediaBackupInfo
import org.signal.libsignal.net.MessageBackupInfo
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RequestUnauthorizedException
import org.signal.libsignal.net.ServerSideErrorException
import org.signal.libsignal.net.UploadTooLargeException
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.network.NetworkResult
import org.signal.network.api.ArchiveApiV2
import org.signal.network.api.ArchiveApiV2.ZkCredentialResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.service.ArchiveError.CopyMediaError
import org.signal.network.service.ArchiveError.CredentialError
import org.signal.network.service.ArchiveError.EntitlementError
import org.signal.network.service.ArchiveError.UploadFormError
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccessPair
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
import java.io.IOException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.toKotlinDuration
/**
* Collection of higher-level archive operations.
*
* Nearly every one of these is a chain of several [ArchiveApiV2] calls, because the archive endpoints sit behind an anonymous auth credential system that is a
* precursor to almost every request. These credentials must be cached and re-used, and this class attempts to encapsulate all of that to provide a clean
* interface.
*/
class ArchiveService(
private val archiveApi: ArchiveApiV2,
private val store: ArchiveCacheStore
) {
companion object {
private val TAG = Log.tag(ArchiveService::class)
/** The server caps how many media items may be deleted in a single request. */
private const val MAX_DELETE_ITEMS_PER_REQUEST = 1000
/** Only suitable for debugging -- an account can have far more media than this in a single page. */
private const val DEBUG_MEDIA_PAGE_SIZE = 10_000
}
/**
* During normal operation, ensures that the backupId has been reserved and that your public key has been set, while also returning archive access data.
* Should be the basis of all backup operations.
*
* When called during registration before backups are initialized, will only fetch access data and not initialize backups. This prevents early initialization
* with incorrect keys before we have restored them.
*/
suspend fun getArchiveServiceAccess(): Either<CredentialError, ArchiveServiceAccessPair> = request {
initBackupAndFetchAuth()
}
/**
* Ensures that backupIds are reserved on the service. As documented on [ArchiveApiV2.triggerBackupIdReservation], this is safe to perform multiple times.
*
* Pass false for [includeMedia] to skip reserving the media backupId, which also avoids spending a rate limit permit on it. That's what you want during
* registration, where only the message backup is relevant.
*/
suspend fun triggerBackupIdReservation(includeMedia: Boolean = true): Either<CredentialError, Unit> = request {
reserveBackupId(
messageBackupKey = store.messageBackupKey,
mediaRootBackupKey = if (includeMedia) store.mediaRootBackupKey else null
)
store.messageCredentials.clearAll()
if (includeMedia) {
store.mediaCredentials.clearAll()
}
}
/**
* Indicates that this backup is still active. Clients must periodically upload new backups or perform a refresh, or the backup may be deleted after 30 days.
*
* Media is only refreshed for paid-tier accounts, since free-tier accounts have no media backup to keep alive.
*/
suspend fun refreshBackup(): Either<CredentialError, Unit> = request {
Log.d(TAG, "Refreshing backup...")
val access = initBackupAndFetchAuth()
val backupLevel = backupLevelOf(access)
Log.d(TAG, "Fetched backup level. Refreshing message backup access.")
withRejectedCredentialActions {
refreshBackupAccess(access.messageBackupAccess)
Log.d(TAG, "Refreshed message backup access.")
if (backupLevel == BackupLevel.PAID) {
Log.d(TAG, "Refreshing media backup access.")
refreshBackupAccess(access.mediaBackupAccess)
Log.d(TAG, "Refreshed media backup access.")
}
}
}
/**
* The backup level the service says this account is on, derived locally from the archive credential.
*
* Reserving the backupId and re-establishing a rejected credential are side effects of this call. Use [getBackupLevelWithoutDowngrade] to check without them.
*/
suspend fun getBackupLevel(): Either<CredentialError, BackupLevel> = request {
backupLevelOf(initBackupAndFetchAuth())
}
/**
* [getBackupLevel] without any of the error handling that clears local state, and without initializing backups as a side effect. Lets a periodic check run
* without risking rolling the user back.
*/
suspend fun getBackupLevelWithoutDowngrade(): Either<CredentialError, BackupLevel> = request {
backupLevelOf(fetchArchiveServiceAccessPair())
}
/**
* Everything needed to read the main message backup file off the cdn: which cdn it's on, the credentials to read from it, and the path to the file.
*/
suspend fun getMessageBackupFileLocation(): Either<CredentialError, BackupFileLocation> = request {
val access = initBackupAndFetchAuth()
val info = fetchMessageBackupInfo(access.messageBackupAccess)
val credentials = store.messageCredentials.cdnReadCredentials ?: fetchAndCacheCdnReadCredentials(CredentialType.MESSAGE, access, info.cdn)
BackupFileLocation(info, credentials.headers)
}
/**
* [getMessageBackupFileLocation] using a freshly-fetched credential derived from [messageBackupKey] rather than anything in the cache.
*
* This is how you check whether an AEP the user typed in actually belongs to [aci]: a key that isn't associated with the account fails zk verification while
* deriving the credential, surfacing as [ArchiveError.CredentialError.ZkVerificationFailed].
*/
suspend fun getMessageBackupFileLocationForKey(aci: ACI, messageBackupKey: MessageBackupKey): Either<CredentialError, BackupFileLocation> = request {
val currentTime = System.currentTimeMillis()
val response = fetchServiceCredentials(currentTime)
val credential: ArchiveServiceCredential = response.messageCredentials
.associateBy { it.redemptionTime }[startOfDay(currentTime)]
?: raise(ArchiveError.ApplicationError(IllegalStateException("No credential available for the current time.")))
val access = ArchiveServiceAccess(credential, messageBackupKey)
val info = fetchMessageBackupInfo(access, aci)
val credentials = fetchCdnReadCredentials(access, info.cdn, aci)
BackupFileLocation(info, credentials.headers)
}
/**
* An upload form for the main message backup file.
*/
suspend fun getMessageBackupUploadForm(backupFileSize: Long): Either<UploadFormError, AttachmentUploadForm> = request {
val access = initBackupAndFetchAuth()
withRejectedCredentialActions {
fetchMessageBackupUploadForm(access.messageBackupAccess, backupFileSize)
}
}
/**
* An upload form for a single piece of media, to be uploaded to the transit cdn.
*
* Getting it onto the archive cdn still requires a follow-up [copyToArchive].
*/
suspend fun getMediaUploadForm(uploadLength: Long): Either<UploadFormError, AttachmentUploadForm> = request {
val access = initBackupAndFetchAuth()
withRejectedCredentialActions {
fetchMediaUploadForm(access.mediaBackupAccess, uploadLength)
}
}
/**
* Credentials for reading from the backup cdn, preferring the cached value if it hasn't expired.
*/
suspend fun getCdnReadCredentials(credentialType: CredentialType, cdnNumber: Int): Either<CredentialError, GetArchiveCdnCredentialsResponse> = request {
store.cacheFor(credentialType).cdnReadCredentials?.let { return@request it }
val access = initBackupAndFetchAuth()
withRejectedCredentialActions {
fetchAndCacheCdnReadCredentials(credentialType, access, cdnNumber)
}
}
/**
* The `{backupDir}/{mediaDir}` path that archived media lives under, preferring the cached value.
*
* This changes if the backup expires, a new backupId is set, or the delete-all endpoint is called -- all of which clear the cache.
*/
suspend fun getArchivedMediaCdnPath(): Either<CredentialError, String> = request {
store.cachedMediaCdnPath?.let { return@request it }
val access = initBackupAndFetchAuth()
val info = withRejectedCredentialActions { fetchMediaBackupInfo(access.mediaBackupAccess) }
val path = "${info.backupDir.urlEncode()}/${info.mediaDir.urlEncode()}"
store.cachedMediaCdnPath = path
path
}
/**
* A page of the media items in the user's archive.
*
* @param cursor A token read from the previous response, telling the server where to start the next page.
*/
suspend fun listRemoteMediaObjects(limit: Int, cursor: String? = null): Either<EntitlementError, ArchiveApiV2.MediaItemsPage> = request {
val access = initBackupAndFetchAuth()
withRejectedCredentialActions {
recover({ fetchMediaItemsPage(access.mediaBackupAccess, limit, cursor) }) { error ->
if (error is EntitlementError.NotEntitled) {
store.onNotEntitled()
}
raise(error)
}
}
}
/**
* Copies media that has already been uploaded to the transit cdn over to the archive cdn.
*
* @return The archive cdn number the media landed on.
*/
suspend fun copyToArchive(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName): Either<CopyMediaError, Int> = request {
val access = initBackupAndFetchAuth()
val mediaSecrets = access.mediaBackupAccess.backupKey.deriveMediaSecrets(mediaName)
val item = CopyBackupMediaItem(
sourceAttachmentCdn = cdnNumber,
sourceKey = remoteLocation,
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)),
mediaId = mediaSecrets.id.value,
encryptionKey = mediaSecrets.macKey + mediaSecrets.aesKey
)
val outcomes: List<CopyBackupMediaOutcome> = withRejectedCredentialActions {
bindSimpleRequest(archiveApi.copyMediaToArchive(store.aci, access.mediaBackupAccess, listOf(item)))
}
when (val outcome = outcomes.firstOrNull()) {
is CopyBackupMediaOutcome.Success -> outcome.cdn
is CopyBackupMediaOutcome.SourceNotFound -> raise(CopyMediaError.SourceNotFound)
is CopyBackupMediaOutcome.WrongSourceLength -> raise(CopyMediaError.WrongSourceLength)
is CopyBackupMediaOutcome.OutOfSpace -> raise(CopyMediaError.OutOfRemoteSpace)
null -> raise(ArchiveError.ApplicationError(IllegalStateException("Copy stream ended without an outcome for the item!")))
}
}
/**
* Deletes media from the backup cdn, in server-sized chunks. An empty list is a no-op success.
*/
suspend fun deleteArchivedMedia(mediaToDelete: List<DeleteBackupMediaItem>): Either<CredentialError, Unit> = request {
if (mediaToDelete.isEmpty()) {
Log.i(TAG, "No media to delete, quick success")
return@request
}
val access = initBackupAndFetchAuth()
withRejectedCredentialActions {
mediaToDelete.chunked(MAX_DELETE_ITEMS_PER_REQUEST).forEachIndexed { index, chunk ->
recover({ deleteArchivedMediaChunk(access.mediaBackupAccess, chunk) }) { error ->
Log.w(TAG, "Error occurred while deleting archived media chunk #$index: $error")
raise(error)
}
}
}
}
/**
* Deletes all message backup metadata, objects, and the stored public key. To use backups again, a public key must be resupplied.
*/
suspend fun deleteMessageBackup(): Either<CredentialError, Unit> = request {
val access = initBackupAndFetchAuth()
deleteBackup(access.messageBackupAccess)
}
/**
* The media-backup counterpart to [deleteMessageBackup].
*/
suspend fun deleteMediaBackup(): Either<CredentialError, Unit> = request {
val access = initBackupAndFetchAuth()
deleteBackup(access.mediaBackupAccess)
}
/**
* Auth credentials that can be used to perform SVR-B operations.
*/
suspend fun getSvrBAuth(): Either<CredentialError, AuthCredentials> = request {
val access = initBackupAndFetchAuth()
withRejectedCredentialActions {
bindSimpleRequest(archiveApi.getSvrBAuthorization(store.aci, access.messageBackupAccess))
}
}
/**
* Whether the backupId can currently be rotated. Authenticated, so it needs no archive credential of its own.
*/
suspend fun getKeyRotationLimit(): Either<EntitlementError, ArchiveApiV2.KeyRotationLimit> = request {
val result = archiveApi.getKeyRotationLimit()
when (result) {
is RequestResult.Success -> result.result
is RequestResult.NonSuccess -> when (result.error) {
ArchiveApiV2.GetKeyRotationLimitError.Forbidden -> raise(EntitlementError.NotEntitled())
}
is RequestResult.RetryableNetworkError -> raise(result.toArchiveError())
is RequestResult.ApplicationError -> raise(result.toArchiveError())
}
}
/**
* Every media item in the user's archive. Potentially a very large number of items, making this only suitable for debugging.
* Use [listRemoteMediaObjects] in production.
*/
suspend fun debugGetArchivedMediaState(): Either<EntitlementError, List<ArchiveApiV2.StoredMediaObject>> = request {
debugFetchAllMediaObjects(initBackupAndFetchAuth())
}
/**
* A summary of the remote backup state, for debugging.
*/
suspend fun debugGetRemoteBackupState(): Either<EntitlementError, DebugBackupMetadata> = request {
val access = initBackupAndFetchAuth()
val mediaBackupInfo = fetchMediaBackupInfo(access.mediaBackupAccess)
val mediaObjects = debugFetchAllMediaObjects(access)
DebugBackupMetadata(
usedSpace = mediaBackupInfo.usedSpace,
mediaCount = mediaObjects.size.toLong(),
mediaSize = mediaObjects.sumOf { it.objectLength }
)
}
private suspend fun Raise<EntitlementError>.debugFetchAllMediaObjects(access: ArchiveServiceAccessPair): List<ArchiveApiV2.StoredMediaObject> {
val mediaObjects = mutableListOf<ArchiveApiV2.StoredMediaObject>()
var cursor: String? = null
do {
val page = fetchMediaItemsPage(access.mediaBackupAccess, DEBUG_MEDIA_PAGE_SIZE, cursor)
mediaObjects += page.storedMediaObjects
cursor = page.cursor
} while (cursor != null)
return mediaObjects
}
/**
* See [getArchiveServiceAccess].
*/
private suspend fun Raise<CredentialError>.initBackupAndFetchAuth(): ArchiveServiceAccessPair {
return if (store.backupsInitialized || store.isLinkedDevice) {
withAuthErrorActions {
fetchArchiveServiceAccessPair()
}
} else if (store.isPreRestoreDuringRegistration) {
Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable())
withAuthErrorActions(includeUnauthorizedActions = false) {
fetchArchiveServiceAccessPair()
}
} else {
withAuthErrorActions {
reserveBackupId(store.messageBackupKey, store.mediaRootBackupKey)
store.messageCredentials.clearAll()
store.mediaCredentials.clearAll()
val access = fetchArchiveServiceAccessPair()
setPublicKey(access.messageBackupAccess)
setPublicKey(access.mediaBackupAccess)
store.backupsInitialized = true
access
}
}
}
/**
* Retrieves an archive credential pair, preferring the cached values. Falling through to the network here means the routine background fetch isn't running
* properly, since fetching on-demand undermines the anonymity the credentials exist to provide.
*/
private suspend fun Raise<CredentialError>.fetchArchiveServiceAccessPair(): ArchiveServiceAccessPair {
val currentTime = System.currentTimeMillis()
val messageCredential = store.messageCredentials.getForTime(currentTime.milliseconds)
val mediaCredential = store.mediaCredentials.getForTime(currentTime.milliseconds)
if (messageCredential != null && mediaCredential != null) {
return ArchiveServiceAccessPair(
messageBackupAccess = ArchiveServiceAccess(messageCredential, store.messageBackupKey),
mediaBackupAccess = ArchiveServiceAccess(mediaCredential, store.mediaRootBackupKey)
)
}
Log.w(TAG, "No credentials found for today, need to fetch new ones! This shouldn't happen under normal circumstances. We should ensure the routine fetch is running properly.")
val response = fetchServiceCredentials(currentTime)
store.messageCredentials.add(response.messageCredentials)
store.messageCredentials.clearOlderThan(currentTime)
store.mediaCredentials.add(response.mediaCredentials)
store.mediaCredentials.clearOlderThan(currentTime)
return ArchiveServiceAccessPair(
messageBackupAccess = ArchiveServiceAccess(store.messageCredentials.getForTime(currentTime.milliseconds)!!, store.messageBackupKey),
mediaBackupAccess = ArchiveServiceAccess(store.mediaCredentials.getForTime(currentTime.milliseconds)!!, store.mediaRootBackupKey)
)
}
/**
* Applies the local state cleanup that a rejected credential implies. Scoped to the credential-fetching portion of an operation, because a 401 from, say,
* fetching backup metadata says something different than a 401 while establishing the credential itself.
*
* A failed zk derivation is handled in [request] instead, since it isn't endpoint-dependent the way a 401 is.
*
* Pass false for [includeUnauthorizedActions] to log a rejected credential without acting on it, which is what registration wants before keys are restored.
*/
private inline fun <T> Raise<CredentialError>.withAuthErrorActions(includeUnauthorizedActions: Boolean = true, block: Raise<CredentialError>.() -> T): T {
return recover({ block() }) { error ->
if (error is CredentialError.Unauthorized && includeUnauthorizedActions) {
Log.w(TAG, "Credential rejected. Resetting initialized state + auth credentials.", error.cause)
store.backupsInitialized = false
store.messageCredentials.clearAll()
store.mediaCredentials.clearAll()
store.cachedMediaCdnPath = null
}
raise(error)
}
}
/**
* Applies the local state cleanup that a rejected *anonymous* credential implies. The counterpart to [withAuthErrorActions]: that one covers establishing a
* credential, this one covers using an established one. Without it a rejected credential is never cleared, so callers retry against the same bad credential
* until it expires on its own.
*
* Only intended to wrap the portion of an operation that runs *after* [initBackupAndFetchAuth], so it can't undo that method's pre-restore exemption.
*/
private inline fun <E : ArchiveError, T> Raise<E>.withRejectedCredentialActions(block: Raise<E>.() -> T): T {
return recover({ block() }) { error ->
if (error is CredentialError.Unauthorized) {
Log.w(TAG, "Anonymous credential rejected. Resetting initialized state + auth credentials.", error.cause)
store.backupsInitialized = false
store.messageCredentials.clearAll()
store.mediaCredentials.clearAll()
store.cachedMediaCdnPath = null
}
raise(error)
}
}
/**
* Fetches fresh cdn read credentials and caches them. Callers that can tolerate a cached value should check [ArchiveCacheStore.CredentialCache.cdnReadCredentials]
* first -- these are cheap to re-fetch but not free.
*/
private suspend fun Raise<CredentialError>.fetchAndCacheCdnReadCredentials(credentialType: CredentialType, access: ArchiveServiceAccessPair, cdnNumber: Int): GetArchiveCdnCredentialsResponse {
val archiveServiceAccess = when (credentialType) {
CredentialType.MESSAGE -> access.messageBackupAccess
CredentialType.MEDIA -> access.mediaBackupAccess
}
val credentials = fetchCdnReadCredentials(archiveServiceAccess, cdnNumber)
store.cacheFor(credentialType).cdnReadCredentials = credentials
return credentials
}
private fun Raise<CredentialError>.backupLevelOf(access: ArchiveServiceAccessPair): BackupLevel {
return when (val result = archiveApi.getZkCredential(store.aci, access.messageBackupAccess)) {
is ZkCredentialResult.Success -> result.value.backupLevel
is ZkCredentialResult.Failure.VerificationFailed -> raise(CredentialError.ZkVerificationFailed(result.exception))
is ZkCredentialResult.Failure.MalformedCredential -> raise(ArchiveError.ApplicationError(result.exception))
}
}
private fun ArchiveCacheStore.cacheFor(credentialType: CredentialType): ArchiveCacheStore.CredentialCache {
return when (credentialType) {
CredentialType.MESSAGE -> messageCredentials
CredentialType.MEDIA -> mediaCredentials
}
}
private fun startOfDay(currentTime: Long): Long {
return currentTime.milliseconds.inWholeDays.days.inWholeSeconds
}
/**
* The general cruft we need to do for every request -- run on IO, map to an Either, and handle common verification errors.
*/
private suspend fun <E : ArchiveError, T> request(block: suspend Raise<E>.() -> T): Either<E, T> {
return withContext(Dispatchers.IO) {
either {
recover({ block() }) { error ->
if (error is CredentialError.ZkVerificationFailed) {
Log.w(TAG, "Unable to verify/receive credentials, clearing cache to fetch new.", error.exception)
store.messageCredentials.clearAll()
store.mediaCredentials.clearAll()
}
raise(error)
}
}
}
}
/**
* Reserves the backupIds for the keys provided. Pass null for either to skip reserving it.
*/
private suspend fun Raise<CredentialError>.reserveBackupId(messageBackupKey: MessageBackupKey?, mediaRootBackupKey: MediaRootBackupKey?) {
val result = archiveApi.triggerBackupIdReservation(messageBackupKey, mediaRootBackupKey, store.aci)
when (result) {
is RequestResult.Success -> Unit
is RequestResult.NonSuccess -> when (val error = result.error) {
ArchiveApiV2.SetBackupIdError.InvalidCredential -> raise(CredentialError.InvalidRequest())
ArchiveApiV2.SetBackupIdError.Unauthorized -> raise(CredentialError.Unauthorized())
is ArchiveApiV2.SetBackupIdError.RateLimited -> raise(CredentialError.RateLimited(error.retryAfter))
}
is RequestResult.RetryableNetworkError -> raise(result.toArchiveError())
is RequestResult.ApplicationError -> raise(result.toArchiveError())
}
}
private suspend fun Raise<CredentialError>.fetchServiceCredentials(currentTime: Long): ArchiveApiV2.ArchiveCredentials {
val result = archiveApi.getServiceCredentials(currentTime)
return when (result) {
is RequestResult.Success -> result.result
is RequestResult.NonSuccess -> when (val error = result.error) {
ArchiveApiV2.GetServiceCredentialsError.InvalidRedemptionTimes -> raise(CredentialError.InvalidRequest())
ArchiveApiV2.GetServiceCredentialsError.Unauthorized -> raise(CredentialError.Unauthorized())
ArchiveApiV2.GetServiceCredentialsError.BackupIdNotFound -> raise(CredentialError.NotFound())
is ArchiveApiV2.GetServiceCredentialsError.RateLimited -> raise(CredentialError.RateLimited(error.retryAfter))
}
is RequestResult.RetryableNetworkError -> raise(result.toArchiveError())
is RequestResult.ApplicationError -> raise(result.toArchiveError())
}
}
private suspend fun Raise<CredentialError>.setPublicKey(access: ArchiveServiceAccess<*>) {
bindSimpleRequest(archiveApi.setPublicKey(store.aci, access))
}
/**
* Tells the service that the backup behind [access] is still in use, so it doesn't get cleaned up.
*/
private suspend fun Raise<CredentialError>.refreshBackupAccess(access: ArchiveServiceAccess<*>) {
bindSimpleRequest(archiveApi.refreshBackup(store.aci, access))
}
private suspend fun Raise<CredentialError>.fetchMessageBackupInfo(access: ArchiveServiceAccess<MessageBackupKey>, aci: ACI = store.aci): MessageBackupInfo {
return bindSimpleRequest(archiveApi.getMessageBackupInfo(aci, access))
}
private suspend fun Raise<CredentialError>.fetchMediaBackupInfo(access: ArchiveServiceAccess<MediaRootBackupKey>): MediaBackupInfo {
return bindSimpleRequest(archiveApi.getMediaBackupInfo(store.aci, access))
}
private suspend fun Raise<CredentialError>.fetchCdnReadCredentials(access: ArchiveServiceAccess<*>, cdnNumber: Int, aci: ACI = store.aci): GetArchiveCdnCredentialsResponse {
return bindSimpleRequest(archiveApi.getCdnReadCredentials(cdnNumber, aci, access))
}
private suspend fun Raise<UploadFormError>.fetchMessageBackupUploadForm(access: ArchiveServiceAccess<MessageBackupKey>, backupFileSize: Long): AttachmentUploadForm {
return bindUploadForm(archiveApi.getMessageBackupUploadForm(store.aci, access, backupFileSize))
}
private suspend fun Raise<UploadFormError>.fetchMediaUploadForm(access: ArchiveServiceAccess<MediaRootBackupKey>, uploadLength: Long): AttachmentUploadForm {
return bindUploadForm(archiveApi.getMediaUploadForm(store.aci, access, uploadLength))
}
private suspend fun Raise<EntitlementError>.fetchMediaItemsPage(access: ArchiveServiceAccess<MediaRootBackupKey>, limit: Int, cursor: String?): ArchiveApiV2.MediaItemsPage {
val result = archiveApi.getArchiveMediaItemsPage(store.aci, access, limit, cursor)
return when (result) {
is RequestResult.Success -> result.result
is RequestResult.NonSuccess -> when (val error = result.error) {
ArchiveApiV2.GetMediaItemsError.InvalidRequest -> raise(CredentialError.InvalidRequest())
ArchiveApiV2.GetMediaItemsError.Unauthorized -> raise(CredentialError.Unauthorized())
ArchiveApiV2.GetMediaItemsError.Forbidden -> raise(EntitlementError.NotEntitled())
is ArchiveApiV2.GetMediaItemsError.RateLimited -> raise(CredentialError.RateLimited(error.retryAfter))
}
is RequestResult.RetryableNetworkError -> raise(result.toArchiveError())
is RequestResult.ApplicationError -> raise(result.toArchiveError())
}
}
private suspend fun Raise<CredentialError>.deleteArchivedMediaChunk(access: ArchiveServiceAccess<MediaRootBackupKey>, chunk: List<DeleteBackupMediaItem>): List<DeleteBackupMediaItem> {
return bindSimpleRequest(archiveApi.deleteArchivedMedia(store.aci, access, chunk))
}
private suspend fun Raise<CredentialError>.deleteBackup(access: ArchiveServiceAccess<*>) {
bindSimpleRequest(archiveApi.deleteBackup(store.aci, access))
}
/**
* The majority of archive endpoints, whose only modeled non-success is a rejected credential.
*/
private fun <T> Raise<CredentialError>.bindSimpleRequest(result: RequestResult<T, RequestUnauthorizedException>): T {
return when (result) {
is RequestResult.Success -> result.result
is RequestResult.NonSuccess -> raise(CredentialError.Unauthorized(result.error))
is RequestResult.RetryableNetworkError -> raise(result.toArchiveError())
is RequestResult.ApplicationError -> raise(result.toArchiveError())
}
}
/**
* [bindSimpleRequest] for the upload-form endpoints, which can additionally reject the requested size.
*/
private fun <T> Raise<UploadFormError>.bindUploadForm(result: RequestResult<T, GetUploadFormError>): T {
return when (result) {
is RequestResult.Success -> result.result
is RequestResult.NonSuccess -> when (val error = result.error) {
is UploadTooLargeException -> raise(UploadFormError.TooLarge(error))
is RequestUnauthorizedException -> raise(CredentialError.Unauthorized(error))
}
is RequestResult.RetryableNetworkError -> raise(result.toArchiveError())
is RequestResult.ApplicationError -> raise(result.toArchiveError())
}
}
/**
* A failed request means the same thing on every endpoint, so each wrapper above hands this branch straight to `raise`.
*
* Note that libsignal folds a rate limit in here with a retry-after attached rather than giving it its own error type.
*/
private fun RequestResult.RetryableNetworkError.toArchiveError(): CredentialError {
return when (val retryAfter = retryAfter) {
null -> ArchiveError.NetworkError(networkError)
else -> CredentialError.RateLimited(retryAfter.toKotlinDuration(), networkError)
}
}
/**
* A local failure means the same thing on every endpoint too. Failing to derive a zk credential arrives here, but means something far more specific than
* "unexpected": the backup key doesn't belong to this account.
*/
private fun RequestResult.ApplicationError.toArchiveError(): CredentialError {
return when (val failure = cause) {
is VerificationFailedException -> CredentialError.ZkVerificationFailed(failure)
else -> ArchiveError.ApplicationError(failure)
}
}
/**
* Where the main message backup file lives on the cdn, and what's needed to read it.
*/
data class BackupFileLocation(
val cdn: Int,
val cdnCredentials: Map<String, String>,
val path: String
) {
constructor(info: MessageBackupInfo, cdnCredentials: Map<String, String>) : this(
cdn = info.cdn,
cdnCredentials = cdnCredentials,
path = "backups/${info.backupDir}/${info.backupName}"
)
}
/**
* A summary of the remote backup state. Debug-only.
*/
data class DebugBackupMetadata(
val usedSpace: Long,
val mediaCount: Long,
val mediaSize: Long
)
/**
* Which of the two archives an operation applies to. The service issues separate credentials for each.
*/
enum class CredentialType {
MESSAGE, MEDIA
}
}
/**
* Returns the value, or throws.
*
* Throws the underlying cause where there is one, so callers that already catch things like [IOException] keep working. Intended for the legacy throwing call
* sites; prefer handling the [Either] directly.
*/
fun <T> Either<ArchiveError, T>.successOrThrow(): T {
return getOrElse { error -> throw error.cause ?: error.toLegacyException() }
}
/**
* The exception the pre-[ArchiveService] code would have thrown for an error that carries no cause of its own.
*
* This is the one place a status code is reconstructed rather than interpreted, and it exists purely for [successOrThrow]: its callers are jobs that branch on
* [NonSuccessfulResponseCodeException.code] to decide whether to retry, so handing them a bare [IOException] silently changes their retry behavior. Anything
* unmapped stays an [IOException] rather than something unchecked, because the job runner treats a runtime exception as a crash.
*/
private fun ArchiveError.toLegacyException(): IOException {
return when (this) {
is ArchiveError.CredentialError.InvalidRequest -> NonSuccessfulResponseCodeException(400)
is ArchiveError.CredentialError.Unauthorized -> NonSuccessfulResponseCodeException(401)
is ArchiveError.EntitlementError.NotEntitled -> NonSuccessfulResponseCodeException(403)
is ArchiveError.CredentialError.NotFound -> NonSuccessfulResponseCodeException(404)
is ArchiveError.CredentialError.RateLimited -> NonSuccessfulResponseCodeException(429)
else -> IOException("Archive operation failed: $this")
}
}
/**
* Translates a [NetworkResult] into the [ArchiveError] vocabulary. Needed for the handful of archive-adjacent requests that still go out through the CDN
* download path rather than [org.signal.network.api.ArchiveApiV2], so their callers can branch on the same errors [ArchiveService] returns.
*
* This is the only place a cdn status code is interpreted -- callers get a named error instead, the same way they do for the requests that go through
* [org.signal.network.api.ArchiveApiV2]. A 5xx is transient no matter what we asked for, so it lands on [ArchiveError.NetworkError] to be retried.
*/
fun <T> NetworkResult<T>.toArchiveResult(): Either<ArchiveError.BackupFileError, T> {
return when (this) {
is NetworkResult.Success -> result.right()
is NetworkResult.NetworkError -> ArchiveError.NetworkError(exception).left()
is NetworkResult.ApplicationError -> ArchiveError.ApplicationError(throwable).left()
is NetworkResult.StatusCodeError -> when (code) {
401 -> ArchiveError.CredentialError.Unauthorized(exception)
403 -> ArchiveError.EntitlementError.NotEntitled(exception)
404 -> ArchiveError.CredentialError.NotFound(exception)
429 -> ArchiveError.CredentialError.RateLimited(retryAfter(), exception)
in 500..599 -> ArchiveError.NetworkError(exception)
else -> ArchiveError.BackupFileError.UnexpectedResponse(exception)
}.left()
}
}
/**
* Everything that can go wrong during an [ArchiveService] operation.
*/
sealed interface ArchiveError {
/** The failure the underlying layer handed us, where there was one. */
val cause: Throwable?
get() = null
/**
* Whether the server rejected the request, as opposed to the request failing locally or never arriving.
*
* [CredentialError.RateLimited] counts, even though libsignal can also produce it out of a retryable network failure that carried a retry-after.
*/
val isServerRejection: Boolean
get() = when (this) {
is CredentialError.Unauthorized,
is CredentialError.NotFound,
is CredentialError.InvalidRequest,
is CredentialError.RateLimited,
is EntitlementError.NotEntitled,
is UploadFormError.TooLarge,
is BackupFileError.UnexpectedResponse -> {
true
}
is CredentialError.ZkVerificationFailed,
is NetworkError,
is ApplicationError,
is CopyMediaError.SourceNotFound,
is CopyMediaError.WrongSourceLength,
is CopyMediaError.OutOfRemoteSpace -> {
false
}
}
/** A generic, retryable network error. Extends [CredentialError] because that's how it lands in every collection. */
data class NetworkError(val exception: IOException) : CredentialError {
override val cause: Throwable get() = exception
val isServerSide: Boolean
get() = exception is ServerSideErrorException
}
/** An unexpected error. You should likely crash. Extends [CredentialError] because that's how it lands in every collection. */
data class ApplicationError(val exception: Throwable) : CredentialError {
override val cause: Throwable get() = exception
}
/**
* Errors that can happen when fetching a credential.
* By implementing all of these other error collection interfaces, we're saying that all of those collections include [CredentialError].
*/
sealed interface CredentialError : ArchiveError, UploadFormError, CopyMediaError, EntitlementError, BackupFileError {
/** The server rejected our credential. */
data class Unauthorized(override val cause: Throwable? = null) : CredentialError
/** Nothing exists at the requested location. */
data class NotFound(override val cause: Throwable? = null) : CredentialError
/** The server rejected the request as malformed. Retrying the same request won't help. */
data class InvalidRequest(override val cause: Throwable? = null) : CredentialError
/** You're rate-limited. Use [retryAfter] for your backoff. */
data class RateLimited(val retryAfter: Duration?, override val cause: Throwable? = null) : CredentialError
/** The zkgroup credential could not be derived or verified. It could mean the backup key is incorrect, or some other state-tracking error. */
data class ZkVerificationFailed(val exception: VerificationFailedException) : CredentialError {
override val cause: Throwable get() = exception
}
}
/** Errors that can happen when you upload a form. */
sealed interface UploadFormError : ArchiveError {
/** The thing you're trying to upload exceeds the server's size limit. Retrying with the same content won't help. */
data class TooLarge(override val cause: Throwable? = null) : UploadFormError
}
/** Errors that can happen when copying media. */
sealed interface CopyMediaError : ArchiveError {
/** The attachment being copied no longer exists on the transit cdn. It needs to be re-uploaded. */
data object SourceNotFound : CopyMediaError
/** The attachment being copied wasn't the length we told the server it would be. */
data object WrongSourceLength : CopyMediaError
/** The account is out of remote storage space. */
data object OutOfRemoteSpace : CopyMediaError
}
/** Errors that can happen when an operation requires paid-tier permissions. */
sealed interface EntitlementError : ArchiveError {
/** The account isn't entitled to this operation, e.g. a free-tier user performing a media operation. Also a [BackupFileError]. */
data class NotEntitled(override val cause: Throwable? = null) : EntitlementError, BackupFileError
}
/** Errors that can happen when reading a file off the CDN. */
sealed interface BackupFileError : ArchiveError {
/** The cdn refused the request for a reason we don't model. Retrying the same request won't help. */
data class UnexpectedResponse(override val cause: Throwable? = null) : BackupFileError
}
}
@@ -0,0 +1,706 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network.service
import arrow.core.Either
import arrow.core.left
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isSameInstanceAs
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.signal.core.models.backup.MediaName
import org.signal.libsignal.net.CopyBackupMediaOutcome
import org.signal.libsignal.net.DeleteBackupMediaItem
import org.signal.libsignal.net.MediaBackupInfo
import org.signal.libsignal.net.MessageBackupInfo
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RequestUnauthorizedException
import org.signal.libsignal.net.ServerSideErrorException
import org.signal.libsignal.net.UploadTooLargeException
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupAuthCredential
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.network.api.ArchiveApiV2
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import java.io.IOException
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import java.time.Duration as JavaDuration
/**
* Most of these assert *which local state an error clears*, because that's the contract every caller depends on and the one that isn't visible from any single
* call site. See [FakeArchiveCacheStore].
*/
class ArchiveServiceTest {
private val archiveApi: ArchiveApiV2 = mockk()
private fun serviceFor(store: FakeArchiveCacheStore) = ArchiveService(archiveApi, store)
// region Credential cache reuse
@Test
fun `cached credentials are reused without a network fetch`() = runTest {
val store = storeWithCredentials()
givenSuccessfulRefresh()
val result = serviceFor(store).refreshBackup()
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { archiveApi.getServiceCredentials(any()) }
}
@Test
fun `missing credentials are fetched, added, and trimmed`() = runTest {
val store = FakeArchiveCacheStore()
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.Success(
ArchiveApiV2.ArchiveCredentials(
messageCredentials = listOf(credential()),
mediaCredentials = listOf(credential())
)
)
givenSuccessfulRefresh()
val result = serviceFor(store).refreshBackup()
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { archiveApi.getServiceCredentials(any()) }
assertThat(store.messageCredentials.isEmpty).isFalse()
assertThat(store.mediaCredentials.isEmpty).isFalse()
assertThat(store.messageCredentials.clearedOlderThan).hasSize(1)
assertThat(store.mediaCredentials.clearedOlderThan).hasSize(1)
}
// endregion
// region Failed zk derivation clears the credential caches, regardless of which request produced it
@Test
fun `zk verification failure on a downstream request clears both credential caches`() = runTest {
val store = storeWithCredentials()
// libsignal surfaces a failed derivation as an ApplicationError, from any anonymous endpoint.
coEvery { archiveApi.getSvrBAuthorization(any(), any()) } returns RequestResult.ApplicationError(VerificationFailedException())
val result = serviceFor(store).getSvrBAuth()
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.ZkVerificationFailed::class)
assertThat(store.messageCredentials.isEmpty).isTrue()
assertThat(store.mediaCredentials.isEmpty).isTrue()
}
@Test
fun `zk verification failure while deriving the backup level clears both credential caches`() = runTest {
val store = storeWithCredentials()
every { archiveApi.getZkCredential(any(), any()) } returns ArchiveApiV2.ZkCredentialResult.Failure.VerificationFailed(VerificationFailedException())
val result = serviceFor(store).getBackupLevel()
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.ZkVerificationFailed::class)
assertThat(store.messageCredentials.isEmpty).isTrue()
assertThat(store.mediaCredentials.isEmpty).isTrue()
}
// endregion
// region Rejected credential handling, and its scope
@Test
fun `unauthorized while fetching service credentials resets initialized state and caches`() = runTest {
val store = FakeArchiveCacheStore(cachedMediaCdnPath = "dir/media")
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.NonSuccess(ArchiveApiV2.GetServiceCredentialsError.Unauthorized)
val result = serviceFor(store).getArchiveServiceAccess()
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
assertThat(store.backupsInitialized).isFalse()
assertThat(store.messageCredentials.isEmpty).isTrue()
assertThat(store.mediaCredentials.isEmpty).isTrue()
assertThat(store.cachedMediaCdnPath).isNull()
}
@Test
fun `unauthorized while fetching service credentials during pre-restore leaves local state alone`() = runTest {
val store = FakeArchiveCacheStore(
backupsInitialized = false,
isPreRestoreDuringRegistration = true,
cachedMediaCdnPath = "dir/media"
)
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.NonSuccess(ArchiveApiV2.GetServiceCredentialsError.Unauthorized)
val result = serviceFor(store).getArchiveServiceAccess()
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
assertThat(store.cachedMediaCdnPath).isEqualTo("dir/media")
coVerify(exactly = 0) { archiveApi.triggerBackupIdReservation(any(), any(), any()) }
}
@Test
fun `unauthorized while using an established credential resets initialized state and caches`() = runTest {
val store = storeWithCredentials(cachedMediaCdnPath = "dir/media")
coEvery { archiveApi.getSvrBAuthorization(any(), any()) } returns RequestResult.NonSuccess(RequestUnauthorizedException("nope"))
val result = serviceFor(store).getSvrBAuth()
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
assertThat(store.backupsInitialized).isFalse()
assertThat(store.messageCredentials.isEmpty).isTrue()
assertThat(store.mediaCredentials.isEmpty).isTrue()
assertThat(store.cachedMediaCdnPath).isNull()
}
@Test
fun `unauthorized while reading the backup file location leaves local state alone`() = runTest {
val store = storeWithCredentials(cachedMediaCdnPath = "dir/media")
coEvery { archiveApi.getMessageBackupInfo(any(), any()) } returns RequestResult.NonSuccess(RequestUnauthorizedException("nope"))
val result = serviceFor(store).getMessageBackupFileLocation()
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
// This read exists to discover whether a backup exists at all, and the server can't distinguish a bad credential from an unprovisioned backupId. Callers read
// a rejection here as "backups aren't set up", so it must not look like our credential went bad.
assertThat(store.backupsInitialized).isTrue()
assertThat(store.messageCredentials.isEmpty).isFalse()
assertThat(store.cachedMediaCdnPath).isEqualTo("dir/media")
}
@Test
fun `unauthorized while checking the backup level without downgrade leaves local state alone`() = runTest {
val store = storeWithCredentials(cachedMediaCdnPath = "dir/media")
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.NonSuccess(ArchiveApiV2.GetServiceCredentialsError.Unauthorized)
store.messageCredentials.clearAll()
store.mediaCredentials.clearAll()
val result = serviceFor(store).getBackupLevelWithoutDowngrade()
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
assertThat(store.backupsInitialized).isTrue()
assertThat(store.cachedMediaCdnPath).isEqualTo("dir/media")
}
@Test
fun `unauthorized while copying media resets initialized state and caches`() = runTest {
val store = storeWithCredentials(cachedMediaCdnPath = "dir/media")
coEvery { archiveApi.copyMediaToArchive(any(), any(), any()) } returns RequestResult.NonSuccess(RequestUnauthorizedException("nope"))
val result = serviceFor(store).copyToArchive(2, "location", 100, MediaName("name"))
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
assertThat(store.backupsInitialized).isFalse()
assertThat(store.cachedMediaCdnPath).isNull()
}
@Test
fun `network error clears nothing`() = runTest {
val store = storeWithCredentials(cachedMediaCdnPath = "dir/media")
coEvery { archiveApi.getSvrBAuthorization(any(), any()) } returns RequestResult.RetryableNetworkError(IOException("down"))
val result = serviceFor(store).getSvrBAuth()
assertThat(result.error()).isInstanceOf(ArchiveError.NetworkError::class)
assertThat(store.backupsInitialized).isTrue()
assertThat(store.messageCredentials.isEmpty).isFalse()
assertThat(store.mediaCredentials.isEmpty).isFalse()
assertThat(store.cachedMediaCdnPath).isEqualTo("dir/media")
}
@Test
fun `rate limit clears nothing`() = runTest {
val store = storeWithCredentials(cachedMediaCdnPath = "dir/media")
coEvery { archiveApi.getSvrBAuthorization(any(), any()) } returns RequestResult.RetryableNetworkError(IOException("slow down"), JavaDuration.ofSeconds(30))
val result = serviceFor(store).getSvrBAuth()
val error = result.error()
assertThat(error).isInstanceOf(ArchiveError.CredentialError.RateLimited::class)
assertThat((error as ArchiveError.CredentialError.RateLimited).retryAfter).isEqualTo(30.seconds)
assertThat(store.messageCredentials.isEmpty).isFalse()
assertThat(store.cachedMediaCdnPath).isEqualTo("dir/media")
}
// endregion
// region Error mapping
@Test
fun `retryable network error without a retry-after becomes NetworkError`() = runTest {
val store = storeWithCredentials()
val cause = IOException("boom")
coEvery { archiveApi.getMessageBackupUploadForm(any(), any(), any()) } returns RequestResult.RetryableNetworkError(cause)
val result = serviceFor(store).getMessageBackupUploadForm(100)
val error = result.error()
assertThat(error).isInstanceOf(ArchiveError.NetworkError::class)
assertThat((error as ArchiveError.NetworkError).exception).isSameInstanceAs(cause)
}
@Test
fun `upload too large becomes TooLarge`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.getMessageBackupUploadForm(any(), any(), any()) } returns RequestResult.NonSuccess(UploadTooLargeException("too big"))
val result = serviceFor(store).getMessageBackupUploadForm(Long.MAX_VALUE)
assertThat(result.error()).isInstanceOf(ArchiveError.UploadFormError.TooLarge::class)
}
@Test
fun `unauthorized upload form becomes Unauthorized rather than TooLarge`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.getMediaUploadForm(any(), any(), any()) } returns RequestResult.NonSuccess(RequestUnauthorizedException("nope"))
val result = serviceFor(store).getMediaUploadForm(100)
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
}
@Test
fun `reserving a backupId maps each modeled error`() = runTest {
val cases = mapOf(
ArchiveApiV2.SetBackupIdError.InvalidCredential to ArchiveError.CredentialError.InvalidRequest::class,
ArchiveApiV2.SetBackupIdError.Unauthorized to ArchiveError.CredentialError.Unauthorized::class,
ArchiveApiV2.SetBackupIdError.RateLimited(5.seconds) to ArchiveError.CredentialError.RateLimited::class
)
for ((apiError, expected) in cases) {
val store = FakeArchiveCacheStore()
coEvery { archiveApi.triggerBackupIdReservation(any(), any(), any()) } returns RequestResult.NonSuccess(apiError)
val result = serviceFor(store).triggerBackupIdReservation()
assertThat(result.leftOrNull()!!::class, name = "$apiError").isEqualTo(expected)
}
}
@Test
fun `fetching service credentials maps each modeled error`() = runTest {
val cases = mapOf(
ArchiveApiV2.GetServiceCredentialsError.InvalidRedemptionTimes to ArchiveError.CredentialError.InvalidRequest::class,
ArchiveApiV2.GetServiceCredentialsError.Unauthorized to ArchiveError.CredentialError.Unauthorized::class,
ArchiveApiV2.GetServiceCredentialsError.BackupIdNotFound to ArchiveError.CredentialError.NotFound::class,
ArchiveApiV2.GetServiceCredentialsError.RateLimited(5.seconds) to ArchiveError.CredentialError.RateLimited::class
)
for ((apiError, expected) in cases) {
val store = FakeArchiveCacheStore()
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.NonSuccess(apiError)
val result = serviceFor(store).getArchiveServiceAccess()
assertThat(result.leftOrNull()!!::class, name = "$apiError").isEqualTo(expected)
}
}
// endregion
// region First-time initialization
@Test
fun `uninitialized account reserves the backupId, sets both public keys, and marks initialized`() = runTest {
val store = FakeArchiveCacheStore(backupsInitialized = false)
coEvery { archiveApi.triggerBackupIdReservation(any(), any(), any()) } returns RequestResult.Success(Unit)
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.Success(
ArchiveApiV2.ArchiveCredentials(listOf(credential()), listOf(credential()))
)
coEvery { archiveApi.setPublicKey(any(), any()) } returns RequestResult.Success(Unit)
val result = serviceFor(store).getArchiveServiceAccess()
assertThat(result.isRight()).isTrue()
assertThat(store.backupsInitialized).isTrue()
coVerifyOrder {
archiveApi.triggerBackupIdReservation(store.messageBackupKey, store.mediaRootBackupKey, store.aci)
archiveApi.getServiceCredentials(any())
archiveApi.setPublicKey(any(), any())
archiveApi.setPublicKey(any(), any())
}
coVerify(exactly = 2) { archiveApi.setPublicKey(any(), any()) }
}
@Test
fun `a failure while setting the public key does not mark backups initialized`() = runTest {
val store = FakeArchiveCacheStore(backupsInitialized = false)
coEvery { archiveApi.triggerBackupIdReservation(any(), any(), any()) } returns RequestResult.Success(Unit)
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.Success(
ArchiveApiV2.ArchiveCredentials(listOf(credential()), listOf(credential()))
)
coEvery { archiveApi.setPublicKey(any(), any()) } returns RequestResult.NonSuccess(RequestUnauthorizedException("nope"))
val result = serviceFor(store).getArchiveServiceAccess()
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
assertThat(store.backupsInitialized).isFalse()
}
@Test
fun `triggering a reservation without media skips the media key and leaves media credentials alone`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.triggerBackupIdReservation(any(), any(), any()) } returns RequestResult.Success(Unit)
val result = serviceFor(store).triggerBackupIdReservation(includeMedia = false)
assertThat(result.isRight()).isTrue()
coVerify { archiveApi.triggerBackupIdReservation(store.messageBackupKey, null, store.aci) }
assertThat(store.messageCredentials.isEmpty).isTrue()
assertThat(store.mediaCredentials.isEmpty).isFalse()
}
// endregion
// region Copying media
@Test
fun `copy outcomes map to their errors`() = runTest {
val cases = listOf<Pair<CopyBackupMediaOutcome, Any>>(
CopyBackupMediaOutcome.Success(ByteArray(15), 3) to 3,
CopyBackupMediaOutcome.SourceNotFound(ByteArray(15)) to ArchiveError.CopyMediaError.SourceNotFound,
CopyBackupMediaOutcome.WrongSourceLength(ByteArray(15)) to ArchiveError.CopyMediaError.WrongSourceLength,
CopyBackupMediaOutcome.OutOfSpace(ByteArray(15)) to ArchiveError.CopyMediaError.OutOfRemoteSpace
)
for ((outcome, expected) in cases) {
val store = storeWithCredentials()
coEvery { archiveApi.copyMediaToArchive(any(), any(), any()) } returns RequestResult.Success(listOf(outcome))
val result = serviceFor(store).copyToArchive(2, "location", 100, MediaName("name"))
assertThat(result.fold(ifLeft = { it }, ifRight = { it }), name = "$outcome").isEqualTo(expected)
}
}
@Test
fun `a copy that produced no outcome is an application error`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.copyMediaToArchive(any(), any(), any()) } returns RequestResult.Success(emptyList())
val result = serviceFor(store).copyToArchive(2, "location", 100, MediaName("name"))
assertThat(result.error()).isInstanceOf(ArchiveError.ApplicationError::class)
}
// endregion
// region Listing and deleting media
@Test
fun `a lack of entitlement while listing media notifies the store`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.getArchiveMediaItemsPage(any(), any(), any(), any()) } returns RequestResult.NonSuccess(ArchiveApiV2.GetMediaItemsError.Forbidden)
val result = serviceFor(store).listRemoteMediaObjects(limit = 10)
assertThat(result.error()).isInstanceOf(ArchiveError.EntitlementError.NotEntitled::class)
assertThat(store.notEntitledCount).isEqualTo(1)
}
@Test
fun `a rejected credential while listing media clears the media credentials`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.getArchiveMediaItemsPage(any(), any(), any(), any()) } returns RequestResult.NonSuccess(ArchiveApiV2.GetMediaItemsError.Unauthorized)
val result = serviceFor(store).listRemoteMediaObjects(limit = 10)
assertThat(result.error()).isInstanceOf(ArchiveError.CredentialError.Unauthorized::class)
assertThat(store.mediaCredentials.isEmpty).isTrue()
assertThat(store.backupsInitialized).isFalse()
assertThat(store.notEntitledCount).isEqualTo(0)
}
@Test
fun `deleting nothing is a success that never touches the network`() = runTest {
val store = storeWithCredentials()
val result = serviceFor(store).deleteArchivedMedia(emptyList())
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { archiveApi.deleteArchivedMedia(any(), any(), any()) }
}
@Test
fun `deletes are chunked at the server limit`() = runTest {
val store = storeWithCredentials()
val items = (1..2500).map { DeleteBackupMediaItem(ByteArray(15), 3) }
val chunkSizes = mutableListOf<Int>()
coEvery { archiveApi.deleteArchivedMedia(any(), any(), any()) } answers {
val chunk = arg<List<DeleteBackupMediaItem>>(2)
chunkSizes += chunk.size
RequestResult.Success(chunk)
}
val result = serviceFor(store).deleteArchivedMedia(items)
assertThat(result.isRight()).isTrue()
assertThat(chunkSizes).isEqualTo(listOf(1000, 1000, 500))
}
@Test
fun `a failed delete chunk stops the remaining chunks`() = runTest {
val store = storeWithCredentials()
val items = (1..2500).map { DeleteBackupMediaItem(ByteArray(15), 3) }
coEvery { archiveApi.deleteArchivedMedia(any(), any(), any()) } returns RequestResult.RetryableNetworkError(IOException("down"))
val result = serviceFor(store).deleteArchivedMedia(items)
assertThat(result.error()).isInstanceOf(ArchiveError.NetworkError::class)
coVerify(exactly = 1) { archiveApi.deleteArchivedMedia(any(), any(), any()) }
}
// endregion
// region Cached derived values
@Test
fun `the archived media cdn path is cached after the first fetch`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.getMediaBackupInfo(any(), any()) } returns RequestResult.Success(MediaBackupInfo("abc123", "def456", 100))
val service = serviceFor(store)
val first = service.getArchivedMediaCdnPath()
val second = service.getArchivedMediaCdnPath()
assertThat(first.getOrNull()).isEqualTo("abc123/def456")
assertThat(second.getOrNull()).isEqualTo("abc123/def456")
assertThat(store.cachedMediaCdnPath).isEqualTo("abc123/def456")
coVerify(exactly = 1) { archiveApi.getMediaBackupInfo(any(), any()) }
}
@Test
fun `cached cdn read credentials are preferred over a fetch`() = runTest {
val cached = GetArchiveCdnCredentialsResponse(mapOf("cached" to "yes"))
val store = storeWithCredentials().apply { messageCredentials.cdnReadCredentials = cached }
val result = serviceFor(store).getCdnReadCredentials(ArchiveService.CredentialType.MESSAGE, cdnNumber = 3)
assertThat(result.getOrNull()).isSameInstanceAs(cached)
coVerify(exactly = 0) { archiveApi.getCdnReadCredentials(any(), any(), any()) }
}
@Test
fun `freshly fetched cdn read credentials are cached against the right credential type`() = runTest {
val store = storeWithCredentials()
val fetched = GetArchiveCdnCredentialsResponse(mapOf("fresh" to "yes"))
coEvery { archiveApi.getCdnReadCredentials(any(), any(), any()) } returns RequestResult.Success(fetched)
val result = serviceFor(store).getCdnReadCredentials(ArchiveService.CredentialType.MEDIA, cdnNumber = 3)
assertThat(result.getOrNull()).isSameInstanceAs(fetched)
assertThat(store.mediaCredentials.cdnReadCredentials).isSameInstanceAs(fetched)
assertThat(store.messageCredentials.cdnReadCredentials).isNull()
}
// endregion
// region Backup file location
@Test
fun `the message backup file location is built from the backup info`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.getMessageBackupInfo(any(), any()) } returns RequestResult.Success(MessageBackupInfo("dir", 3, "name"))
coEvery { archiveApi.getCdnReadCredentials(any(), any(), any()) } returns RequestResult.Success(GetArchiveCdnCredentialsResponse(mapOf("a" to "b")))
val location = serviceFor(store).getMessageBackupFileLocation().getOrNull()
assertThat(location).isNotNull()
assertThat(location!!.cdn).isEqualTo(3)
assertThat(location.path).isEqualTo("backups/dir/name")
assertThat(location.cdnCredentials).isEqualTo(mapOf("a" to "b"))
}
@Test
fun `a location lookup for a specific key uses a freshly fetched credential rather than the cache`() = runTest {
val store = storeWithCredentials()
val currentTime = System.currentTimeMillis()
val startOfDay = currentTime.milliseconds.inWholeDays.days.inWholeSeconds
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.Success(
ArchiveApiV2.ArchiveCredentials(
messageCredentials = listOf(credential(redemptionTime = startOfDay)),
mediaCredentials = emptyList()
)
)
coEvery { archiveApi.getMessageBackupInfo(any(), any()) } returns RequestResult.Success(MessageBackupInfo("dir", 3, "name"))
coEvery { archiveApi.getCdnReadCredentials(any(), any(), any()) } returns RequestResult.Success(GetArchiveCdnCredentialsResponse(mapOf("a" to "b")))
val result = serviceFor(store).getMessageBackupFileLocationForKey(store.aci, store.messageBackupKey)
assertThat(result.getOrNull()?.path).isEqualTo("backups/dir/name")
coVerify(exactly = 1) { archiveApi.getServiceCredentials(any()) }
// The point of this path is to check a key the user typed, so nothing about it may be written back to the cache.
assertThat(store.messageCredentials.clearedOlderThan).isEmpty()
assertThat(store.messageCredentials.cdnReadCredentials).isNull()
}
@Test
fun `a location lookup for a specific key fails when no credential covers today`() = runTest {
val store = storeWithCredentials()
coEvery { archiveApi.getServiceCredentials(any()) } returns RequestResult.Success(
ArchiveApiV2.ArchiveCredentials(
messageCredentials = listOf(credential(redemptionTime = 1)),
mediaCredentials = emptyList()
)
)
val result = serviceFor(store).getMessageBackupFileLocationForKey(store.aci, store.messageBackupKey)
assertThat(result.error()).isInstanceOf(ArchiveError.ApplicationError::class)
}
// endregion
// region successOrThrow
@Test
fun `successOrThrow rethrows the underlying cause`() {
val cause = IOException("boom")
val result: Either<ArchiveError, Unit> = ArchiveError.NetworkError(cause).left()
val thrown = runCatching { result.successOrThrow() }.exceptionOrNull()
assertThat(thrown).isSameInstanceAs(cause)
}
@Test
fun `successOrThrow reconstructs the legacy status code for an error with no cause`() {
// The throwing call sites are jobs that branch on NonSuccessfulResponseCodeException.code to pick a retry policy, so a bare IOException would silently
// change their behavior. Anything unmapped must still be an IOException, since the job runner treats a runtime exception as a crash.
val cases = mapOf<ArchiveError, Int>(
ArchiveError.CredentialError.InvalidRequest() to 400,
ArchiveError.CredentialError.Unauthorized() to 401,
ArchiveError.EntitlementError.NotEntitled() to 403,
ArchiveError.CredentialError.NotFound() to 404,
ArchiveError.CredentialError.RateLimited(null) to 429
)
for ((error, expectedCode) in cases) {
val result: Either<ArchiveError, Unit> = error.left()
val thrown = runCatching { result.successOrThrow() }.exceptionOrNull()!!
assertThat(thrown, name = "$error").isInstanceOf(NonSuccessfulResponseCodeException::class)
assertThat((thrown as NonSuccessfulResponseCodeException).code, name = "$error").isEqualTo(expectedCode)
}
}
@Test
fun `successOrThrow throws a plain IOException for an unmapped error with no cause`() {
val result: Either<ArchiveError, Unit> = ArchiveError.CopyMediaError.SourceNotFound.left()
val thrown = runCatching { result.successOrThrow() }.exceptionOrNull()
assertThat(thrown!!).isInstanceOf(IOException::class)
}
// endregion
// region Server-side vs transport failures
@Test
fun `a server error is distinguishable from a transport failure`() {
// ArchiveBackupIdReservationJob backs off much harder for a struggling server than for a flaky connection.
assertThat(ArchiveError.NetworkError(ServerSideErrorException("Server error: 503")).isServerSide).isTrue()
assertThat(ArchiveError.NetworkError(IOException("connection reset")).isServerSide).isFalse()
}
// endregion
// region Refresh
@Test
fun `refreshing a paid backup refreshes both message and media access`() = runTest {
val store = storeWithCredentials()
givenSuccessfulRefresh(backupLevel = BackupLevel.PAID)
val result = serviceFor(store).refreshBackup()
assertThat(result.isRight()).isTrue()
coVerify(exactly = 2) { archiveApi.refreshBackup(any(), any()) }
}
@Test
fun `refreshing a free backup refreshes message access only`() = runTest {
val store = storeWithCredentials()
givenSuccessfulRefresh(backupLevel = BackupLevel.FREE)
val result = serviceFor(store).refreshBackup()
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { archiveApi.refreshBackup(any(), any()) }
}
// endregion
// region Helpers
private fun storeWithCredentials(cachedMediaCdnPath: String? = null): FakeArchiveCacheStore {
return FakeArchiveCacheStore(cachedMediaCdnPath = cachedMediaCdnPath).apply {
messageCredentials.add(listOf(credential()))
mediaCredentials.add(listOf(credential()))
}
}
private fun credential(redemptionTime: Long = 0): ArchiveServiceCredential {
return ArchiveServiceCredential(ByteArray(16), redemptionTime)
}
/** The raised error, failing the test if the operation actually succeeded. */
private fun <E : ArchiveError, T> Either<E, T>.error(): E {
return leftOrNull() ?: throw AssertionError("Expected an error, but the operation succeeded with ${getOrNull()}")
}
private fun givenSuccessfulRefresh(backupLevel: BackupLevel = BackupLevel.FREE) {
every { archiveApi.getZkCredential(any(), any()) } returns ArchiveApiV2.ZkCredentialResult.Success(
mockk<BackupAuthCredential> { every { this@mockk.backupLevel } returns backupLevel }
)
coEvery { archiveApi.refreshBackup(any(), any()) } returns RequestResult.Success(Unit)
}
// endregion
}
@@ -0,0 +1,73 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network.service
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.backup.MediaRootBackupKey
import org.signal.core.models.backup.MessageBackupKey
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import java.util.UUID
import kotlin.time.Duration
/**
* In-memory [ArchiveCacheStore] for [ArchiveServiceTest].
*
* A real implementation rather than a mock, because most of what these tests assert is which pieces of local state an error clears, and that reads far better as
* a state assertion than as a verified call.
*
* [CredentialCache.getForTime] ignores the time it's handed and just reports whatever was last added. Day-boundary lookup lives in the app's `BackupValues`, not
* here, so honoring it would only make these tests depend on the wall clock. The one place rounding actually matters --
* [ArchiveService.getMessageBackupFileLocationForKey], which matches a credential's `redemptionTime` against the start of the current day -- is covered by
* building a credential with the matching redemption time.
*/
class FakeArchiveCacheStore(
override val aci: ACI = ACI.from(UUID.fromString("aaaaaaaa-0000-0000-0000-000000000001")),
override val messageBackupKey: MessageBackupKey = MessageBackupKey(ByteArray(32) { 1 }),
override val mediaRootBackupKey: MediaRootBackupKey = MediaRootBackupKey(ByteArray(32) { 2 }),
override var backupsInitialized: Boolean = true,
override var cachedMediaCdnPath: String? = null,
override val isLinkedDevice: Boolean = false,
override val isPreRestoreDuringRegistration: Boolean = false
) : ArchiveCacheStore {
override val messageCredentials = CredentialCache()
override val mediaCredentials = CredentialCache()
/** How many times [onNotEntitled] was called. */
var notEntitledCount: Int = 0
private set
override fun onNotEntitled() {
notEntitledCount++
}
class CredentialCache : ArchiveCacheStore.CredentialCache {
private val credentials = mutableListOf<ArchiveServiceCredential>()
/** How many times [clearOlderThan] was called, and with what. */
val clearedOlderThan = mutableListOf<Long>()
override fun getForTime(currentTime: Duration): ArchiveServiceCredential? = credentials.lastOrNull()
override fun add(credentials: List<ArchiveServiceCredential>) {
this.credentials += credentials
}
override fun clearOlderThan(startOfDayInSeconds: Long) {
clearedOlderThan += startOfDayInSeconds
}
override fun clearAll() {
credentials.clear()
cdnReadCredentials = null
}
override var cdnReadCredentials: GetArchiveCdnCredentialsResponse? = null
val isEmpty: Boolean get() = credentials.isEmpty()
}
}