Add BackupSubscriptionCheckJob.

This commit is contained in:
Alex Hart 2024-10-04 10:39:26 -03:00 committed by Greyson Parrelli
parent 24209756e3
commit 5bc8435d25
10 changed files with 326 additions and 26 deletions

View file

@ -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
)
)
}
)
}
}

View file

@ -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 @WorkerThread
fun turnOffAndDeleteBackup() { fun turnOffAndDeleteBackup() {
RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)

View file

@ -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() InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue()
} }

View file

@ -178,10 +178,14 @@ object InAppPaymentsRepository {
return when (inAppPayment.type) { return when (inAppPayment.type) {
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN.") InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN.")
InAppPaymentType.ONE_TIME_GIFT, InAppPaymentType.ONE_TIME_DONATION -> "$JOB_PREFIX${inAppPayment.id.serialize()}" 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 * 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. * allow extra time for completion.

View file

@ -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<BackupSubscriptionCheckJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): BackupSubscriptionCheckJob {
return BackupSubscriptionCheckJob(parameters)
}
}
}

View file

@ -255,10 +255,8 @@ class InAppPaymentRedemptionJob private constructor(
) )
if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) { if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) {
Log.i(TAG, "Enabling backups and setting backup tier to PAID", true) Log.i(TAG, "Setting backup tier to PAID", true)
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.PAID SignalStore.backup.backupTier = MessageBackupTier.PAID
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
} }
} }

View file

@ -127,6 +127,7 @@ public final class JobManagerFactories {
put(BackupRestoreJob.KEY, new BackupRestoreJob.Factory()); put(BackupRestoreJob.KEY, new BackupRestoreJob.Factory());
put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory()); put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory());
put(BoostReceiptRequestResponseJob.KEY, new BoostReceiptRequestResponseJob.Factory()); put(BoostReceiptRequestResponseJob.KEY, new BoostReceiptRequestResponseJob.Factory());
put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory());
put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory()); put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory());
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory()); put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory()); put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory());

View file

@ -81,7 +81,8 @@ internal class BillingApiImpl(
BillingPurchaseResult.Success( BillingPurchaseResult.Success(
purchaseToken = newestPurchase.purchaseToken, purchaseToken = newestPurchase.purchaseToken,
isAcknowledged = newestPurchase.isAcknowledged, 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() val param = QueryPurchasesParams.newBuilder()
.setProductType(ProductType.SUBS) .setProductType(ProductType.SUBS)
.build() .build()
val purchases = doOnConnectionReady { val result = doOnConnectionReady {
Log.d(TAG, "Querying purchases.") Log.d(TAG, "Querying purchases.")
billingClient.queryPurchasesAsync(param) 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
)
} }
/** /**

View file

@ -27,7 +27,7 @@ interface BillingApi {
* Queries the user's current purchases. This enqueues a check and will * Queries the user's current purchases. This enqueues a check and will
* propagate it to the normal callbacks in the api. * 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 suspend fun launchBillingFlow(activity: Activity) = Unit

View file

@ -5,6 +5,9 @@
package org.signal.core.util.billing 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 * Sealed class hierarchy representing the different success
* and error states of google play billing purchases. * and error states of google play billing purchases.
@ -13,8 +16,21 @@ sealed interface BillingPurchaseResult {
data class Success( data class Success(
val purchaseToken: String, val purchaseToken: String,
val isAcknowledged: Boolean, val isAcknowledged: Boolean,
val purchaseTime: Long val purchaseTime: Long,
) : BillingPurchaseResult 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 UserCancelled : BillingPurchaseResult
data object None : BillingPurchaseResult data object None : BillingPurchaseResult
data object TryAgainLater : BillingPurchaseResult data object TryAgainLater : BillingPurchaseResult