Implement initial support for IAP data.

This commit is contained in:
Alex Hart 2024-12-19 16:34:29 -04:00 committed by Greyson Parrelli
parent f537fa6436
commit f2b4bd0585
35 changed files with 957 additions and 241 deletions

View file

@ -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()}") {

View file

@ -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
}
}

View file

@ -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)

View file

@ -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
}

View file

@ -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"
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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 = {},

View file

@ -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<MessageBackupsType> = emptyList(),

View file

@ -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

View file

@ -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)
}
/**

View file

@ -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
)

View file

@ -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<SetCurrencyState> = 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
)
)
}

View file

@ -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<Subscription> = emptyList(),
private val _activeSubscription: ActiveSubscription? = null,
val selectedSubscription: Subscription? = null,

View file

@ -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()

View file

@ -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 {

View file

@ -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<InAppPaymentSubscriberRecord.Type, Int> {

View file

@ -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) {

View file

@ -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")
}
}

View file

@ -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.

View file

@ -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
)

View file

@ -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,

View file

@ -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.")

View file

@ -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());

View file

@ -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<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION)) }
private val recurringDonationCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getRecurringDonationCurrency()) }
val observableRecurringDonationCurrency: Observable<Currency> by lazy { recurringDonationCurrencyPublisher }
private val recurringBackupCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)) }
val observableRecurringBackupsCurrency: Observable<Currency> by lazy { recurringBackupCurrencyPublisher }
private val oneTimeCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getOneTimeCurrency()) }
val observableOneTimeCurrency: Observable<Currency> 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? {

View file

@ -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;
}

View file

@ -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<GooglePlayBillingPurchaseTokenMigrationJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): GooglePlayBillingPurchaseTokenMigrationJob {
return GooglePlayBillingPurchaseTokenMigrationJob(parameters)
}
}
}

View file

@ -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
)
)
}

View file

@ -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)) {

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
)
}

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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 {