From 34648c2c04ba4272e13e0f43ddefea58a60eaa6c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Sat, 1 Aug 2026 00:11:30 -0400 Subject: [PATCH] Refactor archive requests into ArchiveService. --- ...umentationApplicationDependencyProvider.kt | 11 +- .../ArchiveAttachmentReconciliationJobTest.kt | 28 +- .../securesms/jobs/BackupDeleteJobTest.kt | 30 +- .../jobs/BackupSubscriptionCheckJobTest.kt | 32 +- .../securesms/testing/InAppPaymentsRule.kt | 6 +- .../securesms/backup/v2/BackupRepository.kt | 871 +++++------------- .../v2/DatabaseAttachmentArchiveUtil.kt | 7 +- .../backup/v2/SignalStoreArchiveCacheStore.kt | 93 ++ .../MessageBackupsFlowViewModel.kt | 25 +- .../remote/BackupKeyDisplayViewModel.kt | 17 +- .../remote/RemoteBackupsSettingsViewModel.kt | 16 +- .../InternalBackupPlaygroundViewModel.kt | 49 +- .../securesms/dependencies/AppDependencies.kt | 14 +- .../ApplicationDependencyProvider.java | 17 +- .../dependencies/NetworkDependenciesModule.kt | 8 +- .../ArchiveAttachmentReconciliationJob.kt | 58 +- .../jobs/ArchiveBackupIdReservationJob.kt | 36 +- .../jobs/ArchiveCommitAttachmentDeletesJob.kt | 66 +- .../jobs/ArchiveThumbnailUploadJob.kt | 120 +-- .../securesms/jobs/BackupDeleteJob.kt | 81 +- .../securesms/jobs/BackupMessagesJob.kt | 123 +-- .../securesms/jobs/BackupRefreshJob.kt | 46 +- .../jobs/BackupSubscriptionCheckJob.kt | 7 +- .../jobs/CopyAttachmentToArchiveJob.kt | 66 +- .../jobs/InAppPaymentRecurringContextJob.kt | 11 +- .../securesms/jobs/RestoreAttachmentJob.kt | 5 +- .../jobs/RestoreAttachmentThumbnailJob.kt | 5 +- .../jobs/UploadAttachmentToArchiveJob.kt | 65 +- .../securesms/net/SignalNetwork.kt | 4 + .../ui/restore/RemoteRestoreViewModel.kt | 10 +- .../v2/AppRegistrationNetworkController.kt | 44 +- .../thoughtcrime/securesms/BackupPlugin.kt | 17 +- .../MockApplicationDependencyProvider.kt | 14 +- .../InAppPaymentRecurringContextJobTest.kt | 4 +- .../archive/ArchiveGetMediaItemsResponse.kt | 24 - .../ArchiveKeyRotationLimitResponse.kt | 11 - .../ArchiveMediaUploadFormStatusCodes.kt | 26 - .../ArchiveServiceCredentialsResponse.kt | 39 - .../api/archive/ArchiveSetBackupIdRequest.kt | 32 - .../java/org/signal/network/api/ArchiveApi.kt | 438 +-------- .../org/signal/network/api/ArchiveApiV2.kt | 574 ++++++++++++ .../network/service/ArchiveCacheStore.kt | 75 ++ .../signal/network/service/ArchiveService.kt | 859 +++++++++++++++++ .../network/service/ArchiveServiceTest.kt | 706 ++++++++++++++ .../network/service/FakeArchiveCacheStore.kt | 73 ++ 45 files changed, 3171 insertions(+), 1692 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/SignalStoreArchiveCacheStore.kt delete mode 100644 lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetMediaItemsResponse.kt delete mode 100644 lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveKeyRotationLimitResponse.kt delete mode 100644 lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaUploadFormStatusCodes.kt delete mode 100644 lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveServiceCredentialsResponse.kt delete mode 100644 lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveSetBackupIdRequest.kt create mode 100644 lib/network/src/main/java/org/signal/network/api/ArchiveApiV2.kt create mode 100644 lib/network/src/main/java/org/signal/network/service/ArchiveCacheStore.kt create mode 100644 lib/network/src/main/java/org/signal/network/service/ArchiveService.kt create mode 100644 lib/network/src/test/java/org/signal/network/service/ArchiveServiceTest.kt create mode 100644 lib/network/src/test/java/org/signal/network/service/FakeArchiveCacheStore.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt index 4c4bdd1000..3dae32f80e 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt @@ -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 diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentReconciliationJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentReconciliationJobTest.kt index d672817480..8080f6b85f 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentReconciliationJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentReconciliationJobTest.kt @@ -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 { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupDeleteJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupDeleteJobTest.kt index 78032bde9b..10841eda28 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupDeleteJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupDeleteJobTest.kt @@ -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 diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt index e5de2260a1..b356cc1e15 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt @@ -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 { + return SignalStore.backup.backupTier?.right() ?: ArchiveError.CredentialError.NotFound(NonSuccessfulResponseCodeException(404)).left() + } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt index 77d639cfcb..cddfbff43e 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 14051c61c9..9739b833eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -11,9 +11,13 @@ import androidx.annotation.CheckResult import androidx.annotation.Discouraged import androidx.annotation.WorkerThread import androidx.core.app.NotificationCompat +import arrow.core.Either +import arrow.core.flatMap +import arrow.core.left import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okio.ByteString import okio.ByteString.Companion.toByteString @@ -63,26 +67,24 @@ import org.signal.core.util.requireIntOrNull import org.signal.core.util.requireNonNullString import org.signal.core.util.requireString import org.signal.core.util.stream.NonClosingOutputStream -import org.signal.core.util.urlEncode import org.signal.core.util.withinTransaction import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken -import org.signal.libsignal.net.CopyBackupMediaItem -import org.signal.libsignal.net.CopyBackupMediaOutcome import org.signal.libsignal.net.DeleteBackupMediaItem -import org.signal.libsignal.zkgroup.VerificationFailedException +import org.signal.libsignal.net.RequestResult import org.signal.libsignal.zkgroup.backups.BackupLevel import org.signal.libsignal.zkgroup.profiles.ProfileKey -import org.signal.network.ApplicationErrorAction import org.signal.network.NetworkResult -import org.signal.network.StatusCodeErrorAction +import org.signal.network.api.ArchiveApiV2 import org.signal.network.api.SvrBApi import org.signal.network.exceptions.NonSuccessfulResponseCodeException +import org.signal.network.service.ArchiveError +import org.signal.network.service.ArchiveService +import org.signal.network.service.toArchiveResult import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.Cdn import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.backup.ArchiveUploadProgress import org.thoughtcrime.securesms.backup.DeletionState -import org.thoughtcrime.securesms.backup.v2.BackupRepository.copyAttachmentToArchive import org.thoughtcrime.securesms.backup.v2.BackupRepository.exportForDebugging import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter import org.thoughtcrime.securesms.backup.v2.processor.AccountDataArchiveProcessor @@ -136,10 +138,8 @@ import org.thoughtcrime.securesms.jobs.StorageForcePushJob import org.thoughtcrime.securesms.jobs.Svr2MirrorJob import org.thoughtcrime.securesms.jobs.UploadAttachmentToArchiveJob import org.thoughtcrime.securesms.keyvalue.BackupValues -import org.thoughtcrime.securesms.keyvalue.BackupValues.ArchiveServiceCredentials import org.thoughtcrime.securesms.keyvalue.KeyValueStore import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.keyvalue.isDecisionPending import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository import org.thoughtcrime.securesms.net.SignalNetwork @@ -153,19 +153,10 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.toMillis -import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse -import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse -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.api.link.TransferArchiveResponse import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener -import org.whispersystems.signalservice.internal.crypto.PaddingInputStream import org.whispersystems.signalservice.internal.push.AttachmentUploadForm -import org.whispersystems.signalservice.internal.push.AuthCredentials import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -198,38 +189,8 @@ object BackupRepository { private const val RECENT_RECIPIENTS_MAX = 50 private val MANUAL_BACKUP_NOTIFICATION_THRESHOLD = 30.days - private val resetInitializedStateErrorAction: StatusCodeErrorAction = { error -> - when (error.code) { - 401 -> { - Log.w(TAG, "Received status 401. Resetting initialized state + auth credentials.", error.exception) - resetInitializedStateAndAuthCredentials() - } - - 403 -> { - if (SignalStore.backup.backupTierInternalOverride != null) { - Log.w(TAG, "Received status 403, but the internal override is set, so not doing anything.", error.exception) - } else { - Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception) - 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 - scheduleSyncForAccountChange() - } - - SignalStore.uiHints.markHasEverEnabledRemoteBackups() - } - } - } - } - - private val clearAuthCredentials: ApplicationErrorAction = { error -> - if (error.getCause() is VerificationFailedException) { - Log.w(TAG, "Unable to verify/receive credentials, clearing cache to fetch new.", error.getCause()) - SignalStore.backup.messageCredentials.clearAll() - SignalStore.backup.mediaCredentials.clearAll() - } - } + private val archiveService: ArchiveService + get() = AppDependencies.archiveService /** * Generates a new AEP that the user can choose to confirm. @@ -283,86 +244,6 @@ object BackupRepository { } } - /** - * Triggers backup id reservation. As documented, this is safe to perform multiple times. - */ - @WorkerThread - fun triggerBackupIdReservation(): NetworkResult { - val messageBackupKey = SignalStore.backup.messageBackupKey - val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey - return SignalNetwork.archive.triggerBackupIdReservation(messageBackupKey, mediaRootBackupKey, SignalStore.account.requireAci()) - .runIfSuccessful { - SignalStore.backup.messageCredentials.clearAll() - SignalStore.backup.mediaCredentials.clearAll() - } - } - - @WorkerThread - fun triggerBackupIdReservationForRestore(): NetworkResult { - val messageBackupKey = SignalStore.backup.messageBackupKey - return SignalNetwork.archive.triggerBackupIdReservation(messageBackupKey, null, SignalStore.account.requireAci()) - .runIfSuccessful { - SignalStore.backup.messageCredentials.clearAll() - } - } - - /** - * Refreshes backup via server - */ - fun refreshBackup(): NetworkResult { - Log.d(TAG, "Refreshing backup...") - - Log.d(TAG, "Fetching backup auth credential.") - val credentialResult = initBackupAndFetchAuth() - if (credentialResult.getCause() != null) { - Log.w(TAG, "Failed to access backup auth.", credentialResult.getCause()) - return credentialResult.map { Unit } - } - - val credential = credentialResult.successOrThrow() - - Log.d(TAG, "Fetched backup auth credential. Fetching backup tier.") - - val backupTierResult = getBackupTier() - if (backupTierResult.getCause() != null) { - Log.w(TAG, "Failed to access backup tier.", backupTierResult.getCause()) - return backupTierResult.map { Unit } - } - - val backupTier = backupTierResult.successOrThrow() - - Log.d(TAG, "Fetched backup tier. Refreshing message backup access.") - val messageBackupAccessResult = AppDependencies.archiveApi.refreshBackup( - aci = SignalStore.account.requireAci(), - archiveServiceAccess = credential.messageBackupAccess - ) - - if (messageBackupAccessResult.getCause() != null) { - Log.d(TAG, "Failed to refresh message backup access.", messageBackupAccessResult.getCause()) - return messageBackupAccessResult - } - - Log.d(TAG, "Refreshed message backup access.") - if (backupTier == MessageBackupTier.PAID) { - Log.d(TAG, "Refreshing media backup access.") - - val mediaBackupAccessResult = AppDependencies.archiveApi.refreshBackup( - aci = SignalStore.account.requireAci(), - archiveServiceAccess = credential.mediaBackupAccess - ) - - if (mediaBackupAccessResult.getCause() != null) { - Log.d(TAG, "Failed to refresh media backup access.", mediaBackupAccessResult.getCause()) - } - - Log.d(TAG, "Refreshed media backup access.") - - return mediaBackupAccessResult - } else { - return messageBackupAccessResult - } - } - /** * Checks whether or not we do not have enough storage space for our remaining attachments to be downloaded. * Caller from the attachment / thumbnail download jobs. @@ -1548,35 +1429,16 @@ object BackupRepository { return ImportResult.Success(backupTime = header.backupTimeMs, selfRecipientId = selfId) } - fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.getArchiveMediaItemsPage(SignalStore.account.requireAci(), credential.mediaBackupAccess, limit, cursor) - }.runOnStatusCodeError { - SignalStore.backup.mediaCredentials.clearAll() - } - } - /** * Grabs the backup tier we think the user is on without performing any kind of authentication clearing * on a 403 error. Ensures we can check without rolling the user back during the BackupSubscriptionCheckJob. */ - fun getBackupTierWithoutDowngrade(): NetworkResult { - return if (SignalStore.backup.areBackupsEnabled) { - getArchiveServiceAccessPair() - .then { credential -> - val zkCredential = SignalNetwork.archive.getZkCredential(Recipient.self().requireAci(), credential.messageBackupAccess) - val tier = if (zkCredential.backupLevel == BackupLevel.PAID) { - MessageBackupTier.PAID - } else { - MessageBackupTier.FREE - } - - NetworkResult.Success(tier) - } - } else { - NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(404)) + fun getBackupTierWithoutDowngrade(): Either { + if (!SignalStore.backup.areBackupsEnabled) { + return ArchiveError.CredentialError.NotFound(NonSuccessfulResponseCodeException(404)).left() } + + return runBlocking { archiveService.getBackupLevelWithoutDowngrade() }.map { it.toMessageBackupTier() } } /** @@ -1585,12 +1447,12 @@ object BackupRepository { * * Note that this will set the user's backup tier to FREE if they are not on PAID, so avoid this method if you don't intend that to be the case. */ - fun getBackupTier(): NetworkResult { - return if (SignalStore.backup.areBackupsEnabled) { - getBackupTier(Recipient.self().requireAci()) - } else { - NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(404)) + fun getBackupTier(): Either { + if (!SignalStore.backup.areBackupsEnabled) { + return ArchiveError.CredentialError.NotFound(NonSuccessfulResponseCodeException(404)).left() } + + return runBlocking { archiveService.getBackupLevel() }.map { it.toMessageBackupTier() } } fun enablePaidBackupTier() { @@ -1603,114 +1465,45 @@ object BackupRepository { scheduleSyncForAccountChange() } - /** - * Grabs the backup tier for the given ACI. Note that this will set the user's backup - * tier to FREE if they are not on PAID, so avoid this method if you don't intend that - * to be the case. - */ - private fun getBackupTier(aci: ACI): NetworkResult { - return initBackupAndFetchAuth() - .map { credential -> - val zkCredential = SignalNetwork.archive.getZkCredential(aci, credential.messageBackupAccess) - if (zkCredential.backupLevel == BackupLevel.PAID) { - MessageBackupTier.PAID - } else { - MessageBackupTier.FREE - } - } - } - - /** - * Returns an object with details about the remote backup state. - */ - fun debugGetRemoteBackupState(): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.getMediaBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess) - .map { it to credential } - } - .then { pair -> - val (mediaBackupInfo, credential) = pair - SignalNetwork.archive.debugGetUploadedMediaItemMetadata(SignalStore.account.requireAci(), credential.mediaBackupAccess) - .map { mediaObjects -> - DebugBackupMetadata( - usedSpace = mediaBackupInfo.usedSpace, - mediaCount = mediaObjects.size.toLong(), - mediaSize = mediaObjects.sumOf { it.objectLength } - ) - } - } - } - - fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize) - } - } - - fun downloadBackupFile(destination: File, listener: ProgressListener? = null): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) - } - .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } } - .map { pair -> - val (cdnCredentials, info) = pair - val messageReceiver = AppDependencies.signalServiceMessageReceiver - messageReceiver.retrieveBackup(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}", destination, listener) - } - } - - fun getBackupFileLastModified(): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) - } - .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } } - .then { pair -> - val (cdnCredentials, info) = pair + fun downloadBackupFile(destination: File, listener: ProgressListener? = null): Either { + return runBlocking { archiveService.getMessageBackupFileLocation() } + .flatMap { location -> NetworkResult.fromFetch { - AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}") + AppDependencies.signalServiceMessageReceiver.retrieveBackup(location.cdn, location.cdnCredentials, location.path, destination, listener) + }.toArchiveResult() + } + } + + fun getBackupFileLastModified(): Either { + return runBlocking { archiveService.getMessageBackupFileLocation() } + .flatMap { location -> location.getLastModified() } + } + + /** + * Stores the remote backup's last-modified time in [BackupValues.lastBackupTime]. + */ + fun refreshBackupFileTimestamp(): Either { + return getBackupFileLastModified() + .onRight { SignalStore.backup.lastBackupTime = it.toMillis() } + .onLeft { error -> + when (error) { + is ArchiveError.CredentialError.NotFound, + is ArchiveError.CredentialError.Unauthorized -> { + SignalStore.backup.lastBackupTime = 0L + } + is ArchiveError.EntitlementError.NotEntitled, + is ArchiveError.CredentialError.InvalidRequest, + is ArchiveError.CredentialError.RateLimited, + is ArchiveError.BackupFileError.UnexpectedResponse, + is ArchiveError.CredentialError.ZkVerificationFailed, + is ArchiveError.NetworkError, + is ArchiveError.ApplicationError -> { + Log.w(TAG, "Failed to refresh last backup time from remote: ${error::class.simpleName}") + } } } } - /** - * Stores the remote backup's last-modified time in [BackupValues.lastBackupTime], (404/401 clear it to 0). - */ - fun refreshBackupFileTimestamp(): NetworkResult { - return getBackupFileLastModified().also { result -> - when (result) { - is NetworkResult.Success -> SignalStore.backup.lastBackupTime = result.result.toMillis() - is NetworkResult.StatusCodeError if (result.code == 404 || result.code == 401) -> SignalStore.backup.lastBackupTime = 0L - else -> Log.w(TAG, "Failed to refresh last backup time from remote: ${result::class.simpleName}") - } - } - } - - /** - * Returns an object with details about the remote backup state. - */ - fun debugGetArchivedMediaState(): NetworkResult> { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.debugGetUploadedMediaItemMetadata(SignalStore.account.requireAci(), credential.mediaBackupAccess) - } - } - - /** - * Retrieves an [AttachmentUploadForm] that can be used to upload an attachment to the transit cdn. - * - * It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive]. - */ - fun getAttachmentUploadForm(uploadLength: Long): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess, uploadLength) - } - } - /** * Returns if an attachment should be copied to the archive if it meets certain requirements eg * not a story, not already uploaded to the archive cdn, not a preuploaded attachment, etc. @@ -1736,286 +1529,104 @@ object BackupRepository { } /** - * Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn. - * - * @return The archive cdn number the thumbnail landed on. + * Copies an attachment that has been uploaded to the transit cdn to the archive cdn, recording the cdn it landed on. */ - fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - val item = buildCopyBackupMediaItem(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey) - copySingleMediaToArchive(credential.mediaBackupAccess, item) - } + suspend fun copyAttachmentToArchive(attachment: DatabaseAttachment): Either { + return archiveService.copyToArchive( + cdnNumber = attachment.cdn.cdnNumber, + remoteLocation = attachment.remoteLocation!!, + plaintextSize = attachment.size, + mediaName = attachment.requireMediaName() + ) + .map { archiveCdn -> SignalDatabase.attachments.setArchiveCdn(attachmentId = attachment.attachmentId, archiveCdn = archiveCdn) } + .also { Log.i(TAG, "archiveMediaResult: ${it.describe()}") } } - /** - * Copies an attachment that has been uploaded to the transit cdn to the archive cdn. - */ - fun copyAttachmentToArchive(attachment: DatabaseAttachment): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - val item = buildCopyBackupMediaItem(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, attachment.requireMediaName(), credential.mediaBackupAccess.backupKey) - copySingleMediaToArchive(credential.mediaBackupAccess, item) - } - .map { archiveCdn -> - SignalDatabase.attachments.setArchiveCdn(attachmentId = attachment.attachmentId, archiveCdn = archiveCdn) - } - .also { Log.i(TAG, "archiveMediaResult: ${it::class.simpleName}") } - } - - fun deleteAbandonedMediaObjects(mediaObjects: Collection): NetworkResult { - return NetworkResult - .fromLocal { mediaObjects.filter { it.cdn == Cdn.CDN_3.cdnNumber }.map { it.toDeleteBackupMediaItem() } } - .then { mediaToDelete -> - if (mediaToDelete.isEmpty()) { - Log.i(TAG, "No media to delete, quick success") - return@then NetworkResult.Success(Unit) - } - - initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.deleteArchivedMedia( - aci = SignalStore.account.requireAci(), - archiveServiceAccess = credential.mediaBackupAccess, - mediaToDelete = mediaToDelete - ).map { } - } - } - .also { Log.i(TAG, "deleteAbandonedMediaObjectsResult: ${it::class.simpleName}") } - } - - fun deleteBackup(): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.deleteBackup(SignalStore.account.requireAci(), credential.messageBackupAccess) - } - } - - fun deleteMediaBackup(): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.deleteBackup(SignalStore.account.requireAci(), credential.mediaBackupAccess) - } - } - - fun debugDeleteAllArchivedMedia(): NetworkResult { - val itemLimit = 1000 - return debugGetArchivedMediaState() - .then { archivedMedia -> - val mediaChunksToDelete = archivedMedia - .filter { it.cdn == Cdn.CDN_3.cdnNumber } - .map { ArchivedMediaObject(mediaId = it.mediaId, cdn = it.cdn).toDeleteBackupMediaItem() } - .chunked(itemLimit) - - if (mediaChunksToDelete.isEmpty()) { - Log.i(TAG, "No media to delete, quick success") - return@then NetworkResult.Success(Unit) - } - - getArchiveServiceAccessPair() - .then processChunks@{ credential -> - mediaChunksToDelete.forEachIndexed { index, chunk -> - val result = SignalNetwork.archive.deleteArchivedMedia( - aci = SignalStore.account.requireAci(), - archiveServiceAccess = credential.mediaBackupAccess, - mediaToDelete = chunk - ).map { } - - if (result !is NetworkResult.Success) { - Log.w(TAG, "Error occurred while deleting archived media chunk #$index: $result") - return@processChunks result - } - } - NetworkResult.Success(Unit) - } - } - .map { - SignalDatabase.attachments.clearAllArchiveData() - } - .also { Log.i(TAG, "debugDeleteAllArchivedMediaResult: ${it::class.simpleName}") } - } - - /** - * Retrieve credentials for reading from the backup cdn. - */ - fun getCdnReadCredentials(credentialType: CredentialType, cdnNumber: Int): NetworkResult { - val credentialStore = when (credentialType) { - CredentialType.MESSAGE -> SignalStore.backup.messageCredentials - CredentialType.MEDIA -> SignalStore.backup.mediaCredentials - } - - val cached = credentialStore.cdnReadCredentials - if (cached != null) { - return NetworkResult.Success(cached) - } - - return initBackupAndFetchAuth() - .then { credential -> - val archiveServiceAccess = when (credentialType) { - CredentialType.MESSAGE -> credential.messageBackupAccess - CredentialType.MEDIA -> credential.mediaBackupAccess - } - - SignalNetwork.archive.getCdnReadCredentials( - cdnNumber = cdnNumber, - aci = SignalStore.account.requireAci(), - archiveServiceAccess = archiveServiceAccess + suspend fun debugDeleteAllArchivedMedia(): Either { + return archiveService + .debugGetArchivedMediaState() + .flatMap { archivedMedia -> + archiveService.deleteArchivedMedia( + archivedMedia + .filter { it.cdn == Cdn.CDN_3.cdnNumber } + .map { ArchivedMediaObject(mediaId = it.mediaId, cdn = it.cdn).toDeleteBackupMediaItem() } ) } - .also { - if (it is NetworkResult.Success) { - credentialStore.cdnReadCredentials = it.result - } - } - .also { Log.i(TAG, "getCdnReadCredentialsResult: ${it::class.simpleName}") } + .map { SignalDatabase.attachments.clearAllArchiveData() } + .also { Log.i(TAG, "debugDeleteAllArchivedMediaResult: ${it.describe()}") } } fun restoreBackupFileTimestamp(): RestoreTimestampResult { - val timestampResult: NetworkResult = getBackupFileLastModified() + val result = getBackupFileLastModified().toRestoreTimestampResult() - when { - timestampResult is NetworkResult.Success -> { - SignalStore.backup.lastBackupTime = timestampResult.result.toMillis() + when (result) { + is RestoreTimestampResult.Success -> { + SignalStore.backup.lastBackupTime = result.timestamp SignalStore.backup.isBackupTimestampRestored = true SignalStore.uiHints.markHasEverEnabledRemoteBackups() - return RestoreTimestampResult.Success(SignalStore.backup.lastBackupTime) } - timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 404 -> { - Log.i(TAG, "No backup file exists") + RestoreTimestampResult.NotFound, RestoreTimestampResult.BackupsNotEnabled -> { SignalStore.backup.lastBackupTime = 0L SignalStore.backup.isBackupTimestampRestored = true - return RestoreTimestampResult.NotFound } - timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 401 -> { - Log.i(TAG, "Backups not enabled") - SignalStore.backup.lastBackupTime = 0L - SignalStore.backup.isBackupTimestampRestored = true - return RestoreTimestampResult.BackupsNotEnabled - } - - timestampResult is NetworkResult.ApplicationError && timestampResult.getCause() is VerificationFailedException -> { - Log.w(TAG, "Entered AEP fails zk verification", timestampResult.getCause()) - return RestoreTimestampResult.VerificationFailure - } - - else -> { - Log.w(TAG, "Could not check for backup file.", timestampResult.getCause()) - return RestoreTimestampResult.Failure - } - } - } - - fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult { - Log.i(TAG, "Verifying enter aep is associated with account") - var result: RestoreTimestampResult = getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep) - - if (result is RestoreTimestampResult.VerificationFailure) { - Log.w(TAG, "Resetting backup id reservation due to zk verification failure") - val triggerResult = SignalNetwork.archive.triggerBackupIdReservation(aep.deriveMessageBackupKey(), null, aci) - result = when { - triggerResult is NetworkResult.Success -> { - Log.i(TAG, "Reset successful, retrying aep verification") - SignalStore.backup.messageCredentials.clearAll() - getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep) - } - - triggerResult is NetworkResult.StatusCodeError && triggerResult.code == 429 -> { - Log.w(TAG, "Rate limited when resetting backup id, failing operation $triggerResult") - RestoreTimestampResult.RateLimited(triggerResult.retryAfter()) - } - - else -> { - Log.w(TAG, "Reset backup id failed, failing operation", triggerResult.getCause()) - result - } - } + else -> Unit } return result } - private fun getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult { - val currentTime = System.currentTimeMillis() - val messageBackupKey = aep.deriveMessageBackupKey() + fun verifyBackupKeyAssociatedWithAccount(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult { + Log.i(TAG, "Verifying enter aep is associated with account") + val result: RestoreTimestampResult = getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep) - val result: NetworkResult = SignalNetwork.archive.getServiceCredentials(currentTime) - .then { result -> - val credential: ArchiveServiceCredential? = ArchiveServiceCredentials(result.messageCredentials.associateBy { it.redemptionTime }).getForCurrentTime(currentTime.milliseconds) + if (result !is RestoreTimestampResult.VerificationFailure) { + return result + } - if (credential == null) { - NetworkResult.ApplicationError(NullPointerException("No credential available for current time.")) - } else { - NetworkResult.Success( - ArchiveServiceAccess( - credential = credential, - backupKey = messageBackupKey - ) - ) + Log.w(TAG, "Resetting backup id reservation due to zk verification failure") + + return when (val triggerResult = runBlocking { SignalNetwork.archiveV2.triggerBackupIdReservation(aep.deriveMessageBackupKey(), null, aci) }) { + is RequestResult.Success -> { + Log.i(TAG, "Reset successful, retrying aep verification") + SignalStore.backup.messageCredentials.clearAll() + getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci, aep) + } + + is RequestResult.NonSuccess -> when (val error = triggerResult.error) { + is ArchiveApiV2.SetBackupIdError.RateLimited -> { + Log.w(TAG, "Rate limited when resetting backup id, failing operation") + RestoreTimestampResult.RateLimited(error.retryAfter) + } + + ArchiveApiV2.SetBackupIdError.InvalidCredential -> { + Log.w(TAG, "Reset backup id rejected the credential, failing operation") + result + } + + ArchiveApiV2.SetBackupIdError.Unauthorized -> { + Log.w(TAG, "Reset backup id rejected our account auth, failing operation") + result } } - .then { messageAccess -> - SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), messageAccess) - .then { info -> SignalNetwork.archive.getCdnReadCredentials(info.cdn, aci, messageAccess).map { it.headers to info } } - .then { pair -> - val (cdnCredentials, info) = pair - NetworkResult.fromFetch { - AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(info.cdn, cdnCredentials, "backups/${info.backupDir}/${info.backupName}") - } - } + + is RequestResult.RetryableNetworkError -> { + Log.w(TAG, "Reset backup id hit a network error, failing operation", triggerResult.networkError) + result } - return when { - result is NetworkResult.Success -> { - RestoreTimestampResult.Success(result.result.toMillis()) - } - - result is NetworkResult.StatusCodeError && result.code == 404 -> { - Log.i(TAG, "No backup file exists") - RestoreTimestampResult.NotFound - } - - result is NetworkResult.StatusCodeError && result.code == 401 -> { - Log.i(TAG, "Backups not enabled") - RestoreTimestampResult.BackupsNotEnabled - } - - result is NetworkResult.ApplicationError && result.getCause() is VerificationFailedException -> { - Log.w(TAG, "Entered AEP fails zk verification", result.getCause()) - RestoreTimestampResult.VerificationFailure - } - - else -> { - Log.w(TAG, "Could not check for backup file.", result.getCause()) - RestoreTimestampResult.Failure + is RequestResult.ApplicationError -> { + Log.w(TAG, "Reset backup id failed, failing operation", triggerResult.cause) + result } } } - /** - * Retrieves media-specific cdn path, preferring cached value if available. - * - * This will change if the backup expires, a new backup-id is set, or the delete all endpoint is called. - */ - fun getArchivedMediaCdnPath(): NetworkResult { - val cachedMediaPath = SignalStore.backup.cachedMediaCdnPath - - if (cachedMediaPath != null) { - return NetworkResult.Success(cachedMediaPath) - } - - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.getMediaBackupInfo(SignalStore.account.requireAci(), credential.mediaBackupAccess).map { - "${it.backupDir.urlEncode()}/${it.mediaDir.urlEncode()}" - } - } - .also { - if (it is NetworkResult.Success) { - SignalStore.backup.cachedMediaCdnPath = it.result - } - } + private fun getBackupTimestampToVerifyAepAssociatedWithAccountAndHasBackup(aci: ACI, aep: AccountEntropyPool): RestoreTimestampResult { + return runBlocking { archiveService.getMessageBackupFileLocationForKey(aci, aep.deriveMessageBackupKey()) } + .flatMap { location -> location.getLastModified() } + .toRestoreTimestampResult() } suspend fun getBackupTypes(availableBackupTiers: List): List { @@ -2088,93 +1699,7 @@ object BackupRepository { } } - /** - * See [org.signal.network.api.ArchiveApi.getSvrBAuthorization]. - */ - fun getSvrBAuth(): NetworkResult { - return initBackupAndFetchAuth() - .then { SignalNetwork.archive.getSvrBAuthorization(SignalStore.account.requireAci(), it.messageBackupAccess) } - } - - fun getKeyRotationLimit(): NetworkResult { - return SignalNetwork.archive.getKeyRotationLimit() - } - - /** - * During normal operation, ensures that the backupId has been reserved and that your public key has been set, - * while also returning an 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. - */ - private fun initBackupAndFetchAuth(): NetworkResult { - return if (SignalStore.backup.backupsInitialized || SignalStore.account.isLinkedDevice) { - getArchiveServiceAccessPair() - .runOnStatusCodeError(resetInitializedStateErrorAction) - .runOnApplicationError(clearAuthCredentials) - } else if (isPreRestoreDuringRegistration()) { - Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable()) - getArchiveServiceAccessPair() - .runOnApplicationError(clearAuthCredentials) - } else { - val messageBackupKey = SignalStore.backup.messageBackupKey - val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey - - return SignalNetwork.archive - .triggerBackupIdReservation(messageBackupKey, mediaRootBackupKey, SignalStore.account.requireAci()) - .then { - SignalStore.backup.messageCredentials.clearAll() - SignalStore.backup.mediaCredentials.clearAll() - getArchiveServiceAccessPair() - } - .then { credential -> SignalNetwork.archive.setPublicKey(SignalStore.account.requireAci(), credential.messageBackupAccess).map { credential } } - .then { credential -> SignalNetwork.archive.setPublicKey(SignalStore.account.requireAci(), credential.mediaBackupAccess).map { credential } } - .runIfSuccessful { SignalStore.backup.backupsInitialized = true } - .runOnStatusCodeError(resetInitializedStateErrorAction) - .runOnApplicationError(clearAuthCredentials) - } - } - - /** - * Retrieves an auth credential, preferring a cached value if available. - */ - private fun getArchiveServiceAccessPair(): NetworkResult { - val currentTime = System.currentTimeMillis() - - val messageCredential = SignalStore.backup.messageCredentials.byDay.getForCurrentTime(currentTime.milliseconds) - val mediaCredential = SignalStore.backup.mediaCredentials.byDay.getForCurrentTime(currentTime.milliseconds) - - if (messageCredential != null && mediaCredential != null) { - return NetworkResult.Success( - ArchiveServiceAccessPair( - messageBackupAccess = ArchiveServiceAccess(messageCredential, SignalStore.backup.messageBackupKey), - mediaBackupAccess = ArchiveServiceAccess(mediaCredential, SignalStore.backup.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.") - - return SignalNetwork.archive.getServiceCredentials(currentTime).map { result -> - SignalStore.backup.messageCredentials.add(result.messageCredentials) - SignalStore.backup.messageCredentials.clearOlderThan(currentTime) - - SignalStore.backup.mediaCredentials.add(result.mediaCredentials) - SignalStore.backup.mediaCredentials.clearOlderThan(currentTime) - - ArchiveServiceAccessPair( - messageBackupAccess = ArchiveServiceAccess(SignalStore.backup.messageCredentials.byDay.getForCurrentTime(currentTime.milliseconds)!!, SignalStore.backup.messageBackupKey), - mediaBackupAccess = ArchiveServiceAccess(SignalStore.backup.mediaCredentials.byDay.getForCurrentTime(currentTime.milliseconds)!!, SignalStore.backup.mediaRootBackupKey) - ) - } - } - - private fun isPreRestoreDuringRegistration(): Boolean { - return !SignalStore.registration.isRegistrationComplete && - SignalStore.registration.restoreDecisionState.isDecisionPending - } - - private fun scheduleSyncForAccountChange() { + internal fun scheduleSyncForAccountChange() { SignalDatabase.recipients.markNeedsSync(Recipient.self().id) StorageSyncHelper.scheduleSyncForDataChange() } @@ -2190,38 +1715,6 @@ object BackupRepository { val profileKey: ProfileKey ) - private fun buildCopyBackupMediaItem(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): CopyBackupMediaItem { - val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName) - - return CopyBackupMediaItem( - sourceAttachmentCdn = cdnNumber, - sourceKey = remoteLocation, - objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)), - mediaId = mediaSecrets.id.value, - encryptionKey = mediaSecrets.macKey + mediaSecrets.aesKey - ) - } - - /** - * Copies a single item to the archive cdn, returning the cdn it landed on. - * - * libsignal reports per-item outcomes rather than status codes, so we translate them back into the status codes callers already branch on: a missing source - * is a 410, a length mismatch is a 400, and no remaining space is a 413. - */ - private fun copySingleMediaToArchive(archiveServiceAccess: ArchiveServiceAccess, item: CopyBackupMediaItem): NetworkResult { - return SignalNetwork.archive - .copyMediaToArchive(SignalStore.account.requireAci(), archiveServiceAccess, listOf(item)) - .then { outcomes -> - when (val outcome = outcomes.firstOrNull()) { - is CopyBackupMediaOutcome.Success -> NetworkResult.Success(outcome.cdn) - is CopyBackupMediaOutcome.SourceNotFound -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(410, "Source attachment not found")) - is CopyBackupMediaOutcome.WrongSourceLength -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(400, "Wrong source length")) - is CopyBackupMediaOutcome.OutOfSpace -> NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(413, "No media space remaining")) - null -> NetworkResult.ApplicationError(IllegalStateException("Copy stream ended without an outcome for the item!")) - } - } - } - suspend fun restoreRemoteBackup(): RemoteRestoreResult { val context = AppDependencies.application ArchiveRestoreProgress.onRestorePending() @@ -2242,7 +1735,7 @@ object BackupRepository { } } - private fun restoreRemoteBackup(controller: BackupProgressService.Controller, cancellationSignal: () -> Boolean): RemoteRestoreResult { + private suspend fun restoreRemoteBackup(controller: BackupProgressService.Controller, cancellationSignal: () -> Boolean): RemoteRestoreResult { ArchiveRestoreProgress.onRestoringDb() val progressListener = object : ProgressListener { @@ -2261,10 +1754,10 @@ object BackupRepository { Log.i(TAG, "[remoteRestore] Downloading backup") val tempBackupFile = AppDependencies.blobs.forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) when (val result = downloadBackupFile(tempBackupFile, progressListener)) { - is NetworkResult.Success -> Log.i(TAG, "[remoteRestore] Download successful") - else -> { - Log.w(TAG, "[remoteRestore] Failed to download backup file", result.getCause()) - return RemoteRestoreResult.NetworkError + is Either.Right -> Log.i(TAG, "[remoteRestore] Download successful") + is Either.Left -> { + Log.w(TAG, "[remoteRestore] Failed to download backup file", result.value.cause) + return result.value.toRemoteRestoreFailure() } } @@ -2287,11 +1780,12 @@ object BackupRepository { val messageBackupKey = SignalStore.backup.messageBackupKey Log.i(TAG, "[remoteRestore] Fetching SVRB data") - val svrBAuth = when (val result = getSvrBAuth()) { - is NetworkResult.Success -> result.result - is NetworkResult.NetworkError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Network error when getting SVRB auth.", result.getCause()) - is NetworkResult.StatusCodeError -> return RemoteRestoreResult.NetworkError.logW(TAG, "[remoteRestore] Status code error when getting SVRB auth.", result.getCause()) - is NetworkResult.ApplicationError -> throw result.throwable + val svrBAuth = when (val result = archiveService.getSvrBAuth()) { + is Either.Right -> result.value + is Either.Left -> when (val error = result.value) { + is ArchiveError.ApplicationError -> throw error.exception + else -> return error.toRemoteRestoreFailure().logW(TAG, "[remoteRestore] Failed to get SVRB auth: ${error::class.simpleName}", error.cause) + } } val forwardSecrecyToken = when (val result = SignalNetwork.svrB.restore(svrBAuth, messageBackupKey, forwardSecrecyMetadata)) { @@ -2467,26 +1961,89 @@ object BackupRepository { ).encodeByteString() } - fun getRemoteBackupForwardSecrecyMetadata(): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> SignalNetwork.archive.getMessageBackupInfo(SignalStore.account.requireAci(), credential.messageBackupAccess) } - .then { info -> getCdnReadCredentials(CredentialType.MESSAGE, info.cdn).map { it.headers to info } } - .then { pair -> - val (cdnCredentials, info) = pair - val headers = cdnCredentials.toMutableMap().apply { + suspend fun getRemoteBackupForwardSecrecyMetadata(): Either { + return archiveService.getMessageBackupFileLocation() + .flatMap { location -> + val headers = location.cdnCredentials.toMutableMap().apply { this["range"] = "bytes=0-${EncryptedBackupReader.BACKUP_SECRET_METADATA_UPPERBOUND - 1}" } - AppDependencies.signalServiceMessageReceiver.retrieveBackupForwardSecretMetadataBytes( - info.cdn, - headers, - "backups/${info.backupDir}/${info.backupName}", - EncryptedBackupReader.BACKUP_SECRET_METADATA_UPPERBOUND - ) + AppDependencies.signalServiceMessageReceiver + .retrieveBackupForwardSecretMetadataBytes(location.cdn, headers, location.path, EncryptedBackupReader.BACKUP_SECRET_METADATA_UPPERBOUND) + .toArchiveResult() } .map { bytes -> EncryptedBackupReader.readForwardSecrecyMetadata(ByteArrayInputStream(bytes)) } } + private fun BackupLevel.toMessageBackupTier(): MessageBackupTier { + return if (this == BackupLevel.PAID) MessageBackupTier.PAID else MessageBackupTier.FREE + } + + private fun ArchiveService.BackupFileLocation.getLastModified(): Either { + return NetworkResult + .fromFetch { AppDependencies.signalServiceMessageReceiver.getCdnLastModifiedTime(cdn, cdnCredentials, path) } + .toArchiveResult() + } + + private fun Either.toRestoreTimestampResult(): RestoreTimestampResult { + return fold( + ifRight = { RestoreTimestampResult.Success(it.toMillis()) }, + ifLeft = { error -> + when (error) { + is ArchiveError.CredentialError.NotFound -> { + Log.i(TAG, "No backup file exists") + RestoreTimestampResult.NotFound + } + is ArchiveError.CredentialError.Unauthorized -> { + Log.i(TAG, "Backups not enabled") + RestoreTimestampResult.BackupsNotEnabled + } + is ArchiveError.CredentialError.ZkVerificationFailed -> { + Log.w(TAG, "Entered AEP fails zk verification", error.exception) + RestoreTimestampResult.VerificationFailure + } + is ArchiveError.EntitlementError.NotEntitled, + is ArchiveError.CredentialError.InvalidRequest, + is ArchiveError.CredentialError.RateLimited, + is ArchiveError.BackupFileError.UnexpectedResponse, + is ArchiveError.NetworkError, + is ArchiveError.ApplicationError -> { + Log.w(TAG, "Could not check for backup file: ${error::class.simpleName}", error.cause) + RestoreTimestampResult.Failure + } + } + } + ) + } + + private fun Either.describe(): String { + return fold(ifRight = { "Success" }, ifLeft = { it::class.simpleName ?: "Error" }) + } + + /** + * Whether a failed restore step is worth telling the user to check their connection over. + * + * [RemoteRestoreResult.NetworkError] drives "couldn't reach the server, try again" messaging, so only genuinely transient failures may map to it -- a rejected + * credential or a missing backup is a [RemoteRestoreResult.Failure] no amount of retrying fixes. + */ + private fun ArchiveError.BackupFileError.toRemoteRestoreFailure(): RemoteRestoreResult { + return when (this) { + is ArchiveError.NetworkError, + is ArchiveError.CredentialError.RateLimited -> { + RemoteRestoreResult.NetworkError + } + is ArchiveError.CredentialError.Unauthorized, + is ArchiveError.EntitlementError.NotEntitled, + is ArchiveError.CredentialError.NotFound, + is ArchiveError.CredentialError.InvalidRequest, + is ArchiveError.BackupFileError.UnexpectedResponse, + is ArchiveError.CredentialError.ZkVerificationFailed, + is ArchiveError.ApplicationError -> { + RemoteRestoreResult.Failure + } + } + } + interface ExportProgressListener { fun onAccount() fun onRecipient() @@ -2498,10 +2055,6 @@ object BackupRepository { fun onMessage(currentProgress: Long, approximateCount: Long) fun onAttachment(currentProgress: Long, totalCount: Long) } - - enum class CredentialType { - MESSAGE, MEDIA - } } data class ResumableMessagesBackupUploadSpec( @@ -2554,12 +2107,6 @@ class ImportState(val mediaRootBackupKey: MediaRootBackupKey, val backupMode: Ba } } -class DebugBackupMetadata( - val usedSpace: Long, - val mediaCount: Long, - val mediaSize: Long -) - data class StagedBackupKeyRotations( val aep: AccountEntropyPool, val mediaRootBackupKey: MediaRootBackupKey diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/DatabaseAttachmentArchiveUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/DatabaseAttachmentArchiveUtil.kt index 2c6dc9c1c6..85485d6c06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/DatabaseAttachmentArchiveUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/DatabaseAttachmentArchiveUtil.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/SignalStoreArchiveCacheStore.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/SignalStoreArchiveCacheStore.kt new file mode 100644 index 0000000000..6c70bc5e21 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/SignalStoreArchiveCacheStore.kt @@ -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) = 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index 7d394b8147..7ff25cfc7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt index 9ab71880cf..5a744d5b64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupKeyDisplayViewModel.kt @@ -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.") - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt index f68f0463c7..7c0fd82592 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt index 88a7fcbf28..ab53134bdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 1a77657300..4895d571a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 5a6f2b1639..415669b1b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index bc33b8cea1..2bc9962121 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentReconciliationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentReconciliationJob.kt index 2e234fc066..d19ac9c597 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentReconciliationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentReconciliationJob.kt @@ -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 = 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 { + private fun syncCdnPage(archivedItemPage: ArchiveApiV2.MediaItemsPage, currentSnapshotVersion: Long): Set { 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 { - 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 { + 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): Result? { + private suspend fun validateAndDeleteFromRemote(deletes: Set): 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() }") diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveBackupIdReservationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveBackupIdReservationJob.kt index 3cac4a6baf..8592a93ad5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveBackupIdReservationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveBackupIdReservationJob.kt @@ -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) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveCommitAttachmentDeletesJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveCommitAttachmentDeletesJob.kt index 195e437482..6dd2041557 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveCommitAttachmentDeletesJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveCommitAttachmentDeletesJob.kt @@ -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, backoffGenerator: () -> Long, cancellationSignal: () -> Boolean): Result? { + suspend fun deleteMediaObjectsFromCdn(tag: String, attachmentsToDelete: Set, 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt index 616eb6d014..2e99031e65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt @@ -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)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt index b1e31e6b0e..fc906ef6d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt @@ -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 = 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 = 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 = 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 handleNetworkError(networkResult: NetworkResult): 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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt index b00e622cf3..6794fe2021 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRefreshJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRefreshJob.kt index 8a6ef9e5cd..ab746ba3b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRefreshJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRefreshJob.kt @@ -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() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt index 282775fc7b..559334e77a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CopyAttachmentToArchiveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CopyAttachmentToArchiveJob.kt index 1724b759da..55f488ba70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/CopyAttachmentToArchiveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CopyAttachmentToArchiveJob.kt @@ -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)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt index 2ce38a6b52..62ec7ced9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt index c70167fe05..25ec753ede 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt index 46636961f4..2e1491f805 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt @@ -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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/UploadAttachmentToArchiveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/UploadAttachmentToArchiveJob.kt index 2d853a7bbb..1e557375bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/UploadAttachmentToArchiveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/UploadAttachmentToArchiveJob.kt @@ -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()) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt index d9669dced4..8f1a18cdad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreViewModel.kt index 19025505d9..cec03c901f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreViewModel.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index c9d61815c8..8c088f3db8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -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 { diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/BackupPlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/BackupPlugin.kt index 5f90ab42be..53f065bad9 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/BackupPlugin.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/BackupPlugin.kt @@ -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)) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index e7313d2cda..1cd0680a52 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -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) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt index 2e6f7fbc30..fe04f69f72 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt @@ -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( diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetMediaItemsResponse.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetMediaItemsResponse.kt deleted file mode 100644 index 564e9702a6..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetMediaItemsResponse.kt +++ /dev/null @@ -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, - @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 - ) -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveKeyRotationLimitResponse.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveKeyRotationLimitResponse.kt deleted file mode 100644 index e57b0f3c4c..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveKeyRotationLimitResponse.kt +++ /dev/null @@ -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? -) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaUploadFormStatusCodes.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaUploadFormStatusCodes.kt deleted file mode 100644 index 060eaebb90..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveMediaUploadFormStatusCodes.kt +++ /dev/null @@ -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 - } - } -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveServiceCredentialsResponse.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveServiceCredentialsResponse.kt deleted file mode 100644 index 0d047225be..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveServiceCredentialsResponse.kt +++ /dev/null @@ -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> -) { - 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 - get() = credentials[KEY_MESSAGES]!! - - val mediaCredentials: List - get() = credentials[KEY_MEDIA]!! -} diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveSetBackupIdRequest.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveSetBackupIdRequest.kt deleted file mode 100644 index bb4fbde51c..0000000000 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveSetBackupIdRequest.kt +++ /dev/null @@ -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() { - override fun serialize(value: BackupAuthCredentialRequest, gen: JsonGenerator, serializers: SerializerProvider) { - gen.writeString(Base64.encodeWithPadding(value.serialize())) - } - } -} diff --git a/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt b/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt index b72b7172e9..bb7fa26e93 100644 --- a/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt +++ b/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt @@ -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 { - 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 { - 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 { - 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 { - 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, backupFileSize: Long): NetworkResult { - 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): NetworkResult { - 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): NetworkResult { - 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 { - 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 { - 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, uploadLength: Long): NetworkResult { - 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): NetworkResult> { - return NetworkResult.fromFetch { - val mediaObjects: MutableList = 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, limit: Int, cursor: String?): NetworkResult { - 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, - items: List - ): NetworkResult> { - 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, - mediaToDelete: List - ): NetworkResult> { - 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): NetworkResult { - 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 { - 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 runWithBackupAuth( - aci: ACI, - archiveServiceAccess: ArchiveServiceAccess<*>, - block: (UnauthenticatedChatConnection, BackupAuth) -> CompletableFuture> - ): RequestResult { - 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 collectBackupMediaStream( - aci: ACI, - archiveServiceAccess: ArchiveServiceAccess<*>, - block: (UnauthenticatedChatConnection, BackupAuth) -> Flow - ): RequestResult, 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() - } - } - } - - /** - * 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> { - 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 RequestResult.toNetworkResult(): NetworkResult { - 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 RequestResult.toUploadFormNetworkResult(): NetworkResult { - 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 RequestResult.RetryableNetworkError.toNetworkResult(): NetworkResult { - 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 - ) - } } diff --git a/lib/network/src/main/java/org/signal/network/api/ArchiveApiV2.kt b/lib/network/src/main/java/org/signal/network/api/ArchiveApiV2.kt new file mode 100644 index 0000000000..8dd9444e12 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/api/ArchiveApiV2.kt @@ -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 { + 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 { + 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 { + 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, limit: Int, cursor: String?): RequestResult { + 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 { + 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 { + 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, backupFileSize: Long): RequestResult { + 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, uploadLength: Long): RequestResult { + 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): RequestResult { + 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): RequestResult { + 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 { + 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 { + 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): RequestResult { + 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, items: List): RequestResult, 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, mediaToDelete: List): RequestResult, 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 { + 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 SignalWebSocket.requestResult( + request: WebSocketRequestMessage, + parseSuccess: (WebsocketResponse) -> T, + mapError: (WebsocketResponse) -> E? + ): RequestResult { + 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 withBackupAuth( + aci: ACI, + archiveServiceAccess: ArchiveServiceAccess<*>, + block: (UnauthenticatedChatConnection, BackupAuth) -> org.signal.libsignal.internal.CompletableFuture> + ): RequestResult { + 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 collectBackupMediaStream( + aci: ACI, + archiveServiceAccess: ArchiveServiceAccess<*>, + block: (UnauthenticatedChatConnection, BackupAuth) -> Flow + ): RequestResult, 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() + } + } + + /** + * 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> { + 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 { + 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 RequestResult.map(transform: (T) -> R): RequestResult { + 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, + val mediaCredentials: List + ) + + /** + * 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 = 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> = 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 { + data class Success(val value: T) : ZkCredentialResult + + sealed interface Failure : ZkCredentialResult { + 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 + } + } +} diff --git a/lib/network/src/main/java/org/signal/network/service/ArchiveCacheStore.kt b/lib/network/src/main/java/org/signal/network/service/ArchiveCacheStore.kt new file mode 100644 index 0000000000..1840cebe19 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/service/ArchiveCacheStore.kt @@ -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) + + /** 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? + } +} diff --git a/lib/network/src/main/java/org/signal/network/service/ArchiveService.kt b/lib/network/src/main/java/org/signal/network/service/ArchiveService.kt new file mode 100644 index 0000000000..3481d84dde --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/service/ArchiveService.kt @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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): Either = 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 = request { + val access = initBackupAndFetchAuth() + deleteBackup(access.messageBackupAccess) + } + + /** + * The media-backup counterpart to [deleteMessageBackup]. + */ + suspend fun deleteMediaBackup(): Either = request { + val access = initBackupAndFetchAuth() + deleteBackup(access.mediaBackupAccess) + } + + /** + * Auth credentials that can be used to perform SVR-B operations. + */ + suspend fun getSvrBAuth(): Either = 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 = 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> = request { + debugFetchAllMediaObjects(initBackupAndFetchAuth()) + } + + /** + * A summary of the remote backup state, for debugging. + */ + suspend fun debugGetRemoteBackupState(): Either = 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.debugFetchAllMediaObjects(access: ArchiveServiceAccessPair): List { + val mediaObjects = mutableListOf() + + 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.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.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 Raise.withAuthErrorActions(includeUnauthorizedActions: Boolean = true, block: Raise.() -> 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 Raise.withRejectedCredentialActions(block: Raise.() -> 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.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.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 request(block: suspend Raise.() -> T): Either { + 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.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.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.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.refreshBackupAccess(access: ArchiveServiceAccess<*>) { + bindSimpleRequest(archiveApi.refreshBackup(store.aci, access)) + } + + private suspend fun Raise.fetchMessageBackupInfo(access: ArchiveServiceAccess, aci: ACI = store.aci): MessageBackupInfo { + return bindSimpleRequest(archiveApi.getMessageBackupInfo(aci, access)) + } + + private suspend fun Raise.fetchMediaBackupInfo(access: ArchiveServiceAccess): MediaBackupInfo { + return bindSimpleRequest(archiveApi.getMediaBackupInfo(store.aci, access)) + } + + private suspend fun Raise.fetchCdnReadCredentials(access: ArchiveServiceAccess<*>, cdnNumber: Int, aci: ACI = store.aci): GetArchiveCdnCredentialsResponse { + return bindSimpleRequest(archiveApi.getCdnReadCredentials(cdnNumber, aci, access)) + } + + private suspend fun Raise.fetchMessageBackupUploadForm(access: ArchiveServiceAccess, backupFileSize: Long): AttachmentUploadForm { + return bindUploadForm(archiveApi.getMessageBackupUploadForm(store.aci, access, backupFileSize)) + } + + private suspend fun Raise.fetchMediaUploadForm(access: ArchiveServiceAccess, uploadLength: Long): AttachmentUploadForm { + return bindUploadForm(archiveApi.getMediaUploadForm(store.aci, access, uploadLength)) + } + + private suspend fun Raise.fetchMediaItemsPage(access: ArchiveServiceAccess, 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.deleteArchivedMediaChunk(access: ArchiveServiceAccess, chunk: List): List { + return bindSimpleRequest(archiveApi.deleteArchivedMedia(store.aci, access, chunk)) + } + + private suspend fun Raise.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 Raise.bindSimpleRequest(result: RequestResult): 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 Raise.bindUploadForm(result: RequestResult): 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, + val path: String + ) { + constructor(info: MessageBackupInfo, cdnCredentials: Map) : 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 Either.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 NetworkResult.toArchiveResult(): Either { + 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 + } +} diff --git a/lib/network/src/test/java/org/signal/network/service/ArchiveServiceTest.kt b/lib/network/src/test/java/org/signal/network/service/ArchiveServiceTest.kt new file mode 100644 index 0000000000..a9801f05cf --- /dev/null +++ b/lib/network/src/test/java/org/signal/network/service/ArchiveServiceTest.kt @@ -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>( + 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() + + coEvery { archiveApi.deleteArchivedMedia(any(), any(), any()) } answers { + val chunk = arg>(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.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.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 = 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.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 Either.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 { every { this@mockk.backupLevel } returns backupLevel } + ) + coEvery { archiveApi.refreshBackup(any(), any()) } returns RequestResult.Success(Unit) + } + + // endregion +} diff --git a/lib/network/src/test/java/org/signal/network/service/FakeArchiveCacheStore.kt b/lib/network/src/test/java/org/signal/network/service/FakeArchiveCacheStore.kt new file mode 100644 index 0000000000..7c7e68de52 --- /dev/null +++ b/lib/network/src/test/java/org/signal/network/service/FakeArchiveCacheStore.kt @@ -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() + + /** How many times [clearOlderThan] was called, and with what. */ + val clearedOlderThan = mutableListOf() + + override fun getForTime(currentTime: Duration): ArchiveServiceCredential? = credentials.lastOrNull() + + override fun add(credentials: List) { + 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() + } +}