Refactor archive requests into ArchiveService.

This commit is contained in:
Greyson Parrelli
2026-08-04 20:03:36 -03:00
committed by Alex Hart
parent 1911bf7df7
commit 34648c2c04
45 changed files with 3171 additions and 1692 deletions
@@ -9,7 +9,9 @@ import org.signal.core.util.billing.BillingApi
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.network.api.ArchiveApi
import org.signal.network.api.ArchiveApiV2
import org.signal.network.config.SignalServiceConfiguration
import org.signal.network.service.ArchiveService
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.testing.endpoints.DonationTestServer
@@ -37,6 +39,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
private var signalServiceMessageSender: SignalServiceMessageSender? = null
private var billingApi: BillingApi = mockk()
private var accountApi: AccountApi = mockk()
private var archiveService: ArchiveService = mockk(relaxed = true)
init {
recipientCache = LiveRecipientCache(application) { r -> r.run() }
@@ -50,10 +53,16 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return recipientCache
}
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
override fun provideArchiveApi(pushServiceSocket: PushServiceSocket): ArchiveApi {
return mockk()
}
override fun provideArchiveApiV2(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApiV2 {
return mockk()
}
override fun provideArchiveService(archiveApi: ArchiveApiV2): ArchiveService = archiveService
/**
* Adds the Stripe-matching [ResponderInterceptor] on top of the default client (which supplies the
* user agent + DNS), so `api.stripe.com` requests made by [org.signal.donations.StripeApi] are
@@ -6,11 +6,13 @@
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import arrow.core.right
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isNull
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockkObject
@@ -24,7 +26,8 @@ import org.junit.runner.RunWith
import org.signal.core.models.backup.MediaName
import org.signal.core.models.database.AttachmentId
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.network.NetworkResult
import org.signal.network.api.ArchiveApiV2
import org.signal.network.service.ArchiveService
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -33,12 +36,11 @@ import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.MediaEntry
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.io.ByteArrayInputStream
@@ -52,6 +54,8 @@ class ArchiveAttachmentReconciliationJobTest {
@get:Rule
val harness = SignalActivityRule()
private val archiveService: ArchiveService = AppDependencies.archiveService
@Before
fun setUp() {
SignalStore.backup.backupTier = MessageBackupTier.PAID
@@ -61,7 +65,7 @@ class ArchiveAttachmentReconciliationJobTest {
mockkObject(BackupRepository)
mockkObject(ArchiveCommitAttachmentDeletesJob)
every { ArchiveCommitAttachmentDeletesJob.deleteMediaObjectsFromCdn(any(), any(), any(), any()) } returns null
coEvery { ArchiveCommitAttachmentDeletesJob.deleteMediaObjectsFromCdn(any(), any(), any(), any()) } returns null
}
@After
@@ -221,20 +225,14 @@ class ArchiveAttachmentReconciliationJobTest {
val remoteKey = attachment.remoteKey!!.decodeBase64OrThrow()
val mediaId = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash, remoteKey).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(
storedMediaObjects = listOf(StoredMediaObject(cdn = cdn, mediaId = mediaId, objectLength = attachment.size)),
backupDir = null,
mediaDir = null,
cursor = null
)
)
coEvery { archiveService.listRemoteMediaObjects(any(), any()) } returns ArchiveApiV2.MediaItemsPage(
storedMediaObjects = listOf(ArchiveApiV2.StoredMediaObject(cdn = cdn, mediaId = mediaId, objectLength = attachment.size)),
cursor = null
).right()
}
private fun fakeCdnEmpty() {
every { BackupRepository.listRemoteMediaObjects(any(), any()) } returns NetworkResult.Success(
ArchiveGetMediaItemsResponse(storedMediaObjects = emptyList(), backupDir = null, mediaDir = null, cursor = null)
)
coEvery { archiveService.listRemoteMediaObjects(any(), any()) } returns ArchiveApiV2.MediaItemsPage(storedMediaObjects = emptyList(), cursor = null).right()
}
private fun createIncomingMessage(serverTime: Duration, attachment: Attachment): IncomingMessage {
@@ -5,11 +5,15 @@
package org.thoughtcrime.securesms.jobs
import arrow.core.left
import arrow.core.right
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
@@ -21,8 +25,9 @@ import org.junit.Rule
import org.junit.Test
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.service.ArchiveError
import org.signal.network.service.ArchiveService
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.DeletionState
@@ -30,6 +35,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Flag
@@ -49,12 +55,14 @@ class BackupDeleteJobTest {
@get:Rule
val harness = SignalActivityRule()
private val archiveService: ArchiveService = AppDependencies.archiveService
@Before
fun setUp() {
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } returns NetworkResult.Success(MessageBackupTier.PAID)
every { BackupRepository.deleteBackup() } returns NetworkResult.Success(Unit)
every { BackupRepository.deleteMediaBackup() } returns NetworkResult.Success(Unit)
every { BackupRepository.getBackupTier() } returns MessageBackupTier.PAID.right()
coEvery { archiveService.deleteMessageBackup() } returns Unit.right()
coEvery { archiveService.deleteMediaBackup() } returns Unit.right()
}
@After
@@ -193,9 +201,11 @@ class BackupDeleteJobTest {
val result = job.run()
coVerify {
archiveService.deleteMessageBackup()
archiveService.deleteMediaBackup()
}
verify {
BackupRepository.deleteBackup()
BackupRepository.deleteMediaBackup()
BackupRepository.resetInitializedStateAndAuthCredentials()
}
@@ -205,7 +215,7 @@ class BackupDeleteJobTest {
@Test
fun givenNetworkErrorDuringMessageBackupDeletion_whenIRun_thenIExpectRetry() {
every { BackupRepository.deleteBackup() } returns NetworkResult.NetworkError(IOException())
coEvery { archiveService.deleteMessageBackup() } returns ArchiveError.NetworkError(IOException()).left()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -218,7 +228,7 @@ class BackupDeleteJobTest {
@Test
fun givenNetworkErrorDuringMediaBackupDeletion_whenIRun_thenIExpectRetry() {
every { BackupRepository.deleteMediaBackup() } returns NetworkResult.NetworkError(IOException())
coEvery { archiveService.deleteMediaBackup() } returns ArchiveError.NetworkError(IOException()).left()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -231,7 +241,7 @@ class BackupDeleteJobTest {
@Test
fun givenRateLimitedDuringMessageBackupDeletion_whenIRun_thenIExpectRetry() {
every { BackupRepository.deleteBackup() } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(429))
coEvery { archiveService.deleteMessageBackup() } returns ArchiveError.CredentialError.RateLimited(null, NonSuccessfulResponseCodeException(429)).left()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -244,7 +254,7 @@ class BackupDeleteJobTest {
@Test
fun givenRateLimitedDuringMediaBackupDeletion_whenIRun_thenIExpectRetry() {
every { BackupRepository.deleteMediaBackup() } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(429))
coEvery { archiveService.deleteMediaBackup() } returns ArchiveError.CredentialError.RateLimited(null, NonSuccessfulResponseCodeException(429)).left()
SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE
@@ -6,6 +6,9 @@
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import assertk.assertThat
import assertk.assertions.isFalse
import assertk.assertions.isTrue
@@ -29,6 +32,7 @@ import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.service.ArchiveError
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -91,23 +95,9 @@ class BackupSubscriptionCheckJobTest {
every { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } returns Unit
mockkObject(BackupRepository)
every { BackupRepository.getBackupTier() } answers {
val tier = SignalStore.backup.backupTier
if (tier != null) {
NetworkResult.Success(tier)
} else {
NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(404))
}
}
every { BackupRepository.getBackupTier() } answers { currentTierResult() }
every { BackupRepository.getBackupTierWithoutDowngrade() } answers {
val tier = SignalStore.backup.backupTier
if (tier != null) {
NetworkResult.Success(tier)
} else {
NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(404))
}
}
every { BackupRepository.getBackupTierWithoutDowngrade() } answers { currentTierResult() }
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
@@ -464,7 +454,7 @@ class BackupSubscriptionCheckJobTest {
// Set up mismatched state: local tier is PAID but ZK tier is FREE
SignalStore.backup.backupTier = MessageBackupTier.PAID
every { BackupRepository.getBackupTierWithoutDowngrade() } returns NetworkResult.Success(MessageBackupTier.FREE)
every { BackupRepository.getBackupTierWithoutDowngrade() } returns MessageBackupTier.FREE.right()
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
val job = BackupSubscriptionCheckJob.create()
@@ -485,7 +475,7 @@ class BackupSubscriptionCheckJobTest {
// Set up synced state: both local and ZK tiers are PAID
SignalStore.backup.backupTier = MessageBackupTier.PAID
every { BackupRepository.getBackupTierWithoutDowngrade() } returns NetworkResult.Success(MessageBackupTier.PAID)
every { BackupRepository.getBackupTierWithoutDowngrade() } returns MessageBackupTier.PAID.right()
val job = BackupSubscriptionCheckJob.create()
val result = job.run()
@@ -504,7 +494,7 @@ class BackupSubscriptionCheckJobTest {
SignalStore.backup.backupTier = MessageBackupTier.PAID
// ZK credential fetch fails, should trigger refresh
every { BackupRepository.getBackupTierWithoutDowngrade() } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(500))
every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.NetworkError(IOException("Server error: 500")).left()
every { BackupRepository.resetInitializedStateAndAuthCredentials() } returns Unit
val job = BackupSubscriptionCheckJob.create()
@@ -669,4 +659,8 @@ class BackupSubscriptionCheckJobTest {
isAutoRenewing = false // Not auto-renewing means canceled
)
}
private fun currentTierResult(): Either<ArchiveError.CredentialError, MessageBackupTier> {
return SignalStore.backup.backupTier?.right() ?: ArchiveError.CredentialError.NotFound(NonSuccessfulResponseCodeException(404)).left()
}
}
@@ -6,9 +6,11 @@
package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.coEvery
import io.mockk.every
import org.json.JSONObject
import org.junit.rules.ExternalResource
import org.signal.libsignal.net.RequestResult
import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testing.endpoints.DonationResponses
@@ -94,8 +96,8 @@ class InAppPaymentsRule : ExternalResource() {
}
private fun initialiseSetArchiveBackupId() {
AppDependencies.archiveApi.apply {
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
AppDependencies.archiveApiV2.apply {
coEvery { triggerBackupIdReservation(any(), any(), any()) } returns RequestResult.Success(Unit)
}
}