Skip to content
This repository has been archived by the owner on Jan 10, 2025. It is now read-only.

Commit

Permalink
Add BackupSubscriptionCheckJob.
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-signal authored and greyson-signal committed Oct 7, 2024
1 parent 2420975 commit 5bc8435
Show file tree
Hide file tree
Showing 10 changed files with 326 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -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
)
)
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ public static Map<String, Job.Factory> getJobFactories(@NonNull Application appl
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());
Expand Down
18 changes: 14 additions & 4 deletions billing/src/main/java/org/signal/billing/BillingApiImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ internal class BillingApiImpl(
BillingPurchaseResult.Success(
purchaseToken = newestPurchase.purchaseToken,
isAcknowledged = newestPurchase.isAcknowledged,
purchaseTime = newestPurchase.purchaseTime
purchaseTime = newestPurchase.purchaseTime,
isAutoRenewing = newestPurchase.isAutoRenewing
)
}
}
Expand Down Expand Up @@ -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
)
}

/**
Expand Down
Loading

0 comments on commit 5bc8435

Please sign in to comment.