mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 12:55:11 +01:00
Refactor archive requests into ArchiveService.
This commit is contained in:
committed by
Alex Hart
parent
1911bf7df7
commit
34648c2c04
+10
-1
@@ -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
|
||||
|
||||
+13
-15
@@ -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
|
||||
|
||||
|
||||
+13
-19
@@ -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
+5
-2
@@ -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()
|
||||
|
||||
+93
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-13
@@ -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 {
|
||||
|
||||
+7
-10
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -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)
|
||||
|
||||
+26
-23
@@ -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
|
||||
|
||||
+15
-2
@@ -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);
|
||||
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
|
||||
+33
-25
@@ -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() }")
|
||||
|
||||
+23
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-30
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -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
|
||||
|
||||
|
||||
+6
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+20
-24
@@ -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)) {
|
||||
|
||||
+13
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
-24
@@ -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
|
||||
)
|
||||
}
|
||||
-11
@@ -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?
|
||||
)
|
||||
-26
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-39
@@ -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]!!
|
||||
}
|
||||
-32
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user