Utilize InAppPaymentTable as the SSOT for ManageDonationsFragment.

This commit is contained in:
Alex Hart
2026-03-09 16:35:32 -03:00
committed by jeffrey-signal
parent d06febd5b5
commit 7fb866fcfb
9 changed files with 588 additions and 123 deletions

View File

@@ -0,0 +1,336 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.reactivex.rxjava3.observers.TestObserver
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class)
class ManageDonationsViewModelTest {
@get:Rule
val harness = SignalActivityRule()
private val testAmount = FiatMoney(BigDecimal.valueOf(5), Currency.getInstance("USD")).toFiatValue()
private val testBadge = BadgeList.Badge(id = "test-badge")
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
}
@Test
fun givenEndRecordWithNoError_whenIQueryLatest_thenIGetActiveSubscription() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.END,
subscriberId = null,
endOfPeriod = 1000.seconds,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = testAmount,
badge = testBadge
)
)
val latest = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION)
assertThat(latest).isNotNull()
assertThat(latest!!.state).isEqualTo(InAppPaymentTable.State.END)
assertThat(latest.data.cancellation).isNull()
}
@Test
fun givenEmptyDatabase_whenIQueryLatest_thenIGetNull() {
val latest = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION)
assertThat(latest).isNull()
}
@Test
fun givenTransactingRecord_whenIQueryLatest_thenItIsReturned() {
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = testAmount
)
)
SignalDatabase.inAppPayments.moveToTransacting(id)
val latest = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION)
assertThat(latest).isNotNull()
assertThat(latest!!.state).isEqualTo(InAppPaymentTable.State.TRANSACTING)
}
@Test
fun givenCreatedRecord_whenIQueryLatest_thenItIsFiltered() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData()
)
val latest = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION)
assertThat(latest).isNull()
}
@Test
fun givenEndRecordWithError_whenIObserveRedemption_thenIGetFailedSubscription() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.END,
subscriberId = null,
endOfPeriod = 1000.seconds,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = testAmount,
error = InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING)
)
)
val observer = TestObserver<DonationRedemptionJobStatus>()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.take(1)
.subscribe(observer)
observer.awaitCount(1)
observer.assertValue(DonationRedemptionJobStatus.FailedSubscription)
}
@Test
fun givenEndRecordWithCancellation_whenIObserveRedemption_thenIGetNone() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.END,
subscriberId = null,
endOfPeriod = 1000.seconds,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = testAmount,
cancellation = InAppPaymentData.Cancellation(reason = InAppPaymentData.Cancellation.Reason.CANCELED)
)
)
val observer = TestObserver<DonationRedemptionJobStatus>()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.take(1)
.subscribe(observer)
observer.awaitCount(1)
observer.assertValue(DonationRedemptionJobStatus.None)
}
@Test
fun givenPendingBankTransferRecord_whenIObserveRedemption_thenIGetPendingExternalVerification() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = FiatMoney(BigDecimal.valueOf(5), Currency.getInstance("EUR")).toFiatValue(),
paymentMethodType = InAppPaymentData.PaymentMethodType.SEPA_DEBIT,
waitForAuth = InAppPaymentData.WaitingForAuthorizationState()
)
)
val observer = TestObserver<DonationRedemptionJobStatus>()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.take(1)
.subscribe(observer)
observer.awaitCount(1)
val status = observer.values().first()
assertThat(status is DonationRedemptionJobStatus.PendingExternalVerification).isTrue()
}
@Test
fun givenKeepAlivePendingRecord_whenIObserveRedemption_thenIGetPendingKeepAlive() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.PENDING,
subscriberId = null,
endOfPeriod = 1000.seconds,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = testAmount,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT,
keepAlive = true
)
)
)
val observer = TestObserver<DonationRedemptionJobStatus>()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.take(1)
.subscribe(observer)
observer.awaitCount(1)
observer.assertValue(DonationRedemptionJobStatus.PendingKeepAlive)
}
@Test
fun givenPendingOneTimeDonation_whenIObserveRedemption_thenIGetPendingStatus() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.PENDING,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
level = 1L,
amount = testAmount,
badge = testBadge,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
)
)
)
val observer = TestObserver<DonationRedemptionJobStatus>()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION)
.take(1)
.subscribe(observer)
observer.awaitCount(1)
observer.assertValue(DonationRedemptionJobStatus.PendingReceiptRequest)
}
@Test
fun givenEndRecordWithNonRedemptionError_whenICheckPaymentFailure_thenItIsTrue() {
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.END,
subscriberId = null,
endOfPeriod = 1000.seconds,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = testAmount,
error = InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING)
)
)
val payment = SignalDatabase.inAppPayments.getById(id)!!
val isPaymentFailure = payment.data.error?.let {
it.type != InAppPaymentData.Error.Type.REDEMPTION
} ?: false
assertThat(isPaymentFailure).isTrue()
}
@Test
fun givenEndRecordWithRedemptionError_whenICheckPaymentFailure_thenItIsFalse() {
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.END,
subscriberId = null,
endOfPeriod = 1000.seconds,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = testAmount,
error = InAppPaymentData.Error(type = InAppPaymentData.Error.Type.REDEMPTION)
)
)
val payment = SignalDatabase.inAppPayments.getById(id)!!
val isPaymentFailure = payment.data.error?.let {
it.type != InAppPaymentData.Error.Type.REDEMPTION
} ?: false
assertThat(isPaymentFailure).isEqualTo(false)
}
@Test
fun givenStateTransition_whenIUpdateRecord_thenObserverSeesNewState() {
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.PENDING,
subscriberId = null,
endOfPeriod = 1000.seconds,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = testAmount,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT,
keepAlive = true
)
)
)
val observer = TestObserver<DonationRedemptionJobStatus>()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.take(2)
.subscribe(observer)
observer.awaitCount(1)
assertThat(observer.values().first()).isEqualTo(DonationRedemptionJobStatus.PendingKeepAlive)
val payment = SignalDatabase.inAppPayments.getById(id)!!
SignalDatabase.inAppPayments.update(
payment.copy(
state = InAppPaymentTable.State.END,
data = payment.data.copy(
error = InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING)
)
)
)
observer.awaitCount(2)
assertThat(observer.values().last()).isEqualTo(DonationRedemptionJobStatus.FailedSubscription)
}
@Test
fun givenNonVerifiedIdealRecurring_whenIObserveRedemption_thenIGetNonVerifiedMonthlyDonation() {
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
level = 500L,
amount = FiatMoney(BigDecimal.valueOf(5), Currency.getInstance("EUR")).toFiatValue(),
paymentMethodType = InAppPaymentData.PaymentMethodType.IDEAL,
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(checkedVerification = true)
)
)
val observer = TestObserver<DonationRedemptionJobStatus>()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.take(1)
.subscribe(observer)
observer.awaitCount(1)
val status = observer.values().first()
assertThat(status is DonationRedemptionJobStatus.PendingExternalVerification).isTrue()
val verification = status as DonationRedemptionJobStatus.PendingExternalVerification
assertThat(verification.nonVerifiedMonthlyDonation).isNotNull()
assertThat(verification.nonVerifiedMonthlyDonation!!.checkedVerification).isTrue()
}
}

View File

@@ -81,15 +81,24 @@ object InAppPaymentsRepository {
* This operation will only be performed if we find a latest payment for the given subscriber id in the END state without cancelation data
*/
fun updateBackupInAppPaymentWithCancelation(activeSubscription: ActiveSubscription) {
if (activeSubscription.isCanceled || activeSubscription.willCancelAtPeriodEnd()) {
val subscriber = getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) ?: return
updateInAppPaymentWithCancelation(activeSubscription, InAppPaymentSubscriberRecord.Type.BACKUP)
}
/**
* Updates the latest payment object for the given subscriber type with cancelation information as necessary.
*
* This operation will only be performed if we find a latest payment for the given subscriber id in the END state without cancelation data.
*/
fun updateInAppPaymentWithCancelation(activeSubscription: ActiveSubscription, subscriberType: InAppPaymentSubscriberRecord.Type) {
if (activeSubscription.isCanceled || (subscriberType == InAppPaymentSubscriberRecord.Type.BACKUP && activeSubscription.willCancelAtPeriodEnd()) || activeSubscription.isFailedPayment) {
val subscriber = getSubscriber(subscriberType) ?: return
val latestPayment = SignalDatabase.inAppPayments.getLatestBySubscriberId(subscriber.subscriberId) ?: return
if (latestPayment.state == InAppPaymentTable.State.END && latestPayment.data.cancellation == null) {
synchronized(subscriber.type.lock) {
val payment = SignalDatabase.inAppPayments.getLatestBySubscriberId(subscriber.subscriberId) ?: return
val chargeFailure: ActiveSubscription.ChargeFailure? = activeSubscription.chargeFailure
Log.i(TAG, "Recording cancelation in the database. (has charge failure? ${chargeFailure != null})")
Log.i(TAG, "[$subscriberType] Recording cancelation in the database. (has charge failure? ${chargeFailure != null})")
SignalDatabase.inAppPayments.update(
payment.copy(
data = payment.data.newBuilder()

View File

@@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.visible
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Locale
/**
@@ -31,7 +30,7 @@ object ActiveSubscriptionPreference {
val subscription: Subscription,
val renewalTimestamp: Long = -1L,
val redemptionState: ManageDonationsState.RedemptionState,
val activeSubscription: ActiveSubscription.Subscription?,
val isPaymentFailure: Boolean = false,
val subscriberRequiresCancel: Boolean,
val onContactSupport: () -> Unit,
val onRowClick: (ManageDonationsState.RedemptionState) -> Unit
@@ -46,7 +45,7 @@ object ActiveSubscriptionPreference {
renewalTimestamp == newItem.renewalTimestamp &&
redemptionState == newItem.redemptionState &&
FiatMoney.equals(price, newItem.price) &&
activeSubscription == newItem.activeSubscription
isPaymentFailure == newItem.isPaymentFailure
}
}
@@ -112,7 +111,7 @@ object ActiveSubscriptionPreference {
}
private fun presentFailureState(model: Model) {
if (model.activeSubscription?.isFailedPayment == true || model.subscriberRequiresCancel) {
if (model.isPaymentFailure || model.subscriberRequiresCancel) {
presentPaymentFailureState(model)
} else {
presentRedemptionFailureState(model)

View File

@@ -33,9 +33,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Ne
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
@@ -44,9 +47,7 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.seconds
import org.signal.core.ui.R as CoreUiR
/**
@@ -193,13 +194,16 @@ class ManageDonationsFragment :
space(16.dp)
if (state.subscriptionTransactionState is ManageDonationsState.TransactionState.NotInTransaction) {
val activeSubscription = state.subscriptionTransactionState.activeSubscription.activeSubscription
if (activeSubscription != null && !activeSubscription.isCanceled) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { it.level == activeSubscription.level }
if (!state.isLoaded) {
customPref(IndeterminateLoadingCircle)
} else if (state.networkError) {
presentNetworkFailureSettings(state, state.hasReceipts)
} else {
val payment = state.activeSubscription
if (payment != null && payment.data.cancellation == null) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { it.level == payment.data.level.toInt() }
if (subscription != null) {
presentSubscriptionSettings(activeSubscription, subscription, state)
presentSubscriptionSettings(payment, subscription, state)
} else {
customPref(IndeterminateLoadingCircle)
}
@@ -215,10 +219,6 @@ class ManageDonationsFragment :
} else {
presentNotADonorSettings(state.hasReceipts)
}
} else if (state.subscriptionTransactionState == ManageDonationsState.TransactionState.NetworkFailure) {
presentNetworkFailureSettings(state, state.hasReceipts)
} else {
customPref(IndeterminateLoadingCircle)
}
}
}
@@ -275,22 +275,28 @@ class ManageDonationsFragment :
}
private fun DSLConfiguration.presentSubscriptionSettings(
activeSubscription: ActiveSubscription.Subscription,
payment: InAppPaymentTable.InAppPayment,
subscription: Subscription,
state: ManageDonationsState
) {
val price = payment.data.amount!!.toFiatMoney()
val renewalTimestamp = payment.endOfPeriodSeconds.seconds.inWholeMilliseconds
val isPaymentFailure = payment.data.error?.let {
it.type != InAppPaymentData.Error.Type.REDEMPTION && it.data_ != InAppPaymentKeepAliveJob.KEEP_ALIVE
} ?: false
presentSubscriptionSettingsWithState(state) {
customPref(
ActiveSubscriptionPreference.Model(
price = FiatMoney.fromSignalNetworkAmount(activeSubscription.amount, Currency.getInstance(activeSubscription.currency)),
price = price,
subscription = subscription,
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod),
redemptionState = state.getMonthlyDonorRedemptionState(),
renewalTimestamp = renewalTimestamp,
redemptionState = state.subscriptionRedemptionState,
onContactSupport = {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
},
activeSubscription = activeSubscription,
isPaymentFailure = isPaymentFailure,
subscriberRequiresCancel = state.subscriberRequiresCancel,
onRowClick = {
launcher.launch(InAppPaymentType.RECURRING_DONATION)
@@ -312,7 +318,6 @@ class ManageDonationsFragment :
subscription = subscription,
redemptionState = ManageDonationsState.RedemptionState.IN_PROGRESS,
onContactSupport = {},
activeSubscription = null,
subscriberRequiresCancel = state.subscriberRequiresCancel,
onRowClick = {}
)

View File

@@ -1,47 +1,24 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
data class ManageDonationsState(
val hasOneTimeBadge: Boolean = false,
val hasReceipts: Boolean = false,
val featuredBadge: Badge? = null,
val subscriptionTransactionState: TransactionState = TransactionState.Init,
val isLoaded: Boolean = false,
val networkError: Boolean = false,
val availableSubscriptions: List<Subscription> = emptyList(),
val activeSubscription: InAppPaymentTable.InAppPayment? = null,
val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE,
val pendingOneTimeDonation: PendingOneTimeDonation? = null,
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null,
val subscriberRequiresCancel: Boolean = false,
private val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE
val subscriberRequiresCancel: Boolean = false
) {
fun getMonthlyDonorRedemptionState(): RedemptionState {
return when (subscriptionTransactionState) {
TransactionState.Init -> subscriptionRedemptionState
TransactionState.NetworkFailure -> subscriptionRedemptionState
TransactionState.InTransaction -> RedemptionState.IN_PROGRESS
is TransactionState.NotInTransaction -> getStateFromActiveSubscription(subscriptionTransactionState.activeSubscription) ?: subscriptionRedemptionState
}
}
private fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): RedemptionState? {
return when {
activeSubscription.isFailedPayment && !activeSubscription.isPastDue -> RedemptionState.FAILED
activeSubscription.isPendingBankTransfer -> RedemptionState.IS_PENDING_BANK_TRANSFER
activeSubscription.isInProgress -> RedemptionState.IN_PROGRESS
else -> null
}
}
sealed class TransactionState {
data object Init : TransactionState()
data object NetworkFailure : TransactionState()
data object InTransaction : TransactionState()
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
}
enum class RedemptionState {
NONE,
IN_PROGRESS,

View File

@@ -3,33 +3,35 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Optional
import kotlin.time.Duration.Companion.milliseconds
class ManageDonationsViewModel : ViewModel() {
@@ -62,6 +64,46 @@ class ManageDonationsViewModel : ViewModel() {
internalDisplayThanksBottomSheetPulse.emit(Badges.fromDatabaseBadge(it.data.badge!!))
}
}
viewModelScope.launch(Dispatchers.IO) {
updateRecurringDonationState()
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION)
.asFlow()
.collect { redemptionStatus ->
val latestPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION)
val activeSubscription: InAppPaymentTable.InAppPayment? = latestPayment?.let {
if (it.data.cancellation == null) it else null
}
store.update { manageDonationsState ->
manageDonationsState.copy(
nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null,
subscriptionRedemptionState = deriveRedemptionState(redemptionStatus, latestPayment),
activeSubscription = activeSubscription
)
}
}
}
viewModelScope.launch(Dispatchers.IO) {
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION)
.asFlow()
.collect { redemptionStatus ->
val pendingOneTimeDonation = when (redemptionStatus) {
is DonationRedemptionJobStatus.PendingExternalVerification -> redemptionStatus.pendingOneTimeDonation
DonationRedemptionJobStatus.PendingReceiptRedemption,
DonationRedemptionJobStatus.PendingReceiptRequest -> {
val latestPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.ONE_TIME_DONATION)
latestPayment?.toPendingOneTimeDonation()
}
else -> null
}
store.update { it.copy(pendingOneTimeDonation = pendingOneTimeDonation) }
}
}
}
override fun onCleared() {
@@ -69,8 +111,8 @@ class ManageDonationsViewModel : ViewModel() {
}
fun retry() {
if (!disposables.isDisposed && store.state.subscriptionTransactionState == ManageDonationsState.TransactionState.NetworkFailure) {
store.update { it.copy(subscriptionTransactionState = ManageDonationsState.TransactionState.Init) }
if (!disposables.isDisposed && store.state.networkError) {
store.update { it.copy(networkError = false) }
refresh()
}
}
@@ -78,16 +120,23 @@ class ManageDonationsViewModel : ViewModel() {
fun refresh() {
disposables.clear()
val levelUpdateOperationEdges: Observable<Boolean> = LevelUpdate.isProcessing.distinctUntilChanged()
val activeSubscription: Single<ActiveSubscription> = RecurringInAppPaymentRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION)
InAppPaymentKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis().milliseconds)
disposables += Single.fromCallable {
InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(InAppPaymentSubscriberRecord.Type.DONATION)
}.subscribeOn(Schedulers.io()).subscribeBy { requiresCancel ->
store.update {
it.copy(subscriberRequiresCancel = requiresCancel)
}.subscribeOn(Schedulers.io()).subscribeBy(
onSuccess = { requiresCancel ->
store.update {
it.copy(subscriberRequiresCancel = requiresCancel)
}
},
onError = { throwable ->
Log.w(TAG, "Error retrieving cancel state", throwable)
store.update {
it.copy(networkError = true)
}
}
}
)
disposables += Recipient.observable(Recipient.self().id).map { it.badges }.subscribeBy { badges ->
store.update { state ->
@@ -101,53 +150,6 @@ class ManageDonationsViewModel : ViewModel() {
store.update { it.copy(hasReceipts = hasReceipts) }
}
disposables += InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION).subscribeBy { redemptionStatus ->
store.update { manageDonationsState ->
manageDonationsState.copy(
nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null,
subscriptionRedemptionState = mapStatusToRedemptionState(redemptionStatus)
)
}
}
disposables += Observable.combineLatest(
SignalStore.inAppPayments.observablePendingOneTimeDonation,
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION)
) { pendingFromStore, pendingFromJob ->
if (pendingFromStore.isPresent) {
pendingFromStore
} else if (pendingFromJob is DonationRedemptionJobStatus.PendingExternalVerification) {
Optional.ofNullable(pendingFromJob.pendingOneTimeDonation)
} else {
Optional.empty()
}
}
.distinctUntilChanged()
.subscribeBy { pending ->
store.update { it.copy(pendingOneTimeDonation = pending.orNull()) }
}
disposables += levelUpdateOperationEdges.switchMapSingle { isProcessing ->
if (isProcessing) {
Single.just(ManageDonationsState.TransactionState.InTransaction)
} else {
activeSubscription.map { ManageDonationsState.TransactionState.NotInTransaction(it) }
}
}.subscribeBy(
onNext = { transactionState ->
store.update {
it.copy(subscriptionTransactionState = transactionState)
}
},
onError = { throwable ->
Log.w(TAG, "Error retrieving subscription transaction state", throwable)
store.update {
it.copy(subscriptionTransactionState = ManageDonationsState.TransactionState.NetworkFailure)
}
}
)
disposables += RecurringInAppPaymentRepository.getSubscriptions().subscribeBy(
onSuccess = { subs ->
store.update { it.copy(availableSubscriptions = subs) }
@@ -158,18 +160,70 @@ class ManageDonationsViewModel : ViewModel() {
)
}
private fun mapStatusToRedemptionState(status: DonationRedemptionJobStatus): ManageDonationsState.RedemptionState {
private fun updateRecurringDonationState() {
val latestPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION)
val activeSubscription: InAppPaymentTable.InAppPayment? = latestPayment?.let {
if (it.data.cancellation == null) it else null
}
store.update { manageDonationsState ->
manageDonationsState.copy(
isLoaded = true,
activeSubscription = activeSubscription
)
}
}
private fun deriveRedemptionState(status: DonationRedemptionJobStatus, latestPayment: InAppPaymentTable.InAppPayment?): ManageDonationsState.RedemptionState {
return when (status) {
DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED
DonationRedemptionJobStatus.None -> ManageDonationsState.RedemptionState.NONE
DonationRedemptionJobStatus.PendingKeepAlive -> ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH
DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED
is DonationRedemptionJobStatus.PendingExternalVerification -> {
if (latestPayment != null && (latestPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.SEPA_DEBIT || latestPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL)) {
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER
} else {
ManageDonationsState.RedemptionState.IN_PROGRESS
}
}
is DonationRedemptionJobStatus.PendingExternalVerification,
DonationRedemptionJobStatus.PendingReceiptRedemption,
DonationRedemptionJobStatus.PendingReceiptRequest -> ManageDonationsState.RedemptionState.IN_PROGRESS
}
}
private fun InAppPaymentTable.InAppPayment.toPendingOneTimeDonation(): PendingOneTimeDonation? {
if (type.recurring || data.amount == null || data.badge == null) {
return null
}
return PendingOneTimeDonation(
paymentMethodType = when (data.paymentMethodType) {
InAppPaymentData.PaymentMethodType.UNKNOWN -> PendingOneTimeDonation.PaymentMethodType.CARD
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> PendingOneTimeDonation.PaymentMethodType.CARD
InAppPaymentData.PaymentMethodType.CARD -> PendingOneTimeDonation.PaymentMethodType.CARD
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
InAppPaymentData.PaymentMethodType.IDEAL -> PendingOneTimeDonation.PaymentMethodType.IDEAL
InAppPaymentData.PaymentMethodType.PAYPAL -> PendingOneTimeDonation.PaymentMethodType.PAYPAL
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Not supported.")
},
amount = data.amount,
badge = data.badge,
timestamp = insertedAt.inWholeMilliseconds,
error = data.error?.takeIf { it.data_ != InAppPaymentKeepAliveJob.KEEP_ALIVE }?.let {
DonationErrorValue(
type = when (it.type) {
InAppPaymentData.Error.Type.REDEMPTION -> DonationErrorValue.Type.REDEMPTION
InAppPaymentData.Error.Type.PAYMENT_PROCESSING -> DonationErrorValue.Type.PAYMENT
else -> DonationErrorValue.Type.PAYMENT
}
)
}
)
}
companion object {
private val TAG = Log.tag(ManageDonationsViewModel::class.java)
}

View File

@@ -355,10 +355,8 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
return readableDatabase.select()
.from(TABLE_NAME)
.where(
"($STATE = ? OR $STATE = ? OR $STATE = ?) AND $TYPE = ?",
State.serialize(State.PENDING),
State.serialize(State.WAITING_FOR_AUTHORIZATION),
State.serialize(State.END),
"$STATE != ? AND $TYPE = ?",
State.serialize(State.CREATED),
InAppPaymentType.serialize(type)
)
.orderBy("$INSERTED_AT DESC")

View File

@@ -153,6 +153,12 @@ class InAppPaymentKeepAliveJob private constructor(
return
}
if (type == InAppPaymentSubscriberRecord.Type.DONATION && activeSubscription.isCanceled) {
info(type, "Subscription is canceled. Writing cancellation to DB.")
InAppPaymentsRepository.updateInAppPaymentWithCancelation(activeSubscription, type)
return
}
val activeInAppPayment = getActiveInAppPayment(subscriber, subscription)
if (activeInAppPayment == null) {
warn(type, "Failed to generate active in-app payment. Exiting")

View File

@@ -5,19 +5,67 @@
package org.thoughtcrime.securesms.jobs
import android.app.Application
import assertk.assertThat
import assertk.assertions.isTrue
import io.mockk.every
import io.mockk.verify
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsTestRule
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.MockSignalStoreRule
import org.thoughtcrime.securesms.testutil.SystemOutLogger
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class InAppPaymentKeepAliveJobTest {
@get:Rule
val mockSignalStore = MockSignalStoreRule()
@get:Rule
val appDependencies = MockAppDependenciesRule()
@get:Rule
val inAppPaymentsTestRule = InAppPaymentsTestRule()
@Before
fun setUp() {
Log.initialize(SystemOutLogger())
every { mockSignalStore.account.isRegistered } returns true
every { mockSignalStore.account.isLinkedDevice } returns false
every { SignalDatabase.inAppPayments.getOldPendingPayments(any()) } returns emptyList()
every { SignalDatabase.inAppPayments.hasPrePendingRecurringTransaction(any()) } returns false
val subscriber = InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
currency = java.util.Currency.getInstance("USD"),
type = InAppPaymentSubscriberRecord.Type.DONATION,
requiresCancel = false,
paymentMethodType = org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData.PaymentMethodType.CARD,
iapSubscriptionId = null
)
every { InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) } returns subscriber
every { AppDependencies.donationsService.putSubscription(any()) } returns ServiceResponse(200, "", EmptyResponse.INSTANCE, null, null)
}
@Test
fun `Given an unregistered local user, when I run, then I expect skip`() {
every { mockSignalStore.account.isRegistered } returns false
@@ -28,4 +76,37 @@ class InAppPaymentKeepAliveJobTest {
assertThat(result.isSuccess).isTrue()
}
@Test
fun `Given a canceled subscription, when I run, then I write cancellation and return early`() {
val activeSubscription = inAppPaymentsTestRule.createActiveSubscription(
status = "canceled",
isActive = false
)
inAppPaymentsTestRule.initializeActiveSubscriptionMock(activeSubscription = activeSubscription)
every { InAppPaymentsRepository.updateInAppPaymentWithCancelation(any(), any()) } returns Unit
val job = InAppPaymentKeepAliveJob.create(InAppPaymentSubscriberRecord.Type.DONATION)
val result = job.run()
assertThat(result.isSuccess).isTrue()
verify(exactly = 1) { InAppPaymentsRepository.updateInAppPaymentWithCancelation(activeSubscription, InAppPaymentSubscriberRecord.Type.DONATION) }
}
@Test
fun `Given a past-due subscription with charge failure, when I run, then I do not write cancellation`() {
val chargeFailure = ChargeFailure("test", "", "", "", "")
val activeSubscription = inAppPaymentsTestRule.createActiveSubscription(
status = "past_due",
isActive = false,
chargeFailure = chargeFailure
)
inAppPaymentsTestRule.initializeActiveSubscriptionMock(activeSubscription = activeSubscription)
every { InAppPaymentsRepository.updateInAppPaymentWithCancelation(any(), any()) } returns Unit
val job = InAppPaymentKeepAliveJob.create(InAppPaymentSubscriberRecord.Type.DONATION)
job.run()
verify(exactly = 0) { InAppPaymentsRepository.updateInAppPaymentWithCancelation(any(), any()) }
}
}