mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-28 07:51:42 +01:00
Fix paid backups being reported as a subscription mismatch.
This commit is contained in:
+66
-4
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 -> {
|
||||
|
||||
+31
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user