Fix paid backups being reported as a subscription mismatch.

This commit is contained in:
Alex Hart
2026-09-23 16:12:24 -04:00
committed by Michelle Tang
parent 1ef71ad8fb
commit 027ec983cf
5 changed files with 211 additions and 20 deletions
@@ -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)
)
)
}
@@ -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 -> {
@@ -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.
*/
@@ -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
}
}
@@ -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(