From c5a18d6e388d679802db91a3ea8121dc01e16b32 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 3 Sep 2026 11:19:21 -0300 Subject: [PATCH] Fix backup subscription state for subscriptions billed through the App Store. --- .../settings/app/backups/BackupState.kt | 8 +- .../app/backups/BackupStateObserver.kt | 6 +- .../remote/RemoteBackupsSettingsFragment.kt | 29 +++- .../subscription/InAppPaymentsRepository.kt | 19 +++ .../InAppPaymentsRepositoryBackupStoreTest.kt | 132 ++++++++++++++++++ 5 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepositoryBackupStoreTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupState.kt index ed91a14c3f..1b938cf459 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupState.kt @@ -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 /** 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 70e24ac0b8..dd5f6accfd 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 @@ -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) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt index 649237c023..ad3e431c92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt @@ -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() { 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 7c4fcc8731..20f11c0b2e 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 @@ -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. */ diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepositoryBackupStoreTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepositoryBackupStoreTest.kt new file mode 100644 index 0000000000..62a8e0d28f --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepositoryBackupStoreTest.kt @@ -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 + } +}