From 5bc8435d25893b24c7e9a688980bce4928a82fa3 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 4 Oct 2024 10:39:26 -0300 Subject: [PATCH] Add BackupSubscriptionCheckJob. --- .../jobs/BackupSubscriptionCheckJobTest.kt | 168 ++++++++++++++++++ .../securesms/backup/v2/BackupRepository.kt | 14 -- .../MessageBackupsFlowViewModel.kt | 4 +- .../subscription/InAppPaymentsRepository.kt | 6 +- .../jobs/BackupSubscriptionCheckJob.kt | 115 ++++++++++++ .../jobs/InAppPaymentRedemptionJob.kt | 4 +- .../securesms/jobs/JobManagerFactories.java | 1 + .../java/org/signal/billing/BillingApiImpl.kt | 18 +- .../signal/core/util/billing/BillingApi.kt | 2 +- .../util/billing/BillingPurchaseResult.kt | 20 ++- 10 files changed, 326 insertions(+), 26 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt new file mode 100644 index 0000000000..0d36114a32 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt @@ -0,0 +1,168 @@ +package org.thoughtcrime.securesms.jobs + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import okhttp3.mockwebserver.MockResponse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.billing.BillingPurchaseResult +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +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.dependencies.InstrumentationApplicationDependencyProvider +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.testing.Get +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.assertIs +import org.thoughtcrime.securesms.testing.assertIsNull +import org.thoughtcrime.securesms.testing.success +import org.thoughtcrime.securesms.util.RemoteConfig +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import java.math.BigDecimal +import java.util.Currency +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds + +@RunWith(AndroidJUnit4::class) +class BackupSubscriptionCheckJobTest { + @get:Rule + val harness = SignalActivityRule() + + private val testSubject = BackupSubscriptionCheckJob.create() + + @Before + fun setUp() { + mockkStatic(AppDependencies::class) + mockkStatic(RemoteConfig::class) + + every { RemoteConfig.messageBackups } returns true + every { AppDependencies.billingApi } returns mockk() + every { AppDependencies.billingApi.isApiAvailable() } returns true + coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.None + + val billingApi = AppDependencies.billingApi + + every { billingApi.isApiAvailable() } returns true + } + + @Test + fun givenMessageBackupsAreDisabled_whenICheck_thenIExpectSuccess() { + every { RemoteConfig.messageBackups } returns false + + val result = testSubject.run() + + result.isSuccess.assertIs(true) + } + + @Test + fun givenBillingApiIsUnavailable_whenICheck_thenIExpectSuccess() { + every { AppDependencies.billingApi.isApiAvailable() } returns false + + val result = testSubject.run() + + result.isSuccess.assertIs(true) + } + + @Test + fun givenAGooglePlaySubscriptionAndNoSubscriberId_whenICheck_thenIExpectToTurnOffBackups() { + coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success( + purchaseToken = "", + isAcknowledged = true, + purchaseTime = System.currentTimeMillis(), + isAutoRenewing = true + ) + + SignalStore.backup.backupTier = MessageBackupTier.PAID + + val result = testSubject.run() + + result.isSuccess.assertIs(true) + SignalStore.backup.backupTier.assertIsNull() + } + + @Test + fun givenNoSubscriberIdButPaidTier_whenICheck_thenIExpectToTurnOffBackups() { + SignalStore.backup.backupTier = MessageBackupTier.PAID + + val result = testSubject.run() + + result.isSuccess.assertIs(true) + SignalStore.backup.backupTier.assertIsNull() + } + + @Test + fun givenActiveSubscription_whenICheck_thenIExpectToTurnOnBackups() { + initialiseActiveSubscription() + SignalStore.backup.backupTier = null + + val result = testSubject.run() + + result.isSuccess.assertIs(true) + SignalStore.backup.backupTier.assertIs(MessageBackupTier.PAID) + } + + fun givenInactiveSubscription_whenICheck_thenIExpectToTurnOffBackups() { + initialiseActiveSubscription("canceled") + SignalStore.backup.backupTier = MessageBackupTier.PAID + + val result = testSubject.run() + + result.isSuccess.assertIs(true) + SignalStore.backup.backupTier.assertIsNull() + } + + fun givenInactiveSubscriptionAndNoLocalState_whenICheck_thenIExpectToTurnOffBackups() { + initialiseActiveSubscription("canceled") + SignalStore.backup.backupTier = null + + val result = testSubject.run() + + result.isSuccess.assertIs(true) + SignalStore.backup.backupTier.assertIsNull() + } + + private fun initialiseActiveSubscription(status: String = "active") { + val currency = Currency.getInstance("USD") + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = currency, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD + ) + + InAppPaymentsRepository.setSubscriber(subscriber) + SignalStore.inAppPayments.setSubscriberCurrency(currency, subscriber.type) + + InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers( + Get("/v1/subscription/${subscriber.subscriberId.serialize()}") { + MockResponse().success( + ActiveSubscription( + ActiveSubscription.Subscription( + 201, + currency.currencyCode, + BigDecimal.ONE, + System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds, + true, + System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds, + false, + status, + "STRIPE", + "GOOGLE_PLAY_BILLING", + false + ), + null + ) + ) + } + ) + } +} 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 5ae22165b8..d89e848e98 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 @@ -112,20 +112,6 @@ object BackupRepository { } } - @WorkerThread - fun canAccessRemoteBackupSettings(): Boolean { - // TODO [message-backups] - - // We need to check whether the user can access remote backup settings. - - // 1. Do they have a receipt they need to be able to view? - // 2. Do they have a backup they need to be able to manage? - - // The easy thing to do here would actually be to set a ui hint. - - return SignalStore.backup.areBackupsEnabled - } - @WorkerThread fun turnOffAndDeleteBackup() { RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) 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 e140e0298e..65450fe5ea 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 @@ -260,7 +260,9 @@ class MessageBackupsFlowViewModel( ) ) - Log.d(TAG, "Enqueueing InAppPaymentPurchaseTokenJob chain.") + Log.d(TAG, "Enabling backups and enqueueing InAppPaymentPurchaseTokenJob chain.") + SignalStore.backup.areBackupsEnabled = true + SignalStore.uiHints.markHasEverEnabledRemoteBackups() InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt index 1b68eef28b..c4b888887d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt @@ -178,10 +178,14 @@ object InAppPaymentsRepository { return when (inAppPayment.type) { InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN.") InAppPaymentType.ONE_TIME_GIFT, InAppPaymentType.ONE_TIME_DONATION -> "$JOB_PREFIX${inAppPayment.id.serialize()}" - InAppPaymentType.RECURRING_DONATION, InAppPaymentType.RECURRING_BACKUP -> "$JOB_PREFIX${inAppPayment.type.code}" + InAppPaymentType.RECURRING_DONATION, InAppPaymentType.RECURRING_BACKUP -> getRecurringJobQueueKey(inAppPayment.type) } } + fun getRecurringJobQueueKey(inAppPaymentType: InAppPaymentType): String { + return "$JOB_PREFIX${inAppPaymentType.code}" + } + /** * Returns a duration to utilize for jobs tied to different payment methods. For long running bank transfers, we need to * allow extra time for completion. diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt new file mode 100644 index 0000000000..372152ea6f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.billing.BillingPurchaseResult +import org.signal.core.util.logging.Log +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository +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.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.RemoteConfig + +/** + * Checks and rectifies state pertaining to backups subscriptions. + */ +class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : CoroutineJob(parameters) { + + companion object { + private val TAG = Log.tag(BackupSubscriptionCheckJob::class) + + const val KEY = "BackupSubscriptionCheckJob" + + fun create(): BackupSubscriptionCheckJob { + return BackupSubscriptionCheckJob( + Parameters.Builder() + .setQueue(InAppPaymentsRepository.getRecurringJobQueueKey(InAppPaymentType.RECURRING_BACKUP)) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setMaxInstancesForFactory(1) + .build() + ) + } + } + + override suspend fun doRun(): Result { + if (!RemoteConfig.messageBackups) { + Log.i(TAG, "Message backups are not enabled. Exiting.") + return Result.success() + } + + if (!AppDependencies.billingApi.isApiAvailable()) { + Log.i(TAG, "Google Play Billing API is not available on this device. Exiting.") + return Result.success() + } + + val purchase: BillingPurchaseResult = AppDependencies.billingApi.queryPurchases() + val hasActivePurchase = purchase is BillingPurchaseResult.Success && purchase.isAcknowledged && purchase.isWithinTheLastMonth() + + val subscriberId = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) + if (subscriberId == null && hasActivePurchase) { + Log.w(TAG, "User has active Google Play Billing purchase but no subscriber id! User should cancel backup and resubscribe.") + updateLocalState(null) + // TODO [message-backups] Set UI flag hint here to launch sheet (designs pending) + return Result.success() + } + + val tier = SignalStore.backup.backupTier + if (subscriberId == null && tier == MessageBackupTier.PAID) { + Log.w(TAG, "User has no subscriber id but PAID backup tier. Reverting to no backup tier and informing the user.") + updateLocalState(null) + // TODO [message-backups] Set UI flag hint here to launch sheet (designs pending) + return Result.success() + } + + val activeSubscription = RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull() + if (activeSubscription?.isActive == true && tier != MessageBackupTier.PAID) { + Log.w(TAG, "User has an active subscription but no backup tier. Setting to PAID and enabling backups.") + updateLocalState(MessageBackupTier.PAID) + return Result.success() + } + + if (activeSubscription?.isActive != true && tier == MessageBackupTier.PAID) { + Log.w(TAG, "User subscription is inactive or does not exist. Clearing backup tier.") + // TODO [message-backups] Set UI hint? + updateLocalState(null) + return Result.success() + } + + if (activeSubscription?.isActive != true && hasActivePurchase) { + Log.w(TAG, "User subscription is inactive but user has a recent purchase. Clearing backup tier.") + // TODO [message-backups] Set UI hint? + updateLocalState(null) + return Result.success() + } + + return Result.success() + } + + private fun updateLocalState(backupTier: MessageBackupTier?) { + synchronized(InAppPaymentSubscriberRecord.Type.BACKUP) { + SignalStore.backup.backupTier = backupTier + } + } + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): BackupSubscriptionCheckJob { + return BackupSubscriptionCheckJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt index be9aae4020..de05a96061 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt @@ -255,10 +255,8 @@ class InAppPaymentRedemptionJob private constructor( ) if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) { - Log.i(TAG, "Enabling backups and setting backup tier to PAID", true) - SignalStore.backup.areBackupsEnabled = true + Log.i(TAG, "Setting backup tier to PAID", true) SignalStore.backup.backupTier = MessageBackupTier.PAID - SignalStore.uiHints.markHasEverEnabledRemoteBackups() } } 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 ff25fb735f..7d24b8357a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -127,6 +127,7 @@ public final class JobManagerFactories { put(BackupRestoreJob.KEY, new BackupRestoreJob.Factory()); put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory()); put(BoostReceiptRequestResponseJob.KEY, new BoostReceiptRequestResponseJob.Factory()); + put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory()); put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory()); put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory()); put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory()); diff --git a/billing/src/main/java/org/signal/billing/BillingApiImpl.kt b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt index 4e67fb2212..bb54fb4c21 100644 --- a/billing/src/main/java/org/signal/billing/BillingApiImpl.kt +++ b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt @@ -81,7 +81,8 @@ internal class BillingApiImpl( BillingPurchaseResult.Success( purchaseToken = newestPurchase.purchaseToken, isAcknowledged = newestPurchase.isAcknowledged, - purchaseTime = newestPurchase.purchaseTime + purchaseTime = newestPurchase.purchaseTime, + isAutoRenewing = newestPurchase.isAutoRenewing ) } } @@ -185,17 +186,26 @@ internal class BillingApiImpl( } } - override suspend fun queryPurchases() { + override suspend fun queryPurchases(): BillingPurchaseResult { val param = QueryPurchasesParams.newBuilder() .setProductType(ProductType.SUBS) .build() - val purchases = doOnConnectionReady { + val result = doOnConnectionReady { Log.d(TAG, "Querying purchases.") billingClient.queryPurchasesAsync(param) } - purchasesUpdatedListener.onPurchasesUpdated(purchases.billingResult, purchases.purchasesList) + purchasesUpdatedListener.onPurchasesUpdated(result.billingResult, result.purchasesList) + + val purchase = result.purchasesList.maxByOrNull { it.purchaseTime } ?: return BillingPurchaseResult.None + + return BillingPurchaseResult.Success( + purchaseTime = purchase.purchaseTime, + purchaseToken = purchase.purchaseToken, + isAcknowledged = purchase.isAcknowledged, + isAutoRenewing = purchase.isAutoRenewing + ) } /** diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt index d92d815af9..e84bdd7815 100644 --- a/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt @@ -27,7 +27,7 @@ interface BillingApi { * Queries the user's current purchases. This enqueues a check and will * propagate it to the normal callbacks in the api. */ - suspend fun queryPurchases() = Unit + suspend fun queryPurchases(): BillingPurchaseResult = BillingPurchaseResult.None suspend fun launchBillingFlow(activity: Activity) = Unit diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt index d8bf0d2dfb..389c96b335 100644 --- a/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt @@ -5,6 +5,9 @@ package org.signal.core.util.billing +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds + /** * Sealed class hierarchy representing the different success * and error states of google play billing purchases. @@ -13,8 +16,21 @@ sealed interface BillingPurchaseResult { data class Success( val purchaseToken: String, val isAcknowledged: Boolean, - val purchaseTime: Long - ) : BillingPurchaseResult + val purchaseTime: Long, + val isAutoRenewing: Boolean + ) : BillingPurchaseResult { + + /** + * @return true if purchaseTime is within the last month. + */ + fun isWithinTheLastMonth(): Boolean { + val now = System.currentTimeMillis().milliseconds + val oneMonthAgo = now - 31.days + val purchaseTime = this.purchaseTime.milliseconds + + return oneMonthAgo >= purchaseTime + } + } data object UserCancelled : BillingPurchaseResult data object None : BillingPurchaseResult data object TryAgainLater : BillingPurchaseResult