diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupTierDowngradeCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupTierDowngradeCheckJob.kt new file mode 100644 index 0000000000..9dd5bf0f4e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupTierDowngradeCheckJob.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import androidx.annotation.VisibleForTesting +import arrow.core.Either +import org.signal.core.util.logging.Log +import org.signal.network.service.ArchiveError +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +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.BackupTierDowngradeCheckJobData +import org.thoughtcrime.securesms.keyvalue.SignalStore +import kotlin.time.Duration.Companion.days + +/** + * Asks the service what tier we're actually on after a storage service account record told us our backup tier went down. + * + * [remoteBackupTier] is the zkgroup backup level from that record, which we fall back to when the service can't give us a usable answer. + */ +class BackupTierDowngradeCheckJob private constructor( + private val remoteBackupTier: Long?, + parameters: Parameters +) : CoroutineJob(parameters) { + + companion object { + private val TAG = Log.tag(BackupTierDowngradeCheckJob::class) + + const val KEY = "BackupTierDowngradeCheckJob" + + fun enqueue(remoteBackupTier: Long?) { + AppDependencies.jobManager.add(create(remoteBackupTier)) + } + + @VisibleForTesting + fun create(remoteBackupTier: Long?): BackupTierDowngradeCheckJob { + return BackupTierDowngradeCheckJob( + remoteBackupTier = remoteBackupTier, + parameters = Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(3.days.inWholeMilliseconds) + .setMaxInstancesForFactory(1) + .build() + ) + } + } + + override suspend fun doRun(): Result { + if (!SignalStore.account.isRegistered) { + Log.i(TAG, "Not registered. Nothing to confirm.") + return Result.success() + } + + if (SignalStore.account.isPrimaryDevice) { + Log.i(TAG, "Primary device owns its own tier. Nothing to confirm.") + return Result.success() + } + + if (SignalStore.backup.backupTier == null) { + Log.i(TAG, "We already have no tier. Nothing to confirm.") + return Result.success() + } + + return when (val result = BackupRepository.getBackupTierWithoutDowngrade()) { + is Either.Right -> { + Log.i(TAG, "Service says we're on ${result.value}. Applying it over our local tier of ${SignalStore.backup.backupTier}.", true) + SignalStore.backup.backupTier = result.value + Result.success() + } + + is Either.Left -> handleError(result.value) + } + } + + private fun handleError(error: ArchiveError.CredentialError): Result { + return when (error) { + is ArchiveError.NetworkError -> { + Log.w(TAG, "Network error. Retrying later.") + Result.retry(defaultBackoff()) + } + + is ArchiveError.CredentialError.RateLimited -> { + Log.w(TAG, "Rate limited. Retrying later.") + Result.retry(error.retryAfter?.inWholeMilliseconds ?: defaultBackoff()) + } + + is ArchiveError.CredentialError.NotFound, + is ArchiveError.CredentialError.Unauthorized, + is ArchiveError.CredentialError.InvalidRequest, + is ArchiveError.CredentialError.ZkVerificationFailed, + is ArchiveError.ApplicationError -> { + val tier = MessageBackupTier.fromBackupLevel(remoteBackupTier) + Log.w(TAG, "Service could not tell us our tier ($error). Deferring to the account record and applying $tier over ${SignalStore.backup.backupTier}.", true) + SignalStore.backup.backupTier = tier + Result.success() + } + } + } + + override fun serialize(): ByteArray { + return BackupTierDowngradeCheckJobData(remoteBackupTier = remoteBackupTier).encode() + } + + override fun getFactoryKey(): String = KEY + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): BackupTierDowngradeCheckJob { + val data = BackupTierDowngradeCheckJobData.ADAPTER.decode(serializedData!!) + return BackupTierDowngradeCheckJob(remoteBackupTier = data.remoteBackupTier, parameters = parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 31af08a5f3..7e31c60077 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -157,6 +157,7 @@ public final class JobManagerFactories { put(BackupMessagesJob.KEY, new BackupMessagesJob.Factory()); put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory()); put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory()); + put(BackupTierDowngradeCheckJob.KEY, new BackupTierDowngradeCheckJob.Factory()); put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory()); put(CallingAssetsDownloadJob.KEY, new CallingAssetsDownloadJob.Factory()); put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt index 03bb9204c8..6e71e42f16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt @@ -141,7 +141,7 @@ class AccountRecordProcessor( username = remote.proto.username usernameLink = remote.proto.usernameLink notificationProfileManualOverride = remote.proto.notificationProfileManualOverride - backupTier = local.proto.backupTier ?: remote.proto.backupTier + backupTier = if (SignalStore.account.isPrimaryDevice) local.proto.backupTier ?: remote.proto.backupTier else remote.proto.backupTier avatarColor = if (SignalStore.account.isPrimaryDevice) local.proto.avatarColor else remote.proto.avatarColor automaticKeyVerificationDisabled = remote.proto.automaticKeyVerificationDisabled hasSeenAdminDeleteEducationDialog = remote.proto.hasSeenAdminDeleteEducationDialog diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt index 2fdbed2cdc..a172b772e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.database.model.KeyTransparencyStore import org.thoughtcrime.securesms.database.model.RecipientRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi +import org.thoughtcrime.securesms.jobs.BackupTierDowngradeCheckJob import org.thoughtcrime.securesms.jobs.RefreshAttributesJob import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob import org.thoughtcrime.securesms.jobs.StorageSyncJob @@ -196,7 +197,6 @@ object StorageSyncHelper { } backupTier = when { - SignalStore.account.isLinkedDevice -> null SignalStore.backup.areBackupsEnabled && SignalStore.backup.backupTier != null -> SignalStore.backup.backupTier!!.toBackupLevel() SignalStore.backup.backupTierInternalOverride != null -> SignalStore.backup.backupTierInternalOverride!!.toBackupLevel() else -> null @@ -310,8 +310,15 @@ object StorageSyncHelper { if (SignalStore.account.isLinkedDevice) { val remoteBackupTier = MessageBackupTier.fromBackupLevel(update.new.proto.backupTier) - if (remoteBackupTier != SignalStore.backup.backupTier) { - SignalStore.backup.backupTier = remoteBackupTier + val localBackupTier = SignalStore.backup.backupTier + + if (remoteBackupTier != localBackupTier) { + if (isBackupTierDowngrade(from = localBackupTier, to = remoteBackupTier)) { + Log.w(TAG, "Remote account record downgrades our backup tier ($localBackupTier -> $remoteBackupTier). Confirming with the service before applying it.") + BackupTierDowngradeCheckJob.enqueue(update.new.proto.backupTier) + } else { + SignalStore.backup.backupTier = remoteBackupTier + } } } @@ -408,6 +415,14 @@ object StorageSyncHelper { } } + private fun isBackupTierDowngrade(from: MessageBackupTier?, to: MessageBackupTier?): Boolean { + return when (from) { + null -> false + MessageBackupTier.FREE -> to == null + MessageBackupTier.PAID -> to == null || to == MessageBackupTier.FREE + } + } + class IdDifferenceResult( @JvmField val remoteOnlyIds: List, @JvmField val localOnlyIds: List, diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index cd188d0cc5..454accb8e5 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -292,3 +292,7 @@ message IndividualSendJobV2Data { message MultiDeviceAttachmentBackfillRequestJobData { uint64 messageId = 1; } + +message BackupTierDowngradeCheckJobData { + optional uint64 remoteBackupTier = 1; +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/BackupTierDowngradeCheckJobTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/BackupTierDowngradeCheckJobTest.kt new file mode 100644 index 0000000000..0ac5836a42 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/BackupTierDowngradeCheckJobTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import arrow.core.left +import arrow.core.right +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.signal.core.util.logging.Log +import org.signal.libsignal.zkgroup.VerificationFailedException +import org.signal.network.service.ArchiveError +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.testutil.MockSignalStoreRule +import org.thoughtcrime.securesms.testutil.SystemOutLogger +import org.thoughtcrime.securesms.util.RemoteConfig +import java.io.IOException +import kotlin.time.Duration.Companion.minutes + +class BackupTierDowngradeCheckJobTest { + + companion object { + private val FREE_TIER = MessageBackupTier.FREE.toBackupLevel() + } + + @get:Rule + val signalStore = MockSignalStoreRule() + + @Before + fun setUp() { + Log.initialize(SystemOutLogger()) + + every { signalStore.account.isRegistered } returns true + every { signalStore.account.isPrimaryDevice } returns false + every { signalStore.backup.backupTier } returns MessageBackupTier.PAID + + mockkObject(BackupRepository) + mockkObject(RemoteConfig) + every { RemoteConfig.defaultMaxBackoff } returns 1.minutes.inWholeMilliseconds + } + + @After + fun tearDown() { + unmockkObject(BackupRepository) + unmockkObject(RemoteConfig) + } + + @Test + fun `given the service says paid, when I run, then I keep the paid tier`() { + every { BackupRepository.getBackupTierWithoutDowngrade() } returns MessageBackupTier.PAID.right() + + val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run() + + assertTrue(result.isSuccess) + verify { signalStore.backup.backupTier = MessageBackupTier.PAID } + } + + @Test + fun `given the service says free, when I run, then I downgrade to free`() { + every { BackupRepository.getBackupTierWithoutDowngrade() } returns MessageBackupTier.FREE.right() + + val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run() + + assertTrue(result.isSuccess) + verify { signalStore.backup.backupTier = MessageBackupTier.FREE } + } + + @Test + fun `given a network error, when I run, then I retry and leave our tier alone`() { + every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.NetworkError(IOException()).left() + + val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run() + + assertTrue(result.isRetry) + verify(exactly = 0) { signalStore.backup.backupTier = any() } + } + + @Test + fun `given the service rejects our credential, when I run, then I take the tier from the record`() { + every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.CredentialError.Unauthorized().left() + + val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run() + + assertTrue(result.isSuccess) + verify { signalStore.backup.backupTier = MessageBackupTier.FREE } + } + + @Test + fun `given the service has no backup for us and the record had no tier, when I run, then I clear our tier`() { + every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.CredentialError.NotFound().left() + + val result = BackupTierDowngradeCheckJob.create(null).run() + + assertTrue(result.isSuccess) + verify { signalStore.backup.backupTier = null } + } + + @Test + fun `given we cannot verify our own credential, when I run, then I take the tier from the record`() { + every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.CredentialError.ZkVerificationFailed(VerificationFailedException()).left() + + val result = BackupTierDowngradeCheckJob.create(null).run() + + assertTrue(result.isSuccess) + verify { signalStore.backup.backupTier = null } + } + + @Test + fun `given a job carrying a tier, when I serialize and restore it, then I keep that tier`() { + every { BackupRepository.getBackupTierWithoutDowngrade() } returns ArchiveError.CredentialError.NotFound().left() + + val job = BackupTierDowngradeCheckJob.create(FREE_TIER) + val restored = BackupTierDowngradeCheckJob.Factory().create(job.parameters, job.serialize()) + + val result = restored.run() + + assertTrue(result.isSuccess) + verify { signalStore.backup.backupTier = MessageBackupTier.FREE } + } + + @Test + fun `given we are not registered, when I run, then I leave our tier alone`() { + every { signalStore.account.isRegistered } returns false + + val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run() + + assertTrue(result.isSuccess) + verify(exactly = 0) { signalStore.backup.backupTier = any() } + } + + @Test + fun `given we are the primary, when I run, then I leave our tier alone`() { + every { signalStore.account.isPrimaryDevice } returns true + + val result = BackupTierDowngradeCheckJob.create(FREE_TIER).run() + + assertTrue(result.isSuccess) + verify(exactly = 0) { signalStore.backup.backupTier = any() } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/StorageSyncJobTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/StorageSyncJobTest.kt index 24c18fc5ce..b48f892269 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/StorageSyncJobTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/StorageSyncJobTest.kt @@ -35,6 +35,7 @@ import org.signal.core.util.logging.Log import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.database.IssueReporter import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase @@ -54,6 +55,7 @@ import org.thoughtcrime.securesms.testutil.SystemOutLogger import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.storage.SignalStorageRecord import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord import org.whispersystems.signalservice.internal.storage.protos.ContactRecord import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord @@ -375,6 +377,81 @@ class StorageSyncJobTest { assertTrue(remoteStorage.manifest!!.storageIds.contains(StorageId.forAccount(selfStorageId!!))) } + @Test + fun `given a linked device with a tier, when I write our account record, then I include our tier`() { + stubLinkedDevice() + every { recipients.signalStore.backup.areBackupsEnabled } returns true + every { recipients.signalStore.backup.backupTier } returns MessageBackupTier.PAID + + markSelfNeedsSync() + + val result = runJob(StorageSyncJob.forLocalChange()) + + assertTrue(result.isSuccess) + assertEquals(MessageBackupTier.PAID.toBackupLevel(), remoteAccountRecord().backupTier) + } + + @Test + fun `given a linked device and a remote record with a higher tier, when I run, then I apply it`() { + stubLinkedDevice() + + remoteStorage.setRemoteState(listOf(accountRecordWithBackupTier(MessageBackupTier.PAID.toBackupLevel())), version = BASE_MANIFEST_VERSION + 1) + + val jobManager = AppDependencies.jobManager + val result = runJob(StorageSyncJob.forRemoteChange()) + + assertTrue(result.isSuccess) + verify { recipients.signalStore.backup.backupTier = MessageBackupTier.PAID } + verify(exactly = 0) { jobManager.add(ofType()) } + } + + @Test + fun `given a linked device and a remote record without a tier, when I run, then I keep our tier and confirm with the service`() { + stubLinkedDevice() + every { recipients.signalStore.backup.areBackupsEnabled } returns true + every { recipients.signalStore.backup.backupTier } returns MessageBackupTier.PAID + + remoteStorage.setRemoteState(listOf(accountRecordWithBackupTier(null)), version = BASE_MANIFEST_VERSION + 1) + + val jobManager = AppDependencies.jobManager + val result = runJob(StorageSyncJob.forRemoteChange()) + + assertTrue(result.isSuccess) + verify(exactly = 0) { recipients.signalStore.backup.backupTier = null } + verify { jobManager.add(ofType()) } + } + + @Test + fun `given a linked device on paid and a remote record on free, when I run, then I keep our tier and confirm with the service`() { + stubLinkedDevice() + every { recipients.signalStore.backup.areBackupsEnabled } returns true + every { recipients.signalStore.backup.backupTier } returns MessageBackupTier.PAID + + remoteStorage.setRemoteState(listOf(accountRecordWithBackupTier(MessageBackupTier.FREE.toBackupLevel())), version = BASE_MANIFEST_VERSION + 1) + + val jobManager = AppDependencies.jobManager + val result = runJob(StorageSyncJob.forRemoteChange()) + + assertTrue(result.isSuccess) + verify(exactly = 0) { recipients.signalStore.backup.backupTier = MessageBackupTier.FREE } + verify { jobManager.add(ofType()) } + } + + @Test + fun `given a primary with a tier and a remote record without one, when I run, then I write our tier back and confirm nothing`() { + every { recipients.signalStore.backup.areBackupsEnabled } returns true + every { recipients.signalStore.backup.backupTier } returns MessageBackupTier.PAID + + remoteStorage.setRemoteState(listOf(accountRecordWithBackupTier(null)), version = BASE_MANIFEST_VERSION + 1) + + val jobManager = AppDependencies.jobManager + val result = runJob(StorageSyncJob.forRemoteChange()) + + assertTrue(result.isSuccess) + assertEquals(MessageBackupTier.PAID.toBackupLevel(), remoteAccountRecord().backupTier) + verify(exactly = 0) { jobManager.add(ofType()) } + } + @Test fun `given a contact was unregistered long ago, when I run, then I remove their storage id`() { val contact = recipients.createRecipient("Local Contact") @@ -669,4 +746,29 @@ class StorageSyncJobTest { private fun bumpRemoteManifestWithoutTouchingAccountRecord() { remoteStorage.addRemoteRecords(listOf(contactRecord(ACI.from(UUID.randomUUID()), ProfileName.fromParts("Remote", "Contact")))) } + + private fun markSelfNeedsSync() { + SignalDatabase.recipients.markNeedsSync(recipients.self) + Recipient.self().live().refresh() + } + + private fun remoteAccountRecord(): AccountRecord { + return remoteStorage.records.mapNotNull { it.proto.account }.single() + } + + private fun stubLinkedDevice() { + every { recipients.signalStore.account.isLinkedDevice } returns true + every { recipients.signalStore.account.isPrimaryDevice } returns false + every { recipients.signalStore.account.restoredAccountEntropyPoolFromPrimary } returns true + } + + /** Our account record as another device would have rewritten it: same contents, [backupTier] swapped in, under a fresh storage id so that we see it as remote-only. */ + private fun accountRecordWithBackupTier(backupTier: Long?): SignalStorageRecord { + val record = StorageSyncHelper.buildAccountRecord(ApplicationProvider.getApplicationContext(), Recipient.self()) + + return record.copy( + id = StorageId.forAccount(StorageSyncHelper.generateKey()), + proto = record.proto.copy(account = record.proto.account!!.copy(backupTier = backupTier)) + ) + } } diff --git a/lib/libsignal-service/src/main/protowire/StorageService.proto b/lib/libsignal-service/src/main/protowire/StorageService.proto index 1bfcc7b0a7..b09b36d218 100644 --- a/lib/libsignal-service/src/main/protowire/StorageService.proto +++ b/lib/libsignal-service/src/main/protowire/StorageService.proto @@ -237,12 +237,6 @@ message AccountRecord { } } - message BackupTierHistory { - // See zkgroup for integer particular values. Unset if backups are not enabled. - optional uint64 backupTier = 1; - optional uint64 endedAtTimestamp = 2; - } - message NotificationProfileManualOverride { message ManuallyEnabled { bytes id = 1; @@ -299,7 +293,7 @@ message AccountRecord { optional uint64 backupTier = 40; // See zkgroup for integer particular values. Unset if backups are not enabled. IAPSubscriberData backupSubscriberData = 41; optional AvatarColor avatarColor = 42; - BackupTierHistory backupTierHistory = 43; + reserved /* backupTierHistory */ 43; NotificationProfileManualOverride notificationProfileManualOverride = 44; bool notificationProfileSyncDisabled = 45; bool automaticKeyVerificationDisabled = 46;