mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Fix backup subscription state for subscriptions billed through the App Store.
This commit is contained in:
committed by
Cody Henthorne
parent
57972464dd
commit
c5a18d6e38
+6
-2
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+4
-2
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -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() {
|
||||
|
||||
+19
@@ -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.
|
||||
*/
|
||||
|
||||
+132
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user