From 1f243bca7447c5966986ca2bdfce7aca014a9318 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 30 Jul 2025 13:26:06 -0300 Subject: [PATCH] Add job after registration to try to redeem subscription data. --- .../jobs/InAppPaymentRecurringContextJob.kt | 15 ++ .../securesms/jobs/JobManagerFactories.java | 1 + .../PostRegistrationBackupRedemptionJob.kt | 151 ++++++++++++++++++ .../registration/util/RegistrationUtil.java | 6 + .../InAppPaymentRecurringContextJobTest.kt | 6 + .../registration/util/RegistrationUtilTest.kt | 3 + 6 files changed, 182 insertions(+) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt 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 5579b7ec3c..53a19e86ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -14,6 +14,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toInAppPaymentDataChargeFailure @@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.JobManager.Chain import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure @@ -226,6 +228,19 @@ 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 + } + } + + if (tier != MessageBackupTier.PAID) { + warning("ZK credential does not align with entitlement. Forcing a redemption.") + return false + } + val backupExpirationSeconds = whoAmIResponse.entitlements?.backup?.expirationSeconds ?: return false backupExpirationSeconds >= endOfCurrentSubscriptionPeriod 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 1b11ac6b1e..87850a5ba9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -228,6 +228,7 @@ public final class JobManagerFactories { put(PushGroupSendJob.KEY, new PushGroupSendJob.Factory()); put(PushGroupSilentUpdateSendJob.KEY, new PushGroupSilentUpdateSendJob.Factory()); put(MessageFetchJob.KEY, new MessageFetchJob.Factory()); + put(PostRegistrationBackupRedemptionJob.KEY, new PostRegistrationBackupRedemptionJob.Factory()); put(PushProcessEarlyMessagesJob.KEY, new PushProcessEarlyMessagesJob.Factory()); put(PushProcessMessageErrorJob.KEY, new PushProcessMessageErrorJob.Factory()); put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt new file mode 100644 index 0000000000..d9b9347a48 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.signal.core.util.money.FiatMoney +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.backup.DeletionState +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +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 +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.RemoteConfig +import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration +import java.math.BigDecimal +import java.util.Currency +import java.util.Locale + +/** + * Runs after registration to make sure we are on the backup level we expect on this device. + */ +class PostRegistrationBackupRedemptionJob : CoroutineJob { + + companion object { + private val TAG = Log.tag(PostRegistrationBackupRedemptionJob::class) + const val KEY = "PostRestoreBackupRedemptionJob" + } + + constructor() : super( + Parameters.Builder() + .setQueue(InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_BACKUP)) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(Parameters.IMMORTAL) + .build() + ) + + constructor(parameters: Parameters) : super(parameters) + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + override suspend fun doRun(): Result { + if (!SignalStore.account.isRegistered) { + info("User is not registered. Exiting.") + return Result.success() + } + + if (!RemoteConfig.messageBackups) { + info("Message backups feature is not available. Exiting.") + return Result.success() + } + + if (SignalStore.backup.deletionState != DeletionState.NONE) { + info("User is in the process of or has delete their backup. Exiting.") + return Result.success() + } + + if (SignalStore.backup.backupTier != MessageBackupTier.PAID) { + info("Paid backups are not enabled on this device. Exiting.") + return Result.success() + } + + if (SignalStore.backup.backupTierInternalOverride != null) { + info("User has internal override set for backup version. Exiting.") + return Result.success() + } + + if (SignalDatabase.inAppPayments.hasPendingBackupRedemption()) { + info("User has a pending backup redemption. Retrying later.") + return Result.retry(defaultBackoff()) + } + + val subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) + if (subscriber == null) { + info("No subscriber information was available in the database. Exiting.") + return Result.success() + } + + info("Attempting to grab price information for records...") + val subscription = RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription + + val emptyPrice = FiatMoney(BigDecimal.ZERO, Currency.getInstance(Locale.getDefault())) + val price: FiatMoney = if (subscription != null) { + FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency)) + } else if (AppDependencies.billingApi.isApiAvailable()) { + AppDependencies.billingApi.queryProduct()?.price ?: emptyPrice + } else { + emptyPrice + } + + if (price == emptyPrice) { + warning("Could not resolve price, using empty price.") + } + + info("Creating a pending payment...") + val id = SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_BACKUP, + state = InAppPaymentTable.State.PENDING, + subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData( + badge = null, + amount = price.toFiatValue(), + level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(), + recipientId = Recipient.self().id.serialize(), + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + redemption = InAppPaymentData.RedemptionState( + stage = InAppPaymentData.RedemptionState.Stage.INIT + ) + ) + ) + + info("Submitting job chain.") + InAppPaymentPurchaseTokenJob.createJobChain( + inAppPayment = SignalDatabase.inAppPayments.getById(id)!! + ).enqueue() + + return Result.success() + } + + override fun onFailure() = Unit + + private fun info(message: String, throwable: Throwable? = null) { + Log.i(TAG, message, throwable, true) + } + + private fun warning(message: String, throwable: Throwable? = null) { + Log.w(TAG, message, throwable, true) + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): PostRegistrationBackupRedemptionJob { + return PostRegistrationBackupRedemptionJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java index 9e6ac5fc08..13af8a016e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java @@ -6,10 +6,12 @@ package org.thoughtcrime.securesms.registration.util; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob; +import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob; import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode; @@ -51,6 +53,10 @@ public final class RegistrationUtil { .then(new DirectoryRefreshJob(false)) .enqueue(); + if (SignalStore.backup().getBackupTier() == MessageBackupTier.PAID) { + AppDependencies.getJobManager().add(new PostRegistrationBackupRedemptionJob()); + } + SignalStore.emoji().clearSearchIndexMetadata(); EmojiSearchIndexDownloadJob.scheduleImmediately(); 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 2c1a7fb87d..d0cb356be3 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt @@ -20,6 +20,8 @@ import org.robolectric.annotation.Config import org.signal.core.util.logging.Log import org.signal.donations.InAppPaymentType import org.signal.donations.PaymentSourceType +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toInAppPaymentDataChargeFailure import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsTestRule @@ -35,6 +37,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.MockSignalStoreRule import org.thoughtcrime.securesms.testutil.SystemOutLogger +import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure import org.whispersystems.signalservice.api.subscriptions.SubscriberId @@ -439,6 +442,9 @@ class InAppPaymentRecurringContextJobTest { ) } + mockkObject(BackupRepository) + every { BackupRepository.getBackupTier() } returns NetworkResult.Success(MessageBackupTier.PAID) + val iap = insertInAppPayment( type = InAppPaymentType.RECURRING_BACKUP ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/util/RegistrationUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/registration/util/RegistrationUtilTest.kt index c10801a1e4..ffe8c0848e 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/registration/util/RegistrationUtilTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/registration/util/RegistrationUtilTest.kt @@ -27,6 +27,7 @@ import org.robolectric.annotation.Config import org.signal.core.util.logging.Log.initialize import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.Skipped import org.thoughtcrime.securesms.keyvalue.Start import org.thoughtcrime.securesms.profiles.ProfileName @@ -54,6 +55,8 @@ class RegistrationUtilTest { logRecorder = LogRecorder() initialize(logRecorder) + + every { SignalStore.backup.backupTier } returns null } @After