diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt index b7882a9662..bc815a63d2 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt @@ -355,7 +355,7 @@ class BackupSubscriptionCheckJobTest { verify { RecurringInAppPaymentRepository.ensureSubscriberIdSync( eq(InAppPaymentSubscriberRecord.Type.BACKUP), - eq(true), + eq(false), eq(IAPSubscriptionId.GooglePlayBillingPurchaseToken(purchaseToken = "test_token")) ) } @@ -419,7 +419,26 @@ class BackupSubscriptionCheckJobTest { } @Test - fun givenUnacknowledgedPurchaseMatchingSubscriber_whenIRun_thenIExpectStateMismatchDetected() { + fun givenUnacknowledgedRedeemedPurchaseMatchingSubscriber_whenIRun_thenIExpectSuccessAndNoMismatch() { + mockUnacknowledgedPurchase() + insertRedeemedInAppPayment(insertSubscriber()) + + every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success( + createActiveSubscription(isActive = true) + ) + + SignalStore.backup.backupTier = MessageBackupTier.PAID + + val job = BackupSubscriptionCheckJob.create() + val result = job.run() + + assertThat(result.isSuccess).isTrue() + assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isFalse() + verify(exactly = 0) { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } + } + + @Test + fun givenUnacknowledgedUnredeemedPurchaseMatchingSubscriber_whenIRun_thenIExpectStateMismatchDetected() { mockUnacknowledgedPurchase() insertSubscriber() @@ -427,6 +446,8 @@ class BackupSubscriptionCheckJobTest { createActiveSubscription(isActive = true) ) + SignalStore.backup.backupTier = MessageBackupTier.PAID + val job = BackupSubscriptionCheckJob.create() val result = job.run() @@ -435,6 +456,31 @@ class BackupSubscriptionCheckJobTest { verify(exactly = 0) { RecurringInAppPaymentRepository.ensureSubscriberIdSync(any(), any(), any()) } } + @Test + fun givenUnacknowledgedRedeemedPurchaseMatchingSubscriberWithoutEntitlement_whenIRun_thenIExpectRedemption() { + mockUnacknowledgedPurchase() + insertRedeemedInAppPayment(insertSubscriber()) + + every { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) } returns NetworkResult.Success( + createActiveSubscription(isActive = true) + ) + + SignalStore.backup.backupTier = MessageBackupTier.FREE + + val job = BackupSubscriptionCheckJob.create() + val result = job.run() + + assertThat(result.isSuccess).isTrue() + assertThat(SignalStore.backup.subscriptionStateMismatchDetected).isFalse() + verify { + RecurringInAppPaymentRepository.ensureSubscriberIdSync( + eq(InAppPaymentSubscriberRecord.Type.BACKUP), + eq(false), + eq(IAPSubscriptionId.GooglePlayBillingPurchaseToken(purchaseToken = IAP_TOKEN)) + ) + } + } + @Test fun givenValidActiveState_whenIRun_thenIExpectSuccessAndNoMismatch() { mockActivePurchase() @@ -669,7 +715,9 @@ class BackupSubscriptionCheckJobTest { ) } - private fun insertSubscriber(token: String = IAP_TOKEN) { + private fun insertSubscriber(token: String = IAP_TOKEN): SubscriberId { + val subscriberId = SubscriberId.generate() + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( InAppPaymentSubscriberRecord( type = InAppPaymentSubscriberRecord.Type.BACKUP, @@ -677,7 +725,21 @@ class BackupSubscriptionCheckJobTest { requiresCancel = false, paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, currency = null, - subscriberId = SubscriberId.generate() + subscriberId = subscriberId + ) + ) + + return subscriberId + } + + private fun insertRedeemedInAppPayment(subscriberId: SubscriberId) { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_BACKUP, + state = InAppPaymentTable.State.END, + subscriberId = subscriberId, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData( + redemption = InAppPaymentData.RedemptionState(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED) ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupStateObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupStateObserver.kt index a4576ebf9b..36ee97a2fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupStateObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupStateObserver.kt @@ -259,7 +259,8 @@ class BackupStateObserver( val googlePlayBillingSubscriptionIsActiveAndWillRenew = when (purchaseResult) { is BillingPurchaseResult.Success -> { Log.d(TAG, "[getNetworkBackupState][subscriptionStateMismatchDetected] Found a purchase: $purchaseResult") - purchaseResult.isAcknowledged && purchaseResult.isAutoRenewing + purchaseResult.isAutoRenewing && + withContext(SignalDispatchers.IO) { InAppPaymentsRepository.isPurchaseValidatedByService(purchaseResult) } } else -> { 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 5f380daa66..02b1e9986e 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 @@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import org.signal.core.util.Util +import org.signal.core.util.billing.BillingPurchaseResult +import org.signal.core.util.billing.BillingPurchaseState import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log import org.signal.donations.InAppPaymentType @@ -588,6 +590,35 @@ object InAppPaymentsRepository { return getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP)?.iapSubscriptionId is IAPSubscriptionId.AppleIAPOriginalTransactionId } + /** + * Whether the service has successfully validated the given Google Play purchase. + * + * [BillingPurchaseResult.Success.isAcknowledged] is proof when true, but we read it out of the Play Store's local + * cache, which can lag the service by twenty minutes or more (AND-9874). A redemption against the subscriber holding + * this token is equivalent proof, as it cannot complete unless the service validated the token first. + * + * A token match alone is not proof: we write it when the subscriber id is created, before the token ever reaches the + * service. + */ + @WorkerThread + fun isPurchaseValidatedByService(purchase: BillingPurchaseResult): Boolean { + if (purchase !is BillingPurchaseResult.Success || purchase.purchaseState != BillingPurchaseState.PURCHASED) { + return false + } + + if (purchase.isAcknowledged) { + return true + } + + val subscriber = getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) ?: return false + if (subscriber.iapSubscriptionId?.purchaseToken != purchase.purchaseToken) { + return false + } + + val latestPayment = SignalDatabase.inAppPayments.getLatestBySubscriberId(subscriber.subscriberId) ?: return false + return latestPayment.state == InAppPaymentTable.State.END && latestPayment.data.redemption?.stage == InAppPaymentData.RedemptionState.Stage.REDEEMED + } + /** * Gets a non-null subscriber for the given type, or throws. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt index 04c509ec2b..1c6ea392dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -125,8 +125,6 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C return Result.success() } - val hasActivePurchase = purchase is BillingPurchaseResult.Success && purchase.isAcknowledged - // Grabs the purchase token which may need to be linked if we need to rotate the subscription. val linkablePurchaseToken = if (purchase is BillingPurchaseResult.Success && purchase.purchaseState == BillingPurchaseState.PURCHASED) { purchase.purchaseToken @@ -175,27 +173,28 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C checkAndSynchronizeZkCredentialTierWithStoredLocalTier() } + val hasActivePurchase = InAppPaymentsRepository.isPurchaseValidatedByService(purchase) val hasActivePaidBackupTier = SignalStore.backup.backupTier == MessageBackupTier.PAID val hasValidActiveState = hasActivePaidBackupTier && hasActiveSignalSubscription && hasActivePurchase val hasValidInactiveState = !hasActivePaidBackupTier && !hasActiveSignalSubscription && !hasActivePurchase val purchaseToken = if (hasActivePurchase) { - purchase.purchaseToken + linkablePurchaseToken } else { null } if (linkablePurchaseToken != null && hasActiveSignalSubscription && hasLocalDevicePurchaseTokenMismatch(linkablePurchaseToken)) { - Log.i(TAG, "Encountered token mismatch with an active Signal subscription. Attempting to redeem against latest token. (isAcknowledged: $hasActivePurchase)", true) - val rotated = rotateAndRedeem(linkablePurchaseToken, product.price) - Log.i(TAG, "Token mismatch redemption enqueued: $rotated. Setting mismatch value to ${!rotated} and exiting.", true) - SignalStore.backup.subscriptionStateMismatchDetected = !rotated + Log.i(TAG, "Encountered token mismatch with an active Signal subscription. Attempting to redeem against latest token. (hasActivePurchase: $hasActivePurchase)", true) + val enqueued = redeemAgainstToken(linkablePurchaseToken, product.price, rotateSubscriberId = true) + Log.i(TAG, "Token mismatch redemption enqueued: $enqueued. Setting mismatch value to ${!enqueued} and exiting.", true) + SignalStore.backup.subscriptionStateMismatchDetected = !enqueued return Result.success() } else if (purchaseToken != null && hasActiveSignalSubscription && !hasActivePaidBackupTier && !SignalDatabase.inAppPayments.hasPendingBackupRedemption()) { Log.i(TAG, "We have an active signal subscription and active purchase, but no entitlement and no pending redemption. Enqueuing a redemption now.") - val rotated = rotateAndRedeem(purchaseToken, product.price) - Log.i(TAG, "Missing-entitlement redemption enqueued: $rotated. Setting mismatch value to ${!rotated} and exiting.", true) - SignalStore.backup.subscriptionStateMismatchDetected = !rotated + val enqueued = redeemAgainstToken(purchaseToken, product.price, rotateSubscriberId = false) + Log.i(TAG, "Missing-entitlement redemption enqueued: $enqueued. Setting mismatch value to ${!enqueued} and exiting.", true) + SignalStore.backup.subscriptionStateMismatchDetected = !enqueued return Result.success() } else { if (hasValidActiveState || hasValidInactiveState) { @@ -281,15 +280,17 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C } /** - * Rotates the backup subscriber id onto the given purchase token and enqueues a fresh redemption chain. + * Enqueues a fresh redemption chain against the given purchase token, rotating onto a new subscriber id first when + * [rotateSubscriberId] is set. Only rotate when our token differs from the one on the subscriber record, as that id + * may belong to another processor; [InAppPaymentPurchaseTokenJob] rotates reactively on a 409 otherwise. * - * @return whether the redemption chain was enqueued. Callers should treat false as a still-mismatched state. + * @return whether the chain was enqueued. Callers should treat false as a still-mismatched state. */ - private fun rotateAndRedeem(localDevicePurchaseToken: String, localProductPrice: FiatMoney): Boolean { + private fun redeemAgainstToken(localDevicePurchaseToken: String, localProductPrice: FiatMoney, rotateSubscriberId: Boolean): Boolean { try { RecurringInAppPaymentRepository.ensureSubscriberIdSync( subscriberType = InAppPaymentSubscriberRecord.Type.BACKUP, - isRotation = true, + isRotation = rotateSubscriberId, iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(localDevicePurchaseToken) ) @@ -318,7 +319,7 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C return true } catch (e: Exception) { - Log.w(TAG, "Failed to rotate the subscriber id and enqueue a redemption. Will try again later.", e, true) + Log.w(TAG, "Failed to enqueue a redemption. Will try again later.", e, true) return false } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepositoryTest.kt index bf5db22757..6065191a24 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepositoryTest.kt @@ -8,7 +8,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription import android.app.Application import assertk.assertThat import assertk.assertions.isEqualTo +import assertk.assertions.isFalse import assertk.assertions.isNotNull +import assertk.assertions.isTrue import io.mockk.every import org.junit.Before import org.junit.Rule @@ -16,6 +18,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.signal.core.util.billing.BillingPurchaseResult +import org.signal.core.util.billing.BillingPurchaseState import org.signal.core.util.deleteAll import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable @@ -26,6 +30,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.MockSignalStoreRule import org.thoughtcrime.securesms.testutil.SignalDatabaseRule +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.SubscriberId import java.math.BigDecimal @@ -37,6 +42,10 @@ import kotlin.time.Duration.Companion.milliseconds @Config(manifest = Config.NONE, application = Application::class) class InAppPaymentsRepositoryTest { + companion object { + private const val IAP_TOKEN = "test_token" + } + @get:Rule val signalStore = MockSignalStoreRule() @@ -96,6 +105,93 @@ class InAppPaymentsRepositoryTest { assertThat(cancellation.chargeFailure.outcomeType).isEqualTo("") } + @Test + fun `isPurchaseValidatedByService is false for a purchase that is not in the purchased state`() { + insertRedeemedPayment(insertBackupSubscriber(IAP_TOKEN)) + + assertThat(InAppPaymentsRepository.isPurchaseValidatedByService(purchase(state = BillingPurchaseState.PENDING))).isFalse() + } + + @Test + fun `isPurchaseValidatedByService is true for an acknowledged purchase with no local subscriber`() { + assertThat(InAppPaymentsRepository.isPurchaseValidatedByService(purchase(isAcknowledged = true))).isTrue() + } + + @Test + fun `isPurchaseValidatedByService is true for an unacknowledged purchase whose token was redeemed`() { + insertRedeemedPayment(insertBackupSubscriber(IAP_TOKEN)) + + assertThat(InAppPaymentsRepository.isPurchaseValidatedByService(purchase())).isTrue() + } + + /** + * The token on the subscriber record is written as soon as the subscriber id is created, well before the service has + * seen it, so a redemption must only vouch for the token it was actually redeemed against. + */ + @Test + fun `isPurchaseValidatedByService is false for an unacknowledged purchase whose token differs from the redeemed one`() { + insertRedeemedPayment(insertBackupSubscriber("some_other_token")) + + assertThat(InAppPaymentsRepository.isPurchaseValidatedByService(purchase())).isFalse() + } + + @Test + fun `isPurchaseValidatedByService is false for an unacknowledged purchase whose token was never redeemed`() { + val subscriberId = insertBackupSubscriber(IAP_TOKEN) + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_BACKUP, + state = InAppPaymentTable.State.END, + subscriberId = subscriberId, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData() + ) + + assertThat(InAppPaymentsRepository.isPurchaseValidatedByService(purchase())).isFalse() + } + + private fun purchase( + state: BillingPurchaseState = BillingPurchaseState.PURCHASED, + isAcknowledged: Boolean = false, + purchaseToken: String = IAP_TOKEN + ): BillingPurchaseResult { + return BillingPurchaseResult.Success( + purchaseState = state, + purchaseToken = purchaseToken, + isAcknowledged = isAcknowledged, + purchaseTime = System.currentTimeMillis(), + isAutoRenewing = true + ) + } + + private fun insertBackupSubscriber(token: String): SubscriberId { + val subscriberId = SubscriberId.generate() + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + InAppPaymentSubscriberRecord( + subscriberId = subscriberId, + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(token) + ) + ) + + return subscriberId + } + + private fun insertRedeemedPayment(subscriberId: SubscriberId) { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_BACKUP, + state = InAppPaymentTable.State.END, + subscriberId = subscriberId, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData( + redemption = InAppPaymentData.RedemptionState(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED) + ) + ) + } + private fun canceledSubscription(chargeFailure: ActiveSubscription.ChargeFailure?): ActiveSubscription { val periodEnd = System.currentTimeMillis().milliseconds.inWholeSeconds + 45.days.inWholeSeconds return ActiveSubscription(