diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt index 0b75e87c36..862bf29994 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt @@ -108,11 +108,12 @@ class CheckoutFlowActivityTest__RecurringDonations { currency = currency, type = InAppPaymentSubscriberRecord.Type.DONATION, requiresCancel = false, - paymentMethodType = InAppPaymentData.PaymentMethodType.CARD + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = null ) InAppPaymentsRepository.setSubscriber(subscriber) - SignalStore.inAppPayments.setSubscriberCurrency(currency, subscriber.type) + SignalStore.inAppPayments.setRecurringDonationCurrency(currency) InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers( Get("/v1/subscription/${subscriber.subscriberId.serialize()}") { @@ -149,11 +150,12 @@ class CheckoutFlowActivityTest__RecurringDonations { currency = currency, type = InAppPaymentSubscriberRecord.Type.DONATION, requiresCancel = false, - paymentMethodType = InAppPaymentData.PaymentMethodType.CARD + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = null ) InAppPaymentsRepository.setSubscriber(subscriber) - SignalStore.inAppPayments.setSubscriberCurrency(currency, subscriber.type) + SignalStore.inAppPayments.setRecurringDonationCurrency(currency) InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers( Get("/v1/subscription/${subscriber.subscriberId.serialize()}") { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTableTest.kt new file mode 100644 index 0000000000..50a192a03b --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTableTest.kt @@ -0,0 +1,201 @@ +package org.thoughtcrime.securesms.database + +import android.database.sqlite.SQLiteConstraintException +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.signal.core.util.count +import org.signal.core.util.deleteAll +import org.signal.core.util.readToSingleInt +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.assertIs +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import java.util.Currency + +class InAppPaymentSubscriberTableTest { + @get:Rule + val harness = SignalActivityRule() + + @Before + fun setUp() { + SignalDatabase.inAppPaymentSubscribers.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME) + } + + @Test(expected = SQLiteConstraintException::class) + fun givenASubscriberWithCurrencyAndIAPData_whenITryToInsert_thenIExpectException() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = Currency.getInstance("USD"), + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken("testToken") + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + + fail("Expected a thrown exception.") + } + + @Test(expected = SQLiteConstraintException::class) + fun givenADonorSubscriberWithGoogleIAPData_whenITryToInsert_thenIExpectException() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken("testToken") + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + + fail("Expected a thrown exception.") + } + + @Test(expected = SQLiteConstraintException::class) + fun givenADonorSubscriberWithAppleIAPData_whenITryToInsert_thenIExpectException() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = IAPSubscriptionId.AppleIAPOriginalTransactionId(1000L) + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + + fail("Expected a thrown exception.") + } + + @Test(expected = SQLiteConstraintException::class) + fun givenADonorSubscriberWithoutCurrency_whenITryToInsert_thenIExpectException() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = null + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + + fail("Expected a thrown exception.") + } + + @Test + fun givenADonorSubscriberWithCurrency_whenITryToInsert_thenIExpectSuccess() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = Currency.getInstance("USD"), + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = null + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + } + + @Test(expected = SQLiteConstraintException::class) + fun givenABackupSubscriberWithCurrency_whenITryToInsert_thenIExpectException() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = Currency.getInstance("USD"), + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = null + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + + fail("Expected a thrown exception.") + } + + @Test(expected = SQLiteConstraintException::class) + fun givenABackupSubscriberWithoutIAPData_whenITryToInsert_thenIExpectException() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = null + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + } + + @Test + fun givenABackupSubscriberWithGoogleIAPData_whenITryToInsert_thenIExpectSuccess() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken("testToken") + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + } + + @Test + fun givenABackupSubscriberWithAppleIAPData_whenITryToInsert_thenIExpectSuccess() { + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.AppleIAPOriginalTransactionId(1000L) + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber) + } + + @Test + fun givenABackupSubscriberWithAppleIAPData_whenITryToInsertAGoogleSubscriber_thenIExpectSuccess() { + val appleSubscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.AppleIAPOriginalTransactionId(1000L) + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(appleSubscriber) + + val googleSubscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken("testToken") + ) + + SignalDatabase.inAppPaymentSubscribers.insertOrReplace(googleSubscriber) + + val subscriberCount = SignalDatabase.inAppPaymentSubscribers.readableDatabase.count() + .from(InAppPaymentSubscriberTable.TABLE_NAME) + .run() + .readToSingleInt() + + subscriberCount assertIs 1 + + val subscriber = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) + subscriber.iapSubscriptionId?.originalTransactionId assertIs null + subscriber.iapSubscriptionId?.purchaseToken assertIs "testToken" + subscriber.subscriberId assertIs googleSubscriber.subscriberId + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/helpers/migration/FixInAppCurrencyIfAbleTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/helpers/migration/FixInAppCurrencyIfAbleTest.kt index ea471c92ad..2b68e78b3c 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/helpers/migration/FixInAppCurrencyIfAbleTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/helpers/migration/FixInAppCurrencyIfAbleTest.kt @@ -104,7 +104,8 @@ class FixInAppCurrencyIfAbleTest { currency = Currency.getInstance(currencyCode), type = InAppPaymentSubscriberRecord.Type.DONATION, requiresCancel = false, - paymentMethodType = InAppPaymentData.PaymentMethodType.PAYPAL + paymentMethodType = InAppPaymentData.PaymentMethodType.PAYPAL, + iapSubscriptionId = null ) SignalDatabase.inAppPaymentSubscribers.insertOrReplace(record) diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt index 7b136b8310..c1c043592c 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt @@ -14,6 +14,7 @@ import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import okio.ByteString import org.signal.core.util.Base64 +import org.signal.core.util.billing.BillingApi import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess @@ -49,6 +50,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application, private val serviceNetworkAccessMock: SignalServiceNetworkAccess private val recipientCache: LiveRecipientCache private var signalServiceMessageSender: SignalServiceMessageSender? = null + private var billingApi: BillingApi = mockk() init { runSync { @@ -108,6 +110,8 @@ class InstrumentationApplicationDependencyProvider(val application: Application, recipientCache = LiveRecipientCache(application) { r -> r.run() } } + override fun provideBillingApi(): BillingApi = billingApi + override fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess { return serviceNetworkAccessMock } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJobTest.kt new file mode 100644 index 0000000000..f442bcc108 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJobTest.kt @@ -0,0 +1,162 @@ +package org.thoughtcrime.securesms.migrations + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.billing.BillingPurchaseResult +import org.signal.core.util.billing.BillingPurchaseState +import org.signal.core.util.deleteAll +import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable +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.dependencies.AppDependencies +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.testing.assertIs +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId +import org.whispersystems.signalservice.api.subscriptions.SubscriberId + +@RunWith(AndroidJUnit4::class) +class GooglePlayBillingPurchaseTokenMigrationJobTest { + @get:Rule + val harness = SignalActivityRule() + + @Before + fun setUp() { + SignalDatabase.inAppPaymentSubscribers.writableDatabase.deleteAll(InAppPaymentSubscriberTable.TABLE_NAME) + } + + @Test + fun givenNoSubscribers_whenIRunJob_thenIExpectNoBillingAccess() { + val job = GooglePlayBillingPurchaseTokenMigrationJob() + + job.run() + + verify { AppDependencies.billingApi wasNot Called } + } + + @Test + fun givenSubscriberWithAppleData_whenIRunJob_thenIExpectNoBillingAccess() { + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.AppleIAPOriginalTransactionId(1000L) + ) + ) + + val job = GooglePlayBillingPurchaseTokenMigrationJob() + + job.run() + + verify { AppDependencies.billingApi wasNot Called } + } + + @Test + fun givenSubscriberWithGoogleToken_whenIRunJob_thenIExpectNoBillingAccess() { + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken("testToken") + ) + ) + + val job = GooglePlayBillingPurchaseTokenMigrationJob() + + job.run() + + verify { AppDependencies.billingApi wasNot Called } + } + + @Test + fun givenSubscriberWithPlaceholderAndNoBillingAccess_whenIRunJob_thenIExpectNoUpdate() { + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken("-") + ) + ) + + coEvery { AppDependencies.billingApi.isApiAvailable() } returns false + + val job = GooglePlayBillingPurchaseTokenMigrationJob() + + job.run() + + val sub = SignalDatabase.inAppPaymentSubscribers.getBackupsSubscriber() + + sub?.iapSubscriptionId?.purchaseToken assertIs "-" + } + + @Test + fun givenSubscriberWithPlaceholderAndNoPurchase_whenIRunJob_thenIExpectNoUpdate() { + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken("-") + ) + ) + + coEvery { AppDependencies.billingApi.isApiAvailable() } returns true + coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.None + + val job = GooglePlayBillingPurchaseTokenMigrationJob() + + job.run() + + val sub = SignalDatabase.inAppPaymentSubscribers.getBackupsSubscriber() + + sub?.iapSubscriptionId?.purchaseToken assertIs "-" + } + + @Test + fun givenSubscriberWithPurchase_whenIRunJob_thenIExpectUpdate() { + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING, + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken("-") + ) + ) + + coEvery { AppDependencies.billingApi.isApiAvailable() } returns true + coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success( + purchaseState = BillingPurchaseState.PURCHASED, + purchaseToken = "purchaseToken", + isAcknowledged = true, + purchaseTime = System.currentTimeMillis(), + isAutoRenewing = true + ) + + val job = GooglePlayBillingPurchaseTokenMigrationJob() + + job.run() + + val sub = SignalDatabase.inAppPaymentSubscribers.getBackupsSubscriber() + + sub?.iapSubscriptionId?.purchaseToken assertIs "purchaseToken" + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJobTest.kt index 3eed9ac9d7..ccd2c02cfe 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJobTest.kt @@ -36,14 +36,14 @@ class SubscriberIdMigrationJobTest { @Test fun givenUSDSubscriber_whenIRunSubscriberIdMigrationJob_thenIExpectASingleEntry() { val subscriberId = SubscriberId.generate() - SignalStore.inAppPayments.setSubscriberCurrency(Currency.getInstance("USD"), InAppPaymentSubscriberRecord.Type.DONATION) + SignalStore.inAppPayments.setRecurringDonationCurrency(Currency.getInstance("USD")) SignalStore.inAppPayments.setSubscriber("USD", subscriberId) SignalStore.inAppPayments.setSubscriptionPaymentSourceType(PaymentSourceType.PayPal) SignalStore.inAppPayments.shouldCancelSubscriptionBeforeNextSubscribeAttempt = true testSubject.run() - val actual = SignalDatabase.inAppPaymentSubscribers.getByCurrencyCode("USD", InAppPaymentSubscriberRecord.Type.DONATION) + val actual = SignalDatabase.inAppPaymentSubscribers.getByCurrencyCode("USD") actual.assertIsNotNull() actual!!.subscriberId.bytes assertIs subscriberId.bytes diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataArchiveProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataArchiveProcessor.kt index 0330b99de1..c59c5ba83b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataArchiveProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataArchiveProcessor.kt @@ -54,8 +54,8 @@ object AccountDataArchiveProcessor { val selfId = db.recipientTable.getByAci(signalStore.accountValues.aci!!).get() val selfRecord = db.recipientTable.getRecordForSync(selfId)!! - val donationCurrency = signalStore.inAppPaymentValues.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION) - val donationSubscriber = db.inAppPaymentSubscriberTable.getByCurrencyCode(donationCurrency.currencyCode, InAppPaymentSubscriberRecord.Type.DONATION) + val donationCurrency = signalStore.inAppPaymentValues.getRecurringDonationCurrency() + val donationSubscriber = db.inAppPaymentSubscriberTable.getByCurrencyCode(donationCurrency.currencyCode) val chatColors = SignalStore.chatColors.chatColors val chatWallpaper = SignalStore.wallpaper.currentRawWallpaper @@ -127,11 +127,12 @@ object AccountDataArchiveProcessor { val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) val subscriber = InAppPaymentSubscriberRecord( - remoteSubscriberId, - Currency.getInstance(accountData.donationSubscriberData.currencyCode), - InAppPaymentSubscriberRecord.Type.DONATION, - localSubscriber?.requiresCancel ?: accountData.donationSubscriberData.manuallyCancelled, - InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION) + subscriberId = remoteSubscriberId, + currency = Currency.getInstance(accountData.donationSubscriberData.currencyCode), + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = localSubscriber?.requiresCancel ?: accountData.donationSubscriberData.manuallyCancelled, + paymentMethodType = InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION), + iapSubscriptionId = null ) InAppPaymentsRepository.setSubscriber(subscriber) @@ -273,9 +274,12 @@ object AccountDataArchiveProcessor { } } + /** + * This method only supports donations subscriber data, and assumes there is a currency code available. + */ private fun InAppPaymentSubscriberRecord.toSubscriberData(manuallyCancelled: Boolean): AccountData.SubscriberData { val subscriberId = subscriberId.bytes.toByteString() - val currencyCode = currency.currencyCode + val currencyCode = currency!!.currencyCode return AccountData.SubscriberData(subscriberId = subscriberId, currencyCode = currencyCode, manuallyCancelled = manuallyCancelled) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index f839bd5ab9..d29cede52d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -117,7 +117,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega stage = state.stage, currentBackupTier = state.currentMessageBackupTier, selectedBackupTier = state.selectedMessageBackupTier, - availableBackupTypes = state.availableBackupTypes.filter { it.tier == MessageBackupTier.FREE || state.hasBackupSubscriberAvailable }, + availableBackupTypes = state.availableBackupTypes, onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated, onNavigationClick = viewModel::goToPreviousStage, onReadMoreClicked = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt index 698bc87440..db3fc95e08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt @@ -11,7 +11,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.whispersystems.signalservice.api.AccountEntropyPool data class MessageBackupsFlowState( - val hasBackupSubscriberAvailable: Boolean = false, val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier, val currentMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier, val availableBackupTypes: List = emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index 832d6ce1a5..dd546ac1b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription +import androidx.annotation.WorkerThread import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.RemoteConfig +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration import kotlin.time.Duration.Companion.seconds @@ -68,17 +70,6 @@ class MessageBackupsFlowViewModel( check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." } viewModelScope.launch { - try { - ensureSubscriberIdForBackups() - internalStateFlow.update { - it.copy( - hasBackupSubscriberAvailable = true - ) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to ensure a subscriber id exists.", e) - } - internalStateFlow.update { it.copy( availableBackupTypes = BackupRepository.getAvailableBackupsTypes( @@ -210,7 +201,6 @@ class MessageBackupsFlowViewModel( MessageBackupTier.PAID -> { check(state.selectedMessageBackupTier == MessageBackupTier.PAID) check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier }) - check(state.hasBackupSubscriberAvailable) viewModelScope.launch(Dispatchers.IO) { internalStateFlow.update { it.copy(inAppPayment = null) } @@ -221,7 +211,7 @@ class MessageBackupsFlowViewModel( val id = SignalDatabase.inAppPayments.insert( type = InAppPaymentType.RECURRING_BACKUP, state = InAppPaymentTable.State.CREATED, - subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId, + subscriberId = null, endOfPeriod = null, inAppPaymentData = InAppPaymentData( badge = null, @@ -251,10 +241,9 @@ class MessageBackupsFlowViewModel( * the screen this is called in is assumed to only be accessible if the user does not currently have * a subscription. */ - private suspend fun ensureSubscriberIdForBackups() { - val product = AppDependencies.billingApi.queryProduct() ?: error("No product available.") - SignalStore.inAppPayments.setSubscriberCurrency(product.price.currency, InAppPaymentSubscriberRecord.Type.BACKUP) - RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP).blockingAwait() + @WorkerThread + private fun ensureSubscriberIdForBackups(purchaseToken: IAPSubscriptionId.GooglePlayBillingPurchaseToken) { + RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = purchaseToken).blockingAwait() } /** @@ -264,11 +253,13 @@ class MessageBackupsFlowViewModel( @OptIn(FlowPreview::class) private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) { withContext(Dispatchers.IO) { - Log.d(TAG, "Setting purchase token data on InAppPayment.") + Log.d(TAG, "Setting purchase token data on InAppPayment and InAppPaymentSubscriber.") + ensureSubscriberIdForBackups(IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken)) val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! SignalDatabase.inAppPayments.update( inAppPayment.copy( + subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId, data = inAppPayment.data.copy( redemption = inAppPayment.data.redemption!!.copy( googlePlayBillingPurchaseToken = result.purchaseToken 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 4d8c60a8a8..6cc2d5532d 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 @@ -450,14 +450,10 @@ object InAppPaymentsRepository { @Suppress("DEPRECATION") @SuppressLint("DiscouragedApi") @WorkerThread - fun getSubscriber(currency: Currency, type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? { - val subscriber = SignalDatabase.inAppPaymentSubscribers.getByCurrencyCode(currency.currencyCode, type) + fun getRecurringDonationSubscriber(currency: Currency): InAppPaymentSubscriberRecord? { + val subscriber = SignalDatabase.inAppPaymentSubscribers.getByCurrencyCode(currency.currencyCode) - return if (subscriber == null && type == InAppPaymentSubscriberRecord.Type.DONATION) { - SignalStore.inAppPayments.getSubscriber(currency) - } else { - subscriber - } + return subscriber ?: SignalStore.inAppPayments.getSubscriber(currency) } /** @@ -466,10 +462,14 @@ object InAppPaymentsRepository { @JvmStatic @WorkerThread fun getSubscriber(type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? { - val currency = SignalStore.inAppPayments.getSubscriptionCurrency(type) + if (type == InAppPaymentSubscriberRecord.Type.BACKUP) { + return SignalDatabase.inAppPaymentSubscribers.getBackupsSubscriber() + } + + val currency = SignalStore.inAppPayments.getRecurringDonationCurrency() Log.d(TAG, "Attempting to retrieve subscriber of type $type for ${currency.currencyCode}") - return getSubscriber(currency, type) + return getRecurringDonationSubscriber(currency) } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt index 94dbe329aa..c0e49df740 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt @@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.subscription.LevelUpdateOperation import org.thoughtcrime.securesms.subscription.Subscription +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey import org.whispersystems.signalservice.api.subscriptions.SubscriberId @@ -109,7 +110,7 @@ object RecurringInAppPaymentRepository { return cancelCompletable.andThen(ensureSubscriberId(subscriberType, isRotation = true)) } - fun ensureSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false): Completable { + fun ensureSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false, iapSubscriptionId: IAPSubscriptionId? = null): Completable { return Single.fromCallable { Log.d(TAG, "Ensuring SubscriberId for type $subscriberType exists on Signal service {isRotation?$isRotation}...", true) @@ -131,10 +132,19 @@ object RecurringInAppPaymentRepository { InAppPaymentsRepository.setSubscriber( InAppPaymentSubscriberRecord( subscriberId = subscriberId, - currency = SignalStore.inAppPayments.getSubscriptionCurrency(subscriberType), + currency = if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) { + SignalStore.inAppPayments.getRecurringDonationCurrency() + } else { + null + }, type = subscriberType, requiresCancel = false, - paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN + paymentMethodType = if (subscriberType == InAppPaymentSubscriberRecord.Type.BACKUP) { + InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING + } else { + InAppPaymentData.PaymentMethodType.UNKNOWN + }, + iapSubscriptionId = iapSubscriptionId ) ) @@ -214,7 +224,7 @@ object RecurringInAppPaymentRepository { AppDependencies.donationsService.updateSubscriptionLevel( subscriber.subscriberId, subscriptionLevel, - subscriber.currency.currencyCode, + subscriber.currency!!.currencyCode, levelUpdateOperation.idempotencyKey.serialize(), subscriberType.lock ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt index c9143115eb..629c4f3780 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt @@ -24,7 +24,7 @@ class SetCurrencyViewModel( private val store = Store( SetCurrencyState( selectedCurrencyCode = if (inAppPaymentType.recurring) { - SignalStore.inAppPayments.getSubscriptionCurrency(inAppPaymentType.requireSubscriberType()).currencyCode + SignalStore.inAppPayments.getRecurringDonationCurrency().currencyCode } else { SignalStore.inAppPayments.getOneTimeCurrency().currencyCode }, @@ -34,6 +34,10 @@ class SetCurrencyViewModel( ) ) + init { + check(inAppPaymentType != InAppPaymentType.RECURRING_BACKUP) { "Setting currency is unsupported for backups." } + } + val state: LiveData = store.stateLiveData fun setSelectedCurrency(selectedCurrencyCode: String) { @@ -43,7 +47,7 @@ class SetCurrencyViewModel( SignalStore.inAppPayments.setOneTimeCurrency(Currency.getInstance(selectedCurrencyCode)) } else { val currency = Currency.getInstance(selectedCurrencyCode) - val subscriber = InAppPaymentsRepository.getSubscriber(currency, inAppPaymentType.requireSubscriberType()) + val subscriber = InAppPaymentsRepository.getRecurringDonationSubscriber(currency) if (subscriber != null) { InAppPaymentsRepository.setSubscriber(subscriber) @@ -54,7 +58,8 @@ class SetCurrencyViewModel( currency = currency, type = inAppPaymentType.requireSubscriberType(), requiresCancel = false, - paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN + paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN, + iapSubscriptionId = null ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt index 4019515aea..34d62d1132 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt @@ -6,7 +6,6 @@ import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost import org.thoughtcrime.securesms.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation -import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.database.model.isLongRunning import org.thoughtcrime.securesms.database.model.isPending @@ -114,7 +113,7 @@ data class DonateToSignalState( } data class MonthlyDonationState( - val selectedCurrency: Currency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION), + val selectedCurrency: Currency = SignalStore.inAppPayments.getRecurringDonationCurrency(), val subscriptions: List = emptyList(), private val _activeSubscription: ActiveSubscription? = null, val selectedSubscription: Subscription? = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index b37c98f02b..0c959081fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -388,17 +388,18 @@ class DonateToSignalViewModel( onSuccess = { subscriptions -> if (subscriptions.isNotEmpty()) { val priceCurrencies = subscriptions[0].prices.map { it.currency } - val selectedCurrency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION) + val selectedCurrency = SignalStore.inAppPayments.getRecurringDonationCurrency() if (selectedCurrency !in priceCurrencies) { Log.w(TAG, "Unsupported currency selection. Defaulting to USD. $selectedCurrency isn't supported.") val usd = PlatformCurrencyUtil.USD - val newSubscriber = InAppPaymentsRepository.getSubscriber(usd, InAppPaymentSubscriberRecord.Type.DONATION) ?: InAppPaymentSubscriberRecord( + val newSubscriber = InAppPaymentsRepository.getRecurringDonationSubscriber(usd) ?: InAppPaymentSubscriberRecord( subscriberId = SubscriberId.generate(), currency = usd, type = InAppPaymentSubscriberRecord.Type.DONATION, requiresCancel = false, - paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN + paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN, + iapSubscriptionId = null ) InAppPaymentsRepository.setSubscriber(newSubscriber) RecurringInAppPaymentRepository.syncAccountRecord().subscribe() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt index b5b38b1650..f3e5c43662 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt @@ -240,7 +240,7 @@ class InternalConversationSettingsFragment : DSLSettingsFragment( // TODO [alex] - DB on main thread! val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) val summary = if (subscriber != null) { - """currency code: ${subscriber.currency.currencyCode} + """currency code: ${subscriber.currency!!.currencyCode} |subscriber id: ${subscriber.subscriberId.serialize()} """.trimMargin() } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt index efafe6ceed..c939c405a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt @@ -13,17 +13,21 @@ import androidx.core.content.contentValuesOf import org.signal.core.util.DatabaseSerializer import org.signal.core.util.Serializer import org.signal.core.util.insertInto +import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log import org.signal.core.util.readToSingleObject import org.signal.core.util.requireBoolean import org.signal.core.util.requireInt +import org.signal.core.util.requireLongOrNull import org.signal.core.util.requireNonNullString +import org.signal.core.util.requireString import org.signal.core.util.select import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.subscriptions.SubscriberId import java.util.Currency @@ -61,7 +65,13 @@ class InAppPaymentSubscriberTable( /** Specifies which payment method was utilized for the latest transaction with this id */ private const val PAYMENT_METHOD_TYPE = "payment_method_type" - const val CREATE_TABLE = """ + /** Google Play Billing purchase token, only valid for backup payments */ + private const val PURCHASE_TOKEN = "purchase_token" + + /** iOS original transaction id token, only valid for synced backups that originated on iOS */ + private const val ORIGINAL_TRANSACTION_ID = "original_transaction_id" + + val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( $ID INTEGER PRIMARY KEY, $SUBSCRIBER_ID TEXT NOT NULL UNIQUE, @@ -69,7 +79,14 @@ class InAppPaymentSubscriberTable( $TYPE INTEGER NOT NULL, $REQUIRES_CANCEL INTEGER DEFAULT 0, $PAYMENT_METHOD_TYPE INTEGER DEFAULT 0, - UNIQUE($CURRENCY_CODE, $TYPE) + $PURCHASE_TOKEN TEXT, + $ORIGINAL_TRANSACTION_ID INTEGER, + UNIQUE($CURRENCY_CODE, $TYPE), + CHECK ( + ($CURRENCY_CODE != '' AND $PURCHASE_TOKEN IS NULL AND $ORIGINAL_TRANSACTION_ID IS NULL AND $TYPE = ${TypeSerializer.serialize(InAppPaymentSubscriberRecord.Type.DONATION)}) + OR ($CURRENCY_CODE = '' AND $PURCHASE_TOKEN IS NOT NULL AND $ORIGINAL_TRANSACTION_ID IS NULL AND $TYPE = ${TypeSerializer.serialize(InAppPaymentSubscriberRecord.Type.BACKUP)}) + OR ($CURRENCY_CODE = '' AND $PURCHASE_TOKEN IS NULL AND $ORIGINAL_TRANSACTION_ID IS NOT NULL AND $TYPE = ${TypeSerializer.serialize(InAppPaymentSubscriberRecord.Type.BACKUP)}) + ) ) """ } @@ -80,20 +97,31 @@ class InAppPaymentSubscriberTable( * This is a destructive, mutating operation. For setting specific values, prefer the alternative setters available on this table class. */ fun insertOrReplace(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord) { - Log.i(TAG, "Setting subscriber for currency ${inAppPaymentSubscriberRecord.currency.currencyCode}", Exception(), true) + if (inAppPaymentSubscriberRecord.type == InAppPaymentSubscriberRecord.Type.DONATION) { + Log.i(TAG, "Setting subscriber for currency ${inAppPaymentSubscriberRecord.currency?.currencyCode}", Exception(), true) + } writableDatabase.withinTransaction { db -> db.insertInto(TABLE_NAME) .values(InAppPaymentSubscriberSerializer.serialize(inAppPaymentSubscriberRecord)) .run(conflictStrategy = SQLiteDatabase.CONFLICT_REPLACE) - SignalStore.inAppPayments.setSubscriberCurrency( - inAppPaymentSubscriberRecord.currency, - inAppPaymentSubscriberRecord.type - ) + if (inAppPaymentSubscriberRecord.type == InAppPaymentSubscriberRecord.Type.DONATION) { + SignalStore.inAppPayments.setRecurringDonationCurrency( + inAppPaymentSubscriberRecord.currency!! + ) + } } } + fun getBackupsSubscriber(): InAppPaymentSubscriberRecord? { + return readableDatabase.select() + .from(TABLE_NAME) + .where("$TYPE = ?", TypeSerializer.serialize(InAppPaymentSubscriberRecord.Type.BACKUP)) + .run() + .readToSingleObject(InAppPaymentSubscriberSerializer) + } + /** * Sets whether the subscriber in question requires a cancellation before a new subscription can be created. */ @@ -117,10 +145,10 @@ class InAppPaymentSubscriberTable( /** * Retrieves a subscriber for the given type by the currency code. */ - fun getByCurrencyCode(currencyCode: String, type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? { + fun getByCurrencyCode(currencyCode: String): InAppPaymentSubscriberRecord? { return readableDatabase.select() .from(TABLE_NAME) - .where("$TYPE = ? AND $CURRENCY_CODE = ?", TypeSerializer.serialize(type), currencyCode.uppercase()) + .where("$CURRENCY_CODE = ?", currencyCode.uppercase()) .run() .readToSingleObject(InAppPaymentSubscriberSerializer) } @@ -140,10 +168,12 @@ class InAppPaymentSubscriberTable( override fun serialize(data: InAppPaymentSubscriberRecord): ContentValues { return contentValuesOf( SUBSCRIBER_ID to data.subscriberId.serialize(), - CURRENCY_CODE to data.currency.currencyCode.uppercase(), + CURRENCY_CODE to (data.currency?.currencyCode?.uppercase() ?: ""), TYPE to TypeSerializer.serialize(data.type), REQUIRES_CANCEL to data.requiresCancel, - PAYMENT_METHOD_TYPE to data.paymentMethodType.value + PAYMENT_METHOD_TYPE to data.paymentMethodType.value, + PURCHASE_TOKEN to data.iapSubscriptionId?.purchaseToken, + ORIGINAL_TRANSACTION_ID to data.iapSubscriptionId?.originalTransactionId ) } @@ -152,12 +182,36 @@ class InAppPaymentSubscriberTable( val currencyCode = input.requireNonNullString(CURRENCY_CODE).takeIf { it.isNotEmpty() } return InAppPaymentSubscriberRecord( subscriberId = SubscriberId.deserialize(input.requireNonNullString(SUBSCRIBER_ID)), - currency = currencyCode?.let { Currency.getInstance(it) } ?: SignalStore.inAppPayments.getSubscriptionCurrency(type), + currency = resolveCurrency(currencyCode, type), type = type, requiresCancel = input.requireBoolean(REQUIRES_CANCEL) || currencyCode.isNullOrBlank(), - paymentMethodType = InAppPaymentData.PaymentMethodType.fromValue(input.requireInt(PAYMENT_METHOD_TYPE)) ?: InAppPaymentData.PaymentMethodType.UNKNOWN + paymentMethodType = InAppPaymentData.PaymentMethodType.fromValue(input.requireInt(PAYMENT_METHOD_TYPE)) ?: InAppPaymentData.PaymentMethodType.UNKNOWN, + iapSubscriptionId = readIAPSubscriptionIdFromCursor(input) ) } + + private fun resolveCurrency(currencyCode: String?, type: InAppPaymentSubscriberRecord.Type): Currency? { + return currencyCode?.let { + Currency.getInstance(currencyCode) + } ?: if (type == InAppPaymentSubscriberRecord.Type.DONATION) { + SignalStore.inAppPayments.getRecurringDonationCurrency() + } else { + null + } + } + + private fun readIAPSubscriptionIdFromCursor(cursor: Cursor): IAPSubscriptionId? { + val purchaseToken = cursor.requireString(PURCHASE_TOKEN) + val originalTransactionId = cursor.requireLongOrNull(ORIGINAL_TRANSACTION_ID) + + return if (purchaseToken.isNotNullOrBlank()) { + IAPSubscriptionId.GooglePlayBillingPurchaseToken(purchaseToken) + } else if (originalTransactionId != null) { + IAPSubscriptionId.AppleIAPOriginalTransactionId(originalTransactionId) + } else { + null + } + } } object TypeSerializer : Serializer { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index df3bf1de81..7bdb3c7a34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -118,6 +118,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V258_FixGroupRevoke import org.thoughtcrime.securesms.database.helpers.migration.V259_AdjustNotificationProfileMidnightEndTimes import org.thoughtcrime.securesms.database.helpers.migration.V260_RemapQuoteAuthors import org.thoughtcrime.securesms.database.helpers.migration.V261_RemapCallRingers +import org.thoughtcrime.securesms.database.helpers.migration.V262_InAppPaymentsSubscriberTableRebuild /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -238,10 +239,11 @@ object SignalDatabaseMigrations { 258 to V258_FixGroupRevokedInviteeUpdate, 259 to V259_AdjustNotificationProfileMidnightEndTimes, 260 to V260_RemapQuoteAuthors, - 261 to V261_RemapCallRingers + 261 to V261_RemapCallRingers, + 261 to V262_InAppPaymentsSubscriberTableRebuild ) - const val DATABASE_VERSION = 261 + const val DATABASE_VERSION = 262 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V262_InAppPaymentsSubscriberTableRebuild.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V262_InAppPaymentsSubscriberTableRebuild.kt new file mode 100644 index 0000000000..81fae4ec59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V262_InAppPaymentsSubscriberTableRebuild.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Adds IAP fields and updates constraints. + */ +@Suppress("ClassName") +object V262_InAppPaymentsSubscriberTableRebuild : SignalDatabaseMigration { + + private const val DONOR_TYPE = 0 + private const val BACKUP_TYPE = 1 + + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL( + """ + CREATE TABLE in_app_payment_subscriber_tmp ( + _id INTEGER PRIMARY KEY, + subscriber_id TEXT NOT NULL UNIQUE, + currency_code TEXT NOT NULL, + type INTEGER NOT NULL, + requires_cancel INTEGER DEFAULT 0, + payment_method_type INTEGER DEFAULT 0, + purchase_token TEXT, + original_transaction_id INTEGER, + UNIQUE(currency_code, type), + CHECK ( + (currency_code != '' AND purchase_token IS NULL AND original_transaction_id IS NULL AND type = $DONOR_TYPE) + OR (currency_code = '' AND purchase_token IS NOT NULL AND original_transaction_id IS NULL AND type = $BACKUP_TYPE) + OR (currency_code = '' AND purchase_token IS NULL AND original_transaction_id IS NOT NULL AND type = $BACKUP_TYPE) + ) + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO in_app_payment_subscriber_tmp (_id, subscriber_id, currency_code, type, requires_cancel, payment_method_type, purchase_token) + SELECT + _id, + subscriber_id, + CASE + WHEN type = $DONOR_TYPE THEN currency_code + ELSE '' + END, + type, + requires_cancel, + payment_method_type, + CASE + WHEN type = $BACKUP_TYPE THEN "-" + ELSE NULL + END + FROM in_app_payment_subscriber + """.trimIndent() + ) + + db.execSQL("DROP TABLE in_app_payment_subscriber") + + db.execSQL("ALTER TABLE in_app_payment_subscriber_tmp RENAME TO in_app_payment_subscriber") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt index d312683597..77bbf433bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.database.model import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.subscriptions.SubscriberId import java.util.Currency import java.util.concurrent.locks.Lock @@ -18,10 +19,11 @@ import java.util.concurrent.locks.ReentrantLock */ data class InAppPaymentSubscriberRecord( val subscriberId: SubscriberId, - val currency: Currency, val type: Type, val requiresCancel: Boolean, - val paymentMethodType: InAppPaymentData.PaymentMethodType + val paymentMethodType: InAppPaymentData.PaymentMethodType, + val currency: Currency?, + val iapSubscriptionId: IAPSubscriptionId? ) { /** * Serves as the mutex by which to perform mutations to subscriptions. diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt index 096a11395d..913cd675e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt @@ -248,7 +248,7 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas val updateLevelResponse = AppDependencies.donationsService.updateSubscriptionLevel( subscriber.subscriberId, level, - subscriber.currency.currencyCode, + subscriber.currency!!.currencyCode, updateOperation.idempotencyKey.serialize(), subscriber.type.lock ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt index 66e3421db8..9cb1c9fc1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt @@ -244,7 +244,7 @@ class InAppPaymentKeepAliveJob private constructor( paymentMethodType = subscriber.paymentMethodType, badge = badge, amount = FiatValue( - currencyCode = subscriber.currency.currencyCode, + currencyCode = subscription.currency, amount = subscription.amount.toDecimalValue() ), error = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt index c7c62d31d4..fee869175d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -309,15 +309,25 @@ class InAppPaymentRecurringContextJob private constructor( } private fun handlePaymentFailure(inAppPayment: InAppPaymentTable.InAppPayment, subscription: Subscription, chargeFailure: ChargeFailure?) { - SignalDatabase.inAppPaymentSubscribers.insertOrReplace( - InAppPaymentSubscriberRecord( - subscriberId = inAppPayment.subscriberId!!, - currency = Currency.getInstance(inAppPayment.data.amount!!.currencyCode), - type = inAppPayment.type.requireSubscriberType(), - requiresCancel = true, - paymentMethodType = inAppPayment.data.paymentMethodType + val record = SignalDatabase.inAppPaymentSubscribers.getBySubscriberId(subscriberId = inAppPayment.subscriberId!!) + if (record == null) { + warning("Could not find subscriber record in local database. Building from payment data.") + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + InAppPaymentSubscriberRecord( + subscriberId = inAppPayment.subscriberId, + currency = if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) null else Currency.getInstance(inAppPayment.data.amount!!.currencyCode), + type = inAppPayment.type.requireSubscriberType(), + requiresCancel = true, + paymentMethodType = inAppPayment.data.paymentMethodType, + iapSubscriptionId = null + ) ) - ) + } else { + info("Marking requiresCancel as true in subscriber record due to payment failure.") + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + record.copy(requiresCancel = true) + ) + } if (inAppPayment.data.redemption?.keepAlive == true) { info("Cancellation occurred during keep-alive. Setting cancellation state.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index d09c722380..695b61d57f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob; import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob; import org.thoughtcrime.securesms.migrations.EmojiDownloadMigrationJob; import org.thoughtcrime.securesms.migrations.EmojiSearchIndexCheckMigrationJob; +import org.thoughtcrime.securesms.migrations.GooglePlayBillingPurchaseTokenMigrationJob; import org.thoughtcrime.securesms.migrations.IdentityTableCleanupMigrationJob; import org.thoughtcrime.securesms.migrations.LegacyMigrationJob; import org.thoughtcrime.securesms.migrations.MigrationCompleteJob; @@ -263,68 +264,69 @@ public final class JobManagerFactories { put(UploadAttachmentToArchiveJob.KEY, new UploadAttachmentToArchiveJob.Factory()); // Migrations - put(AccountConsistencyMigrationJob.KEY, new AccountConsistencyMigrationJob.Factory()); - put(AccountRecordMigrationJob.KEY, new AccountRecordMigrationJob.Factory()); - put(AepMigrationJob.KEY, new AepMigrationJob.Factory()); - put(ApplyUnknownFieldsToSelfMigrationJob.KEY, new ApplyUnknownFieldsToSelfMigrationJob.Factory()); - put(AttachmentCleanupMigrationJob.KEY, new AttachmentCleanupMigrationJob.Factory()); - put(AttachmentHashBackfillMigrationJob.KEY, new AttachmentHashBackfillMigrationJob.Factory()); - put(AttributesMigrationJob.KEY, new AttributesMigrationJob.Factory()); - put(AvatarIdRemovalMigrationJob.KEY, new AvatarIdRemovalMigrationJob.Factory()); - put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory()); - put(BackfillDigestsMigrationJob.KEY, new BackfillDigestsMigrationJob.Factory()); - put(BackfillDigestsForDuplicatesMigrationJob.KEY, new BackfillDigestsForDuplicatesMigrationJob.Factory()); - put(BackupJitterMigrationJob.KEY, new BackupJitterMigrationJob.Factory()); - put(BackupMediaSnapshotSyncJob.KEY, new BackupMediaSnapshotSyncJob.Factory()); - put(BackupNotificationMigrationJob.KEY, new BackupNotificationMigrationJob.Factory()); - put(BackupRefreshJob.KEY, new BackupRefreshJob.Factory()); - put(BadE164MigrationJob.KEY, new BadE164MigrationJob.Factory()); - put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory()); - put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory()); - put(ClearGlideCacheMigrationJob.KEY, new ClearGlideCacheMigrationJob.Factory()); - put(ContactLinkRebuildMigrationJob.KEY, new ContactLinkRebuildMigrationJob.Factory()); - put(CopyUsernameToSignalStoreMigrationJob.KEY, new CopyUsernameToSignalStoreMigrationJob.Factory()); - put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory()); - put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory()); - put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory()); - put(EmojiDownloadMigrationJob.KEY, new EmojiDownloadMigrationJob.Factory()); - put(EmojiSearchIndexCheckMigrationJob.KEY, new EmojiSearchIndexCheckMigrationJob.Factory()); - put(IdentityTableCleanupMigrationJob.KEY, new IdentityTableCleanupMigrationJob.Factory()); - put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory()); - put(MigrationCompleteJob.KEY, new MigrationCompleteJob.Factory()); - put(OptimizeMessageSearchIndexMigrationJob.KEY, new OptimizeMessageSearchIndexMigrationJob.Factory()); - put(PinOptOutMigration.KEY, new PinOptOutMigration.Factory()); - put(PinReminderMigrationJob.KEY, new PinReminderMigrationJob.Factory()); - put(PniAccountInitializationMigrationJob.KEY, new PniAccountInitializationMigrationJob.Factory()); - put(PniMigrationJob.KEY, new PniMigrationJob.Factory()); - put(PnpLaunchMigrationJob.KEY, new PnpLaunchMigrationJob.Factory()); - put(PreKeysSyncMigrationJob.KEY, new PreKeysSyncMigrationJob.Factory()); - put(ProfileMigrationJob.KEY, new ProfileMigrationJob.Factory()); - put(ProfileSharingUpdateMigrationJob.KEY, new ProfileSharingUpdateMigrationJob.Factory()); - put(RebuildMessageSearchIndexMigrationJob.KEY, new RebuildMessageSearchIndexMigrationJob.Factory()); - put(RecheckPaymentsMigrationJob.KEY, new RecheckPaymentsMigrationJob.Factory()); - put(RecipientSearchMigrationJob.KEY, new RecipientSearchMigrationJob.Factory()); - put(SelfRegisteredStateMigrationJob.KEY, new SelfRegisteredStateMigrationJob.Factory()); - put(StickerLaunchMigrationJob.KEY, new StickerLaunchMigrationJob.Factory()); - put(StickerAdditionMigrationJob.KEY, new StickerAdditionMigrationJob.Factory()); - put(StickerDayByDayMigrationJob.KEY, new StickerDayByDayMigrationJob.Factory()); - put(StickerMyDailyLifeMigrationJob.KEY, new StickerMyDailyLifeMigrationJob.Factory()); - put(StorageCapabilityMigrationJob.KEY, new StorageCapabilityMigrationJob.Factory()); - put(StorageFixLocalUnknownMigrationJob.KEY, new StorageFixLocalUnknownMigrationJob.Factory()); - put(StorageServiceMigrationJob.KEY, new StorageServiceMigrationJob.Factory()); - put(StorageServiceSystemNameMigrationJob.KEY, new StorageServiceSystemNameMigrationJob.Factory()); - put(StoryViewedReceiptsStateMigrationJob.KEY, new StoryViewedReceiptsStateMigrationJob.Factory()); - put(SubscriberIdMigrationJob.KEY, new SubscriberIdMigrationJob.Factory()); - put(Svr2MirrorMigrationJob.KEY, new Svr2MirrorMigrationJob.Factory()); - put(SyncCallLinksMigrationJob.KEY, new SyncCallLinksMigrationJob.Factory()); - put(SyncDistributionListsMigrationJob.KEY, new SyncDistributionListsMigrationJob.Factory()); - put(SyncKeysMigrationJob.KEY, new SyncKeysMigrationJob.Factory()); - put(TrimByLengthSettingsMigrationJob.KEY, new TrimByLengthSettingsMigrationJob.Factory()); - put(UpdateSmsJobsMigrationJob.KEY, new UpdateSmsJobsMigrationJob.Factory()); - put(UserNotificationMigrationJob.KEY, new UserNotificationMigrationJob.Factory()); - put(UuidMigrationJob.KEY, new UuidMigrationJob.Factory()); - put(WallpaperCleanupMigrationJob.KEY, new WallpaperCleanupMigrationJob.Factory()); - put(WallpaperStorageMigrationJob.KEY, new WallpaperStorageMigrationJob.Factory()); + put(AccountConsistencyMigrationJob.KEY, new AccountConsistencyMigrationJob.Factory()); + put(AccountRecordMigrationJob.KEY, new AccountRecordMigrationJob.Factory()); + put(AepMigrationJob.KEY, new AepMigrationJob.Factory()); + put(ApplyUnknownFieldsToSelfMigrationJob.KEY, new ApplyUnknownFieldsToSelfMigrationJob.Factory()); + put(AttachmentCleanupMigrationJob.KEY, new AttachmentCleanupMigrationJob.Factory()); + put(AttachmentHashBackfillMigrationJob.KEY, new AttachmentHashBackfillMigrationJob.Factory()); + put(AttributesMigrationJob.KEY, new AttributesMigrationJob.Factory()); + put(AvatarIdRemovalMigrationJob.KEY, new AvatarIdRemovalMigrationJob.Factory()); + put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory()); + put(BackfillDigestsMigrationJob.KEY, new BackfillDigestsMigrationJob.Factory()); + put(BackfillDigestsForDuplicatesMigrationJob.KEY, new BackfillDigestsForDuplicatesMigrationJob.Factory()); + put(BackupJitterMigrationJob.KEY, new BackupJitterMigrationJob.Factory()); + put(BackupMediaSnapshotSyncJob.KEY, new BackupMediaSnapshotSyncJob.Factory()); + put(BackupNotificationMigrationJob.KEY, new BackupNotificationMigrationJob.Factory()); + put(BackupRefreshJob.KEY, new BackupRefreshJob.Factory()); + put(BadE164MigrationJob.KEY, new BadE164MigrationJob.Factory()); + put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory()); + put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory()); + put(ClearGlideCacheMigrationJob.KEY, new ClearGlideCacheMigrationJob.Factory()); + put(ContactLinkRebuildMigrationJob.KEY, new ContactLinkRebuildMigrationJob.Factory()); + put(CopyUsernameToSignalStoreMigrationJob.KEY, new CopyUsernameToSignalStoreMigrationJob.Factory()); + put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory()); + put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory()); + put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory()); + put(EmojiDownloadMigrationJob.KEY, new EmojiDownloadMigrationJob.Factory()); + put(EmojiSearchIndexCheckMigrationJob.KEY, new EmojiSearchIndexCheckMigrationJob.Factory()); + put(GooglePlayBillingPurchaseTokenMigrationJob.KEY, new GooglePlayBillingPurchaseTokenMigrationJob.Factory()); + put(IdentityTableCleanupMigrationJob.KEY, new IdentityTableCleanupMigrationJob.Factory()); + put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory()); + put(MigrationCompleteJob.KEY, new MigrationCompleteJob.Factory()); + put(OptimizeMessageSearchIndexMigrationJob.KEY, new OptimizeMessageSearchIndexMigrationJob.Factory()); + put(PinOptOutMigration.KEY, new PinOptOutMigration.Factory()); + put(PinReminderMigrationJob.KEY, new PinReminderMigrationJob.Factory()); + put(PniAccountInitializationMigrationJob.KEY, new PniAccountInitializationMigrationJob.Factory()); + put(PniMigrationJob.KEY, new PniMigrationJob.Factory()); + put(PnpLaunchMigrationJob.KEY, new PnpLaunchMigrationJob.Factory()); + put(PreKeysSyncMigrationJob.KEY, new PreKeysSyncMigrationJob.Factory()); + put(ProfileMigrationJob.KEY, new ProfileMigrationJob.Factory()); + put(ProfileSharingUpdateMigrationJob.KEY, new ProfileSharingUpdateMigrationJob.Factory()); + put(RebuildMessageSearchIndexMigrationJob.KEY, new RebuildMessageSearchIndexMigrationJob.Factory()); + put(RecheckPaymentsMigrationJob.KEY, new RecheckPaymentsMigrationJob.Factory()); + put(RecipientSearchMigrationJob.KEY, new RecipientSearchMigrationJob.Factory()); + put(SelfRegisteredStateMigrationJob.KEY, new SelfRegisteredStateMigrationJob.Factory()); + put(StickerLaunchMigrationJob.KEY, new StickerLaunchMigrationJob.Factory()); + put(StickerAdditionMigrationJob.KEY, new StickerAdditionMigrationJob.Factory()); + put(StickerDayByDayMigrationJob.KEY, new StickerDayByDayMigrationJob.Factory()); + put(StickerMyDailyLifeMigrationJob.KEY, new StickerMyDailyLifeMigrationJob.Factory()); + put(StorageCapabilityMigrationJob.KEY, new StorageCapabilityMigrationJob.Factory()); + put(StorageFixLocalUnknownMigrationJob.KEY, new StorageFixLocalUnknownMigrationJob.Factory()); + put(StorageServiceMigrationJob.KEY, new StorageServiceMigrationJob.Factory()); + put(StorageServiceSystemNameMigrationJob.KEY, new StorageServiceSystemNameMigrationJob.Factory()); + put(StoryViewedReceiptsStateMigrationJob.KEY, new StoryViewedReceiptsStateMigrationJob.Factory()); + put(SubscriberIdMigrationJob.KEY, new SubscriberIdMigrationJob.Factory()); + put(Svr2MirrorMigrationJob.KEY, new Svr2MirrorMigrationJob.Factory()); + put(SyncCallLinksMigrationJob.KEY, new SyncCallLinksMigrationJob.Factory()); + put(SyncDistributionListsMigrationJob.KEY, new SyncDistributionListsMigrationJob.Factory()); + put(SyncKeysMigrationJob.KEY, new SyncKeysMigrationJob.Factory()); + put(TrimByLengthSettingsMigrationJob.KEY, new TrimByLengthSettingsMigrationJob.Factory()); + put(UpdateSmsJobsMigrationJob.KEY, new UpdateSmsJobsMigrationJob.Factory()); + put(UserNotificationMigrationJob.KEY, new UserNotificationMigrationJob.Factory()); + put(UuidMigrationJob.KEY, new UuidMigrationJob.Factory()); + put(WallpaperCleanupMigrationJob.KEY, new WallpaperCleanupMigrationJob.Factory()); + put(WallpaperStorageMigrationJob.KEY, new WallpaperStorageMigrationJob.Factory()); // Dead jobs put(FailingJob.KEY, new FailingJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt index ebd8316962..ed1fc9e3e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt @@ -45,7 +45,6 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor private val TAG = Log.tag(InAppPaymentValues::class.java) private const val KEY_DONATION_SUBSCRIPTION_CURRENCY_CODE = "donation.currency.code" - private const val KEY_BACKUPS_SUBSCRIPTION_CURRENCY_CODE = "donation.backups.currency.code" private const val KEY_CURRENCY_CODE_ONE_TIME = "donation.currency.code.boost" private const val KEY_SUBSCRIBER_ID_PREFIX = "donation.subscriber.id." private const val KEY_LAST_KEEP_ALIVE_LAUNCH = "donation.last.successful.ping" @@ -164,12 +163,9 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor SUBSCRIPTION_PAYMENT_SOURCE_TYPE ) - private val recurringDonationCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION)) } + private val recurringDonationCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getRecurringDonationCurrency()) } val observableRecurringDonationCurrency: Observable by lazy { recurringDonationCurrencyPublisher } - private val recurringBackupCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)) } - val observableRecurringBackupsCurrency: Observable by lazy { recurringBackupCurrencyPublisher } - private val oneTimeCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getOneTimeCurrency()) } val observableOneTimeCurrency: Observable by lazy { oneTimeCurrencyPublisher } @@ -185,12 +181,8 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor } } - fun getSubscriptionCurrency(subscriberType: InAppPaymentSubscriberRecord.Type): Currency { - val currencyCode = if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) { - getString(KEY_DONATION_SUBSCRIPTION_CURRENCY_CODE, null) - } else { - getString(KEY_BACKUPS_SUBSCRIPTION_CURRENCY_CODE, null) - } + fun getRecurringDonationCurrency(): Currency { + val currencyCode = getString(KEY_DONATION_SUBSCRIPTION_CURRENCY_CODE, null) val currency: Currency? = if (currencyCode == null) { val localeCurrency = CurrencyUtil.getCurrencyByLocale(Locale.getDefault()) @@ -218,7 +210,7 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor fun getOneTimeCurrency(): Currency { val oneTimeCurrency = getString(KEY_CURRENCY_CODE_ONE_TIME, null) return if (oneTimeCurrency == null) { - val currency = getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION) + val currency = getRecurringDonationCurrency() setOneTimeCurrency(currency) currency } else { @@ -247,29 +239,22 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor null } else { InAppPaymentSubscriberRecord( - SubscriberId.fromBytes(subscriberIdBytes), - currency, - InAppPaymentSubscriberRecord.Type.DONATION, - shouldCancelSubscriptionBeforeNextSubscribeAttempt, - getSubscriptionPaymentSourceType().toPaymentMethodType() + subscriberId = SubscriberId.fromBytes(subscriberIdBytes), + currency = currency, + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = shouldCancelSubscriptionBeforeNextSubscribeAttempt, + paymentMethodType = getSubscriptionPaymentSourceType().toPaymentMethodType(), + iapSubscriptionId = null ) } } - fun setSubscriberCurrency(currency: Currency, type: InAppPaymentSubscriberRecord.Type) { - if (type == InAppPaymentSubscriberRecord.Type.DONATION) { - store.beginWrite() - .putString(KEY_DONATION_SUBSCRIPTION_CURRENCY_CODE, currency.currencyCode) - .apply() + fun setRecurringDonationCurrency(currency: Currency) { + store.beginWrite() + .putString(KEY_DONATION_SUBSCRIPTION_CURRENCY_CODE, currency.currencyCode) + .apply() - recurringDonationCurrencyPublisher.onNext(currency) - } else { - store.beginWrite() - .putString(KEY_BACKUPS_SUBSCRIPTION_CURRENCY_CODE, currency.currencyCode) - .apply() - - recurringBackupCurrencyPublisher.onNext(currency) - } + recurringDonationCurrencyPublisher.onNext(currency) } fun getLevelOperation(level: String): LevelUpdateOperation? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index bb7dedd3db..225a105b4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -165,9 +165,10 @@ public class ApplicationMigrations { static final int EMOJI_SEARCH_INDEX_CHECK_2 = 121; static final int QUOTE_AUTHOR_FIX = 122; static final int BAD_E164_FIX = 123; + static final int GPB_TOKEN_MIGRATION = 124; } - public static final int CURRENT_VERSION = 123; + public static final int CURRENT_VERSION = 124; /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call @@ -758,6 +759,10 @@ public class ApplicationMigrations { jobs.put(Version.BAD_E164_FIX, new BadE164MigrationJob()); } + if (lastSeenVersion < Version.GPB_TOKEN_MIGRATION) { + jobs.put(Version.GPB_TOKEN_MIGRATION, new GooglePlayBillingPurchaseTokenMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJob.kt new file mode 100644 index 0000000000..6dac2e59b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJob.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.migrations + +import kotlinx.coroutines.runBlocking +import okio.IOException +import org.signal.core.util.billing.BillingPurchaseResult +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +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 + +/** + * When we migrate subscriptions, purchase tokens are stored as '-' string. This migration + * goes in and updates that purchase token with the real value from the latest subscription, if + * available. + */ +internal class GooglePlayBillingPurchaseTokenMigrationJob private constructor( + parameters: Parameters +) : MigrationJob(parameters) { + + constructor() : this( + Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .build() + ) + + companion object { + private val TAG = Log.tag(GooglePlayBillingPurchaseTokenMigrationJob::class) + + const val KEY = "GooglePlayBillingPurchaseTokenMigrationJob" + } + + override fun getFactoryKey(): String = KEY + + override fun isUiBlocking(): Boolean = false + + override fun performMigration() { + if (!SignalStore.account.isRegistered) { + return + } + + val backupSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) ?: return + + if (backupSubscriber.iapSubscriptionId?.purchaseToken == "-") { + val purchaseResult: BillingPurchaseResult.Success? = runBlocking { + if (AppDependencies.billingApi.isApiAvailable()) { + val purchase = AppDependencies.billingApi.queryPurchases() + + if (purchase is BillingPurchaseResult.Success) { + Log.d(TAG, "Successfully found purchase result.") + purchase + } else { + Log.d(TAG, "No purchase was available.") + null + } + } else { + Log.d(TAG, "Billing API is not available.") + null + } + } + + if (purchaseResult == null) { + return + } + + InAppPaymentsRepository.setSubscriber( + backupSubscriber.copy( + iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(purchaseToken = purchaseResult.purchaseToken) + ) + ) + + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + } + } + + override fun shouldRetry(e: Exception): Boolean { + Log.w(TAG, "Checking retry state for exception.", e) + return e is IOException + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): GooglePlayBillingPurchaseTokenMigrationJob { + return GooglePlayBillingPurchaseTokenMigrationJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJob.kt index 14c0a99e4d..13ba7720a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJob.kt @@ -36,11 +36,12 @@ internal class SubscriberIdMigrationJob( if (subscriber != null) { SignalDatabase.inAppPaymentSubscribers.insertOrReplace( InAppPaymentSubscriberRecord( - subscriber.subscriberId, - subscriber.currency, - InAppPaymentSubscriberRecord.Type.DONATION, - SignalStore.inAppPayments.shouldCancelSubscriptionBeforeNextSubscribeAttempt, - SignalStore.inAppPayments.getSubscriptionPaymentSourceType().toPaymentMethodType() + subscriberId = subscriber.subscriberId, + currency = subscriber.currency, + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = SignalStore.inAppPayments.shouldCancelSubscriptionBeforeNextSubscribeAttempt, + paymentMethodType = SignalStore.inAppPayments.getSubscriptionPaymentSourceType().toPaymentMethodType(), + iapSubscriptionId = null ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt index 3a0c516b24..becd97c41c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt @@ -8,6 +8,7 @@ import org.signal.core.util.nullIfEmpty import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.storage.StorageSyncHelper.applyAccountStorageSyncUpdates +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.storage.SignalAccountRecord import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.api.storage.safeSetBackupsSubscriber @@ -88,14 +89,15 @@ class AccountRecordProcessor( } val backupsSubscriberId: ByteString - val backupsSubscriberCurrencyCode: String + val backupsPurchaseToken: IAPSubscriptionId? - if (remote.proto.backupsSubscriberId.isNotEmpty()) { - backupsSubscriberId = remote.proto.backupsSubscriberId - backupsSubscriberCurrencyCode = remote.proto.backupsSubscriberCurrencyCode + val remoteBackupSubscriberData = remote.proto.backupSubscriberData + if (remoteBackupSubscriberData != null && remoteBackupSubscriberData.subscriberId.isNotEmpty()) { + backupsSubscriberId = remoteBackupSubscriberData.subscriberId + backupsPurchaseToken = IAPSubscriptionId.from(remoteBackupSubscriberData) } else { - backupsSubscriberId = local.proto.backupsSubscriberId - backupsSubscriberCurrencyCode = remote.proto.backupsSubscriberCurrencyCode + backupsSubscriberId = local.proto.backupSubscriberData?.subscriberId ?: ByteString.EMPTY + backupsPurchaseToken = IAPSubscriptionId.from(local.proto.backupSubscriberData) } val storyViewReceiptsState = if (remote.proto.storyViewReceiptsEnabled == OptionalBool.UNSET) { @@ -139,7 +141,7 @@ class AccountRecordProcessor( safeSetPayments(payments?.enabled == true, payments?.entropy?.toByteArray()) safeSetSubscriber(donationSubscriberId, donationSubscriberCurrencyCode) - safeSetBackupsSubscriber(backupsSubscriberId, backupsSubscriberCurrencyCode) + safeSetBackupsSubscriber(backupsSubscriberId, backupsPurchaseToken) }.toSignalAccountRecord(StorageId.forAccount(keyGenerator.generate())) return if (doParamsMatch(remote, merged)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt index 2ad49b9bc5..67c3e16ea1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt @@ -172,11 +172,11 @@ object StorageSyncHelper { } getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)?.let { - safeSetSubscriber(it.subscriberId.bytes.toByteString(), it.currency.currencyCode) + safeSetSubscriber(it.subscriberId.bytes.toByteString(), it.currency?.currencyCode ?: "") } getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP)?.let { - safeSetBackupsSubscriber(it.subscriberId.bytes.toByteString(), it.currency.currencyCode) + safeSetBackupsSubscriber(it.subscriberId.bytes.toByteString(), it.iapSubscriptionId) } safeSetPayments(SignalStore.payments.mobileCoinPaymentsEnabled(), Optional.ofNullable(SignalStore.payments.paymentsEntropy).map { obj: Entropy -> obj.bytes }.orElse(null)) @@ -219,11 +219,16 @@ object StorageSyncHelper { SignalStore.story.viewedReceiptsEnabled = update.new.proto.storyViewReceiptsEnabled == OptionalBool.ENABLED } - val remoteSubscriber = StorageSyncModels.remoteToLocalSubscriber(update.new.proto.subscriberId, update.new.proto.subscriberCurrencyCode, InAppPaymentSubscriberRecord.Type.DONATION) + val remoteSubscriber = StorageSyncModels.remoteToLocalDonorSubscriber(update.new.proto.subscriberId, update.new.proto.subscriberCurrencyCode) if (remoteSubscriber != null) { setSubscriber(remoteSubscriber) } + val remoteBackupsSubscriber = StorageSyncModels.remoteToLocalBackupSubscriber(update.new.proto.backupSubscriberData) + if (remoteBackupsSubscriber != null) { + setSubscriber(remoteBackupsSubscriber) + } + if (update.new.proto.subscriptionManuallyCancelled && !update.old.proto.subscriptionManuallyCancelled) { SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt index c0df1e911f..ab6b7481c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.storage import okio.ByteString import okio.ByteString.Companion.toByteString import org.signal.core.util.isNotEmpty +import org.signal.core.util.isNullOrEmpty import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme import org.thoughtcrime.securesms.database.GroupTable.ShowAsStoryState @@ -18,6 +19,7 @@ import org.thoughtcrime.securesms.database.model.RecipientRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord import org.whispersystems.signalservice.api.storage.SignalContactRecord import org.whispersystems.signalservice.api.storage.SignalGroupV1Record @@ -283,10 +285,32 @@ object StorageSyncModels { } } - fun remoteToLocalSubscriber( + fun remoteToLocalBackupSubscriber( + iapData: AccountRecord.IAPSubscriberData? + ): InAppPaymentSubscriberRecord? { + if (iapData == null || iapData.subscriberId.isNullOrEmpty()) { + return null + } + + val subscriberId = SubscriberId.fromBytes(iapData.subscriberId.toByteArray()) + val localSubscriberRecord = inAppPaymentSubscribers.getBySubscriberId(subscriberId) + val requiresCancel = localSubscriberRecord != null && localSubscriberRecord.requiresCancel + val paymentMethodType = localSubscriberRecord?.paymentMethodType ?: InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING + val iapSubscriptionId = IAPSubscriptionId.from(iapData) ?: return null + + return InAppPaymentSubscriberRecord( + subscriberId = subscriberId, + currency = null, + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = requiresCancel, + paymentMethodType = paymentMethodType, + iapSubscriptionId = iapSubscriptionId + ) + } + + fun remoteToLocalDonorSubscriber( subscriberId: ByteString, - subscriberCurrencyCode: String, - type: InAppPaymentSubscriberRecord.Type + subscriberCurrencyCode: String ): InAppPaymentSubscriberRecord? { if (subscriberId.isNotEmpty()) { val subscriberId = SubscriberId.fromBytes(subscriberId.toByteArray()) @@ -305,7 +329,14 @@ object StorageSyncModels { } } - return InAppPaymentSubscriberRecord(subscriberId, currency, type, requiresCancel, paymentMethodType) + return InAppPaymentSubscriberRecord( + subscriberId = subscriberId, + currency = currency, + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = requiresCancel, + paymentMethodType = paymentMethodType, + iapSubscriptionId = null + ) } else { return null } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt index a7bd54605f..1eb069b9fb 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt @@ -81,7 +81,7 @@ class RecurringInAppPaymentRepositoryTest { mockkObject(SignalStore.Companion) every { SignalStore.Companion.inAppPayments } returns mockk { - every { SignalStore.Companion.inAppPayments.getSubscriptionCurrency(any()) } returns Currency.getInstance("USD") + every { SignalStore.Companion.inAppPayments.getRecurringDonationCurrency() } returns Currency.getInstance("USD") every { SignalStore.Companion.inAppPayments.updateLocalStateForManualCancellation(any()) } returns Unit every { SignalStore.Companion.inAppPayments.updateLocalStateForLocalSubscribe(any()) } returns Unit } @@ -285,7 +285,8 @@ class RecurringInAppPaymentRepositoryTest { currency = Currency.getInstance("USD"), type = InAppPaymentSubscriberRecord.Type.DONATION, requiresCancel = false, - paymentMethodType = InAppPaymentData.PaymentMethodType.CARD + paymentMethodType = InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = null ) } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/AccountRecordExtensions.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/AccountRecordExtensions.kt index 15fbc29af1..5134f1bb18 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/AccountRecordExtensions.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/AccountRecordExtensions.kt @@ -8,9 +8,11 @@ package org.whispersystems.signalservice.api.storage import okio.ByteString import okio.ByteString.Companion.toByteString import org.signal.core.util.isNotEmpty +import org.signal.core.util.isNotNullOrBlank import org.whispersystems.signalservice.api.payments.PaymentsConstants import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.api.storage.IAPSubscriptionId.Companion.isNotNullOrBlank import org.whispersystems.signalservice.api.storage.StorageRecordProtoUtil.defaultAccountRecord import org.whispersystems.signalservice.internal.storage.protos.AccountRecord import org.whispersystems.signalservice.internal.storage.protos.Payments @@ -41,13 +43,15 @@ fun AccountRecord.Builder.safeSetSubscriber(subscriberId: ByteString, subscriber return this } -fun AccountRecord.Builder.safeSetBackupsSubscriber(subscriberId: ByteString, subscriberCurrencyCode: String): AccountRecord.Builder { - if (subscriberId.isNotEmpty() && subscriberId.size == 32 && subscriberCurrencyCode.isNotBlank()) { - this.backupsSubscriberId = subscriberId - this.backupsSubscriberCurrencyCode = subscriberCurrencyCode +fun AccountRecord.Builder.safeSetBackupsSubscriber(subscriberId: ByteString, iapSubscriptionId: IAPSubscriptionId?): AccountRecord.Builder { + if (subscriberId.isNotEmpty() && subscriberId.size == 32 && iapSubscriptionId.isNotNullOrBlank()) { + this.backupSubscriberData = AccountRecord.IAPSubscriberData( + subscriberId = subscriberId, + purchaseToken = iapSubscriptionId.purchaseToken, + originalTransactionId = iapSubscriptionId.originalTransactionId + ) } else { - this.backupsSubscriberId = defaultAccountRecord.backupsSubscriberId - this.backupsSubscriberCurrencyCode = defaultAccountRecord.backupsSubscriberCurrencyCode + this.backupSubscriberData = defaultAccountRecord.backupSubscriberData } return this diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/IAPSubscriptionId.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/IAPSubscriptionId.kt new file mode 100644 index 0000000000..8688f72b66 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/IAPSubscriptionId.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.storage + +import org.signal.core.util.isNotNullOrBlank +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +/** + * Represents an In-app purchase subscription id, whose form depends on what platform the subscription originated on. + */ +sealed class IAPSubscriptionId(open val purchaseToken: String?, open val originalTransactionId: Long?) { + /** + * Represents a Google Play Billing subscription, identified by the purchase token. + */ + data class GooglePlayBillingPurchaseToken(override val purchaseToken: String) : IAPSubscriptionId(purchaseToken, null) + + /** + * Represents an Apple IAP subscription, identified by the original transaction id. + */ + data class AppleIAPOriginalTransactionId(override val originalTransactionId: Long) : IAPSubscriptionId(null, originalTransactionId) + + companion object { + /** + * Checks the given proto for valid IAP subscription data and creates an ID for it. + * If there is no valid data, we return null. + */ + fun from(proto: AccountRecord.IAPSubscriberData?): IAPSubscriptionId? { + return if (proto == null) { + null + } else if (proto.purchaseToken.isNotNullOrBlank()) { + GooglePlayBillingPurchaseToken(proto.purchaseToken) + } else if (proto.originalTransactionId != null) { + AppleIAPOriginalTransactionId(proto.originalTransactionId) + } else { + null + } + } + + @OptIn(ExperimentalContracts::class) + fun IAPSubscriptionId?.isNotNullOrBlank(): Boolean { + contract { + returns(true) implies (this@isNotNullOrBlank != null) + } + + return this != null && (purchaseToken.isNotNullOrBlank() || originalTransactionId != null) + } + } +} diff --git a/libsignal-service/src/main/protowire/StorageService.proto b/libsignal-service/src/main/protowire/StorageService.proto index 59d87e8d7d..1fbe0e58ee 100644 --- a/libsignal-service/src/main/protowire/StorageService.proto +++ b/libsignal-service/src/main/protowire/StorageService.proto @@ -185,43 +185,58 @@ message AccountRecord { Color color = 3; } - bytes profileKey = 1; - string givenName = 2; - string familyName = 3; - string avatarUrlPath = 4; - bool noteToSelfArchived = 5; - bool readReceipts = 6; - bool sealedSenderIndicators = 7; - bool typingIndicators = 8; - reserved /* proxiedLinkPreviews */ 9; - bool noteToSelfMarkedUnread = 10; - bool linkPreviews = 11; - PhoneNumberSharingMode phoneNumberSharingMode = 12; - bool unlistedPhoneNumber = 13; - repeated PinnedConversation pinnedConversations = 14; - bool preferContactAvatars = 15; - Payments payments = 16; - uint32 universalExpireTimer = 17; - bool primarySendsSms = 18; - string e164 = 19; - repeated string preferredReactionEmoji = 20; - bytes subscriberId = 21; - string subscriberCurrencyCode = 22; - bool displayBadgesOnProfile = 23; - bool subscriptionManuallyCancelled = 24; - bool keepMutedChatsArchived = 25; - bool hasSetMyStoriesPrivacy = 26; - bool hasViewedOnboardingStory = 27; - reserved /* storiesDisabled */ 28; - bool storiesDisabled = 29; - OptionalBool storyViewReceiptsEnabled = 30; - reserved /* hasReadOnboardingStory */ 31; - bool hasSeenGroupStoryEducationSheet = 32; - string username = 33; - bool hasCompletedUsernameOnboarding = 34; - UsernameLink usernameLink = 35; - bytes backupsSubscriberId = 36; - string backupsSubscriberCurrencyCode = 37; + message IAPSubscriberData { + bytes subscriberId = 1; + + oneof iapSubscriptionId { + // Identifies an Android Play Store IAP subscription. + string purchaseToken = 2; + // Identifies an iOS App Store IAP subscription. + uint64 originalTransactionId = 3; + } + } + + bytes profileKey = 1; + string givenName = 2; + string familyName = 3; + string avatarUrlPath = 4; + bool noteToSelfArchived = 5; + bool readReceipts = 6; + bool sealedSenderIndicators = 7; + bool typingIndicators = 8; + reserved /* proxiedLinkPreviews */ 9; + bool noteToSelfMarkedUnread = 10; + bool linkPreviews = 11; + PhoneNumberSharingMode phoneNumberSharingMode = 12; + bool unlistedPhoneNumber = 13; + repeated PinnedConversation pinnedConversations = 14; + bool preferContactAvatars = 15; + Payments payments = 16; + uint32 universalExpireTimer = 17; + bool primarySendsSms = 18; + string e164 = 19; + repeated string preferredReactionEmoji = 20; + bytes subscriberId = 21; + string subscriberCurrencyCode = 22; + bool displayBadgesOnProfile = 23; + bool subscriptionManuallyCancelled = 24; + bool keepMutedChatsArchived = 25; + bool hasSetMyStoriesPrivacy = 26; + bool hasViewedOnboardingStory = 27; + reserved /* storiesDisabled */ 28; + bool storiesDisabled = 29; + OptionalBool storyViewReceiptsEnabled = 30; + reserved /* hasReadOnboardingStory */ 31; + bool hasSeenGroupStoryEducationSheet = 32; + string username = 33; + bool hasCompletedUsernameOnboarding = 34; + UsernameLink usernameLink = 35; + reserved /* backupsSubscriberId */ 36; + reserved /* backupsSubscriberCurrencyCode */ 37; + reserved /* backupsSubscriptionManuallyCancelled */ 38; + optional bool hasBackup = 39; // Set to true after backups are enabled and one is uploaded. + optional uint64 backupTier = 40; // See zkgroup for integer particular values + IAPSubscriberData backupSubscriberData = 41; } message StoryDistributionListRecord {