Fix backup subscription state for subscriptions billed through the App Store.

This commit is contained in:
Alex Hart
2026-09-09 16:37:40 -04:00
committed by Cody Henthorne
parent 57972464dd
commit c5a18d6e38
5 changed files with 188 additions and 6 deletions
@@ -85,11 +85,15 @@ sealed interface BackupState {
) : WithTypeAndRenewalTime
/**
* Subscription mismatch detected.
* Subscription mismatch detected: we have a Signal subscription with no corresponding Google Play purchase.
*
* @param isBilledThroughOtherStore The subscription is billed through another platform's store, so Google Play will
* never report a purchase for it.
*/
data class SubscriptionMismatchMissingGooglePlay(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration
override val renewalTime: Duration,
val isBilledThroughOtherStore: Boolean = false
) : WithTypeAndRenewalTime
/**
@@ -191,7 +191,8 @@ class BackupStateObserver(
Log.d(TAG, "[getDatabaseBackupState] We have a subscription state mismatch with Google Play.")
return BackupState.SubscriptionMismatchMissingGooglePlay(
messageBackupsType = paidBackupType,
renewalTime = latestPayment.endOfPeriod
renewalTime = latestPayment.endOfPeriod,
isBilledThroughOtherStore = InAppPaymentsRepository.isBackupBilledThroughOtherStore()
)
}
@@ -312,7 +313,8 @@ class BackupStateObserver(
Log.d(TAG, "[getNetworkBackupState][subscriptionMismatchDetected] found a subscription mismatch and successfully loaded configuration.")
return BackupState.SubscriptionMismatchMissingGooglePlay(
messageBackupsType = type,
renewalTime = activeSubscription.activeSubscription.endOfCurrentPeriod.seconds
renewalTime = activeSubscription.activeSubscription.endOfCurrentPeriod.seconds,
isBilledThroughOtherStore = InAppPaymentsRepository.isBackupBilledThroughOtherStore(activeSubscription.activeSubscription)
)
}
@@ -1377,6 +1377,7 @@ private fun SubscriptionNotFoundCard(
) {
Buttons.MediumTonal(
onClick = onRenewClick,
enabled = isRenewEnabled,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
@@ -1392,7 +1393,6 @@ private fun SubscriptionNotFoundCard(
Buttons.MediumTonal(
onClick = onLearnMoreClick,
enabled = isRenewEnabled,
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(CoreUiR.color.signal_light_colorOnSurface)
@@ -1420,8 +1420,14 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
) {
val days by rememberUpdatedState((state.renewalTime - System.currentTimeMillis().milliseconds).inWholeDays)
val title = if (state.isBilledThroughOtherStore || days <= 0) {
stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found)
} else {
pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days)
}
SubscriptionNotFoundCard(
title = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days),
title = title,
isRenewEnabled = isRenewEnabled,
isLinkedDevice = isLinkedDevice,
onRenewClick = onRenewClick,
@@ -2031,6 +2037,25 @@ private fun SubscriptionMismatchMissingGooglePlayCardPreview() {
}
}
@DayNightPreviews
@Composable
private fun SubscriptionMismatchBilledThroughOtherStoreCardPreview() {
Previews.Preview {
SubscriptionMismatchMissingGooglePlayCard(
state = BackupState.SubscriptionMismatchMissingGooglePlay(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
),
renewalTime = System.currentTimeMillis().milliseconds + 30.days,
isBilledThroughOtherStore = true
),
isRenewEnabled = true
)
}
}
@DayNightPreviews
@Composable
private fun BackupCardPreview() {
@@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.internal.push.DonationProcessor
@@ -568,6 +569,24 @@ object InAppPaymentsRepository {
return getRecurringDonationSubscriber(currency)
}
/**
* Whether the user's backup subscription is billed through a store other than Google Play. This happens when they
* transferred from another platform and we restored that platform's subscriber record out of their backup, in which
* case Google Play will never report a purchase for it.
*
* @param subscription The active subscription, when available. The service reports the billing platform directly,
* which covers us before we've restored or synced that platform's subscriber record. A locally
* known Apple record is never overridden by the service report.
*/
@WorkerThread
fun isBackupBilledThroughOtherStore(subscription: ActiveSubscription.Subscription? = null): Boolean {
if (subscription?.paymentMethod == ActiveSubscription.PaymentMethod.APPLE_APP_STORE) {
return true
}
return getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP)?.iapSubscriptionId is IAPSubscriptionId.AppleIAPOriginalTransactionId
}
/**
* Gets a non-null subscriber for the given type, or throws.
*/
@@ -0,0 +1,132 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.isTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
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 org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import java.math.BigDecimal
/**
* Verifies how we identify a backup subscription that is billed through a store other than Google Play, which is what
* distinguishes a user who transferred from another platform from a user whose Google Play purchase has genuinely gone
* away.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class InAppPaymentsRepositoryBackupStoreTest {
@get:Rule
val signalStore = MockSignalStoreRule()
@get:Rule
val appDependencies = MockAppDependenciesRule()
@get:Rule
val signalDatabaseRule = SignalDatabaseRule()
@Test
fun givenAnAppleSubscriber_whenICheckBillingStore_thenIExpectOtherStore() {
insertBackupSubscriber(IAPSubscriptionId.AppleIAPOriginalTransactionId(1000L))
assertThat(InAppPaymentsRepository.isBackupBilledThroughOtherStore()).isTrue()
}
@Test
fun givenAGooglePlaySubscriber_whenICheckBillingStore_thenIExpectNotOtherStore() {
insertBackupSubscriber(IAPSubscriptionId.GooglePlayBillingPurchaseToken("test_token"))
assertThat(InAppPaymentsRepository.isBackupBilledThroughOtherStore()).isFalse()
}
@Test
fun givenNoSubscriber_whenICheckBillingStore_thenIExpectNotOtherStore() {
assertThat(InAppPaymentsRepository.isBackupBilledThroughOtherStore()).isFalse()
}
@Test
fun givenAGooglePlaySubscriberAndAnAppleBilledSubscription_whenICheckBillingStore_thenIExpectOtherStore() {
insertBackupSubscriber(IAPSubscriptionId.GooglePlayBillingPurchaseToken("test_token"))
val result = InAppPaymentsRepository.isBackupBilledThroughOtherStore(
createSubscription(ActiveSubscription.PaymentMethod.APPLE_APP_STORE)
)
assertThat(result).isTrue()
}
@Test
fun givenAnAppleSubscriberAndAGoogleBilledSubscription_whenICheckBillingStore_thenIExpectOtherStore() {
insertBackupSubscriber(IAPSubscriptionId.AppleIAPOriginalTransactionId(1000L))
val result = InAppPaymentsRepository.isBackupBilledThroughOtherStore(
createSubscription(ActiveSubscription.PaymentMethod.GOOGLE_PLAY_BILLING)
)
assertThat(result).isTrue()
}
@Test
fun givenAGooglePlaySubscriberAndAGoogleBilledSubscription_whenICheckBillingStore_thenIExpectNotOtherStore() {
insertBackupSubscriber(IAPSubscriptionId.GooglePlayBillingPurchaseToken("test_token"))
val result = InAppPaymentsRepository.isBackupBilledThroughOtherStore(
createSubscription(ActiveSubscription.PaymentMethod.GOOGLE_PLAY_BILLING)
)
assertThat(result).isFalse()
}
private fun insertBackupSubscriber(iapSubscriptionId: IAPSubscriptionId) {
SignalDatabase.inAppPaymentSubscribers.insertOrReplace(
InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
currency = null,
type = InAppPaymentSubscriberRecord.Type.BACKUP,
requiresCancel = false,
paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN,
iapSubscriptionId = iapSubscriptionId
)
)
}
private fun createSubscription(paymentMethod: ActiveSubscription.PaymentMethod): ActiveSubscription.Subscription {
val subscription = ActiveSubscription.Subscription(
SubscriptionsConfiguration.BACKUPS_LEVEL,
"USD",
BigDecimal(299),
2147472000L,
true,
2147472000L,
false,
"active",
ActiveSubscription.Processor.GOOGLE_PLAY_BILLING.code,
paymentMethod.name,
false
)
assertThat(subscription.paymentMethod).isEqualTo(paymentMethod)
return subscription
}
}