Implement badge gifting behind feature flag.

This commit is contained in:
Alex Hart
2022-05-02 14:29:42 -03:00
committed by Greyson Parrelli
parent 5d16d1cd23
commit a4a4665aaa
164 changed files with 4999 additions and 486 deletions

View File

@@ -45,6 +45,7 @@ class DSLSettingsAdapter : MappingAdapter() {
abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
protected val iconView: ImageView = itemView.findViewById(R.id.icon)
private val iconEndView: ImageView? = itemView.findViewById(R.id.icon_end)
protected val titleView: TextView = itemView.findViewById(R.id.title)
protected val summaryView: TextView = itemView.findViewById(R.id.summary)
@@ -58,6 +59,10 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
iconView.setImageDrawable(icon)
iconView.visible = icon != null
val iconEnd = model.iconEnd?.resolve(context)
iconEndView?.setImageDrawable(iconEnd)
iconEndView?.visible = iconEnd != null
val title = model.title?.resolve(context)
if (title != null) {
titleView.text = model.title?.resolve(context)

View File

@@ -15,9 +15,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
@@ -30,11 +28,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
private val viewModel: AppSettingsViewModel by viewModels(
factoryProducer = {
AppSettingsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
}
)
private val viewModel: AppSettingsViewModel by viewModels()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
@@ -48,7 +42,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
override fun onResume() {
super.onResume()
viewModel.refreshActiveSubscription()
viewModel.refreshExpiredGiftBadge()
}
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
@@ -76,13 +70,22 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
)
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
customPref(
PaymentsPreference(
unreadCount = state.unreadPaymentsCount
) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
}
if (FeatureFlags.donorBadges() && PlayServicesUtil.getPlayServicesStatus(requireContext()) == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
iconEnd = if (state.hasExpiredGiftBadge) DSLSettingsIcon.from(R.drawable.ic_info_solid_24, R.color.signal_accent_primary) else null,
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
},
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
)
} else {
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)
}
@@ -130,6 +133,18 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
dividerPref()
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
customPref(
PaymentsPreference(
unreadCount = state.unreadPaymentsCount
) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
}
)
}
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.preferences__help),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
@@ -146,44 +161,6 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
)
if (FeatureFlags.donorBadges() && PlayServicesUtil.getPlayServicesStatus(requireContext()) == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
customPref(
SubscriptionPreference(
title = DSLSettingsText.from(
if (state.hasActiveSubscription) {
R.string.preferences__subscription
} else {
R.string.preferences__monthly_donation
}
),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
isActive = state.hasActiveSubscription,
onClick = { isActive ->
if (isActive) {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
} else {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscribeFragment())
}
},
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
)
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__one_time_donation),
icon = DSLSettingsIcon.from(R.drawable.ic_boost_24),
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment())
},
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
)
} else {
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)
}
if (FeatureFlags.internalUser()) {
dividerPref()

View File

@@ -5,5 +5,5 @@ import org.thoughtcrime.securesms.recipients.Recipient
data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int,
val hasActiveSubscription: Boolean
val hasExpiredGiftBadge: Boolean
)

View File

@@ -2,22 +2,14 @@ package org.thoughtcrime.securesms.components.settings.app
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import java.util.concurrent.TimeUnit
class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
class AppSettingsViewModel : ViewModel() {
private val store = Store(AppSettingsState(Recipient.self(), 0, false))
private val store = Store(AppSettingsState(Recipient.self(), 0, SignalStore.donationsValues().getExpiredGiftBadge() != null))
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
@@ -29,38 +21,7 @@ class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRep
store.update(selfLiveData) { self, state -> state.copy(self = self) }
}
fun refreshActiveSubscription() {
if (!FeatureFlags.donorBadges()) {
return
}
store.update {
it.copy(hasActiveSubscription = TimeUnit.SECONDS.toMillis(SignalStore.donationsValues().getLastEndOfPeriod()) > System.currentTimeMillis())
}
subscriptionsRepository.getActiveSubscription().subscribeBy(
onSuccess = { subscription -> store.update { it.copy(hasActiveSubscription = subscription.activeSubscription != null) } },
onError = { throwable ->
if (throwable.isNotFoundException()) {
Log.w(TAG, "Could not load active subscription due to unset SubscriberId (404).")
}
Log.w(TAG, "Could not load active subscription", throwable)
}
)
}
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T
}
}
companion object {
private val TAG = Log.tag(AppSettingsViewModel::class.java)
}
private fun Throwable.isNotFoundException(): Boolean {
return this is PushNetworkException && this.cause is NotFoundException || this is NotFoundException
fun refreshExpiredGiftBadge() {
store.update { it.copy(hasExpiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge() != null) }
}
}

View File

@@ -12,9 +12,9 @@ import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -23,16 +23,21 @@ import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.ProfileUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
import java.io.IOException
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@@ -47,7 +52,7 @@ import java.util.concurrent.TimeUnit
* 1. Confirm the SetupIntent via the Stripe API
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
*
* For Boosts:
* For Boosts and Gifts:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Create a PaymentIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
@@ -86,19 +91,65 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
}
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
Log.d(TAG, "Creating payment intent for $price...", true)
return stripeApi.createPaymentIntent(price, application.getString(R.string.Boost__thank_you_for_your_donation))
.onErrorResumeNext { Single.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it)) }
.flatMapCompletable { result ->
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall())
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge())
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost())
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent)
/**
* @param price The amount to charce the local user
* @param paymentData PaymentData from Google Pay that describes the payment method
* @param badgeRecipient Who will be getting the badge
* @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self)
*/
fun continuePayment(price: FiatMoney, paymentData: PaymentData, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
val verifyRecipient = Completable.fromAction {
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
val recipient = Recipient.resolved(badgeRecipient)
if (recipient.isSelf) {
Log.d(TAG, "Badge recipient is self, so this is a boost. Skipping verification.", true)
return@fromAction
}
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
try {
val profile = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)
if (!profile.profile.capabilities.isGiftBadges) {
Log.w(TAG, "Badge recipient does not support gifting. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
} else {
Log.d(TAG, "Badge recipient supports gifting. Verification successful.", true)
}
} catch (e: IOException) {
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
}
}
return verifyRecipient.doOnComplete {
Log.d(TAG, "Creating payment intent for $price...", true)
}.andThen(stripeApi.createPaymentIntent(price, badgeLevel))
.onErrorResumeNext {
if (it is DonationError) {
Single.error(it)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, it))
}
}
.flatMapCompletable { result ->
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
}
}.subscribeOn(Schedulers.io())
}
fun continueSubscriptionSetup(paymentData: PaymentData): Completable {
@@ -140,20 +191,36 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
}
private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Log.d(TAG, "Confirming payment intent...", true)
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext {
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it))
Completable.error(DonationError.getPaymentSetupError(donationErrorSource, it))
}
val waitOnRedemption = Completable.create {
Log.d(TAG, "Confirmed payment intent. Recording boost receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(DonationReceiptRecord.createForBoost(price))
val donationReceiptRecord = if (isBoost) {
DonationReceiptRecord.createForBoost(price)
} else {
DonationReceiptRecord.createForGift(price)
}
val donationTypeLabel = donationReceiptRecord.type.code.capitalize(Locale.US)
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntent)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
}
BoostReceiptRequestResponseJob.createJobChain(paymentIntent).enqueue { _, jobState ->
chain.enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
@@ -164,25 +231,25 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Boost request response job chain succeeded.", true)
Log.d(TAG, "$donationTypeLabel request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Boost request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST))
Log.d(TAG, "$donationTypeLabel request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(donationErrorSource))
}
else -> {
Log.d(TAG, "Boost request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
}
} else {
Log.d(TAG, "Boost redemption timed out waiting for job completion.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
} catch (e: InterruptedException) {
Log.d(TAG, "Boost redemption job interrupted", e, true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
}
@@ -282,11 +349,11 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
}
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeApi.PaymentIntent> {
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
return ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, description)
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
.map {
StripeApi.PaymentIntent(it.id, it.clientSecret)

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -23,6 +24,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
val localSubscription = SignalStore.donationsValues().getSubscriber()
return if (localSubscription != null) {
donationsService.getSubscription(localSubscription.subscriberId)
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
} else {
Single.just(ActiveSubscription.EMPTY)
@@ -30,6 +32,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
}
fun getSubscriptions(): Single<List<Subscription>> = donationsService.getSubscriptionLevels(Locale.getDefault())
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
.map { subscriptionLevels ->
subscriptionLevels.levels.map { (code, level) ->

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
@@ -16,6 +17,7 @@ class BoostRepository(private val donationsService: DonationsService) {
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
return donationsService.boostAmounts
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
.map { result ->
result
@@ -27,6 +29,7 @@ class BoostRepository(private val donationsService: DonationsService) {
fun getBoostBadge(): Single<Badge> {
return donationsService.getBoostBadge(Locale.getDefault())
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
.map(Badges::fromServiceBadge)
}

View File

@@ -23,9 +23,11 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
@@ -37,7 +39,7 @@ class BoostViewModel(
private val fetchTokenRequestCode: Int
) : ViewModel() {
private val store = Store(BoostState(currencySelection = SignalStore.donationsValues().getBoostCurrency()))
private val store = Store(BoostState(currencySelection = SignalStore.donationsValues().getOneTimeCurrency()))
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
private val disposables = CompositeDisposable()
private val networkDisposable: Disposable
@@ -77,7 +79,7 @@ class BoostViewModel(
fun refresh() {
disposables.clear()
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
val currencyObservable = SignalStore.donationsValues().observableOneTimeCurrency
val allBoosts = boostRepository.getBoosts()
val boostBadge = boostRepository.getBoostBadge()
@@ -85,7 +87,7 @@ class BoostViewModel(
val boostList = if (currency in boostMap) {
boostMap[currency]!!
} else {
SignalStore.donationsValues().setBoostCurrency(PlatformCurrencyUtil.USD)
SignalStore.donationsValues().setOneTimeCurrency(PlatformCurrencyUtil.USD)
listOf()
}
@@ -140,7 +142,7 @@ class BoostViewModel(
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
donationPaymentRepository.continuePayment(boost.price, paymentData, Recipient.self().id, null, SubscriptionLevels.BOOST_LEVEL.toLong()).subscribeBy(
onError = { throwable ->
store.update { it.copy(stage = BoostState.Stage.READY) }
val donationError: DonationError = if (throwable is DonationError) {

View File

@@ -13,14 +13,14 @@ import java.util.Currency
import java.util.Locale
class SetCurrencyViewModel(
private val isBoost: Boolean,
private val isOneTime: Boolean,
supportedCurrencyCodes: List<String>
) : ViewModel() {
private val store = Store(
SetCurrencyState(
selectedCurrencyCode = if (isBoost) {
SignalStore.donationsValues().getBoostCurrency().currencyCode
selectedCurrencyCode = if (isOneTime) {
SignalStore.donationsValues().getOneTimeCurrency().currencyCode
} else {
SignalStore.donationsValues().getSubscriptionCurrency().currencyCode
},
@@ -35,8 +35,8 @@ class SetCurrencyViewModel(
fun setSelectedCurrency(selectedCurrencyCode: String) {
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
if (isBoost) {
SignalStore.donationsValues().setBoostCurrency(Currency.getInstance(selectedCurrencyCode))
if (isOneTime) {
SignalStore.donationsValues().setOneTimeCurrency(Currency.getInstance(selectedCurrencyCode))
} else {
val currency = Currency.getInstance(selectedCurrencyCode)
val subscriber = SignalStore.donationsValues().getSubscriber(currency)
@@ -83,9 +83,9 @@ class SetCurrencyViewModel(
}
}
class Factory(private val isBoost: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
class Factory(private val isOneTime: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(SetCurrencyViewModel(isBoost, supportedCurrencyCodes))!!
return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!!
}
}
}

View File

@@ -19,12 +19,21 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
}
/**
* Boost validation errors, which occur before the user could be charged.
* Gifting recipient validation errors, which occur before the user could be charged for a gift.
*/
sealed class BoostError(message: String) : DonationError(DonationErrorSource.BOOST, Exception(message)) {
object AmountTooSmallError : BoostError("Amount is too small")
object AmountTooLargeError : BoostError("Amount is too large")
object InvalidCurrencyError : BoostError("Currency is not supported")
sealed class GiftRecipientVerificationError(cause: Throwable) : DonationError(DonationErrorSource.GIFT, cause) {
object SelectedRecipientIsInvalid : GiftRecipientVerificationError(Exception("Selected recipient is invalid."))
object SelectedRecipientDoesNotSupportGifts : GiftRecipientVerificationError(Exception("Selected recipient does not support gifts."))
class FailedToFetchProfile(cause: Throwable) : GiftRecipientVerificationError(Exception("Failed to fetch recipient profile.", cause))
}
/**
* One-time donation validation errors, which occur before the user could be charged.
*/
sealed class OneTimeDonationError(source: DonationErrorSource, message: String) : DonationError(source, Exception(message)) {
class AmountTooSmallError(source: DonationErrorSource) : OneTimeDonationError(source, "Amount is too small")
class AmountTooLargeError(source: DonationErrorSource) : OneTimeDonationError(source, "Amount is too large")
class InvalidCurrencyError(source: DonationErrorSource) : OneTimeDonationError(source, "Currency is not supported")
}
/**
@@ -69,6 +78,11 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
*/
class TimeoutWaitingForTokenError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Timed out waiting for badge redemption to complete."))
/**
* Verification of request credentials object failed
*/
class FailedToValidateCredentialError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Failed to validate credential from server."))
/**
* Some generic error not otherwise accounted for occurred during the redemption process.
*/
@@ -134,13 +148,13 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
}
@JvmStatic
fun boostAmountTooSmall(): DonationError = BoostError.AmountTooSmallError
fun oneTimeDonationAmountTooSmall(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooSmallError(source)
@JvmStatic
fun boostAmountTooLarge(): DonationError = BoostError.AmountTooLargeError
fun oneTimeDonationAmountTooLarge(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooLargeError(source)
@JvmStatic
fun invalidCurrencyForBoost(): DonationError = BoostError.InvalidCurrencyError
fun invalidCurrencyForOneTimeDonation(source: DonationErrorSource): DonationError = OneTimeDonationError.InvalidCurrencyError(source)
@JvmStatic
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
@@ -148,6 +162,9 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
@JvmStatic
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)
@JvmStatic
fun badgeCredentialVerificationFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.FailedToValidateCredentialError(source)
@JvmStatic
fun genericPaymentFailure(source: DonationErrorSource): DonationError = PaymentProcessingError.GenericError(source)
}

View File

@@ -23,6 +23,7 @@ class DonationErrorParams<V> private constructor(
callback: Callback<V>
): DonationErrorParams<V> {
return when (throwable) {
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError -> DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
@@ -36,6 +37,13 @@ class DonationErrorParams<V> private constructor(
positiveAction = callback.onOk(context),
negativeAction = null
)
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> DonationErrorParams(
title = R.string.DonationsErrors__failed_to_validate_badge,
message = R.string.DonationsErrors__could_not_validate,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable, callback)
else -> DonationErrorParams(
title = R.string.DonationsErrors__couldnt_add_badge,
message = R.string.DonationsErrors__your_badge_could_not,
@@ -45,6 +53,32 @@ class DonationErrorParams<V> private constructor(
}
}
private fun <V> getGenericRedemptionError(context: Context, genericError: DonationError.BadgeRedemptionError.GenericError, callback: Callback<V>): DonationErrorParams<V> {
return when (genericError.source) {
DonationErrorSource.GIFT -> DonationErrorParams(
title = R.string.DonationsErrors__failed_to_send_gift_badge,
message = R.string.DonationsErrors__could_not_send_gift_badge,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
else -> DonationErrorParams(
title = R.string.DonationsErrors__couldnt_add_badge,
message = R.string.DonationsErrors__your_badge_could_not,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
}
}
private fun <V> getVerificationErrorParams(context: Context, verificationError: DonationError.GiftRecipientVerificationError, callback: Callback<V>): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__recipient_verification_failed,
message = R.string.DonationsErrors__target_does_not_support_gifting,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
}
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {
return when (declinedError.declineCode) {
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {

View File

@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
enum class DonationErrorSource(private val code: String) {
BOOST("boost"),
SUBSCRIPTION("subscription"),
GIFT("gift"),
GIFT_REDEMPTION("gift-redemption"),
KEEP_ALIVE("keep-alive"),
UNKNOWN("unknown");

View File

@@ -5,7 +5,6 @@ import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -31,7 +30,6 @@ object ActiveSubscriptionPreference {
class Model(
val price: FiatMoney,
val subscription: Subscription,
val onAddBoostClick: () -> Unit,
val renewalTimestamp: Long = -1L,
val redemptionState: ManageDonationsState.SubscriptionRedemptionState,
val activeSubscription: ActiveSubscription.Subscription,
@@ -57,7 +55,6 @@ object ActiveSubscriptionPreference {
val title: TextView = itemView.findViewById(R.id.my_support_title)
val price: TextView = itemView.findViewById(R.id.my_support_price)
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
val boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
val progress: ProgressBar = itemView.findViewById(R.id.my_support_progress)
override fun bind(model: Model) {
@@ -79,10 +76,6 @@ object ActiveSubscriptionPreference {
ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS -> presentInProgressState()
ManageDonationsState.SubscriptionRedemptionState.FAILED -> presentFailureState(model)
}
boost.setOnClickListener {
model.onAddBoostClick()
}
}
private fun presentRenewalState(model: Model) {

View File

@@ -1,6 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
enum class ManageDonationsEvent {
NOT_SUBSCRIBED,
ERROR_GETTING_SUBSCRIPTION
}

View File

@@ -1,11 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import android.widget.Toast
import android.content.Intent
import android.text.SpannableStringBuilder
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheet
import org.thoughtcrime.securesms.badges.gifts.flow.GiftFlowActivity
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -14,13 +18,17 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
import java.util.concurrent.TimeUnit
@@ -28,7 +36,17 @@ import java.util.concurrent.TimeUnit
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
* a subscriber. Used to manage their current subscription, view badges, and boost.
*/
class ManageDonationsFragment : DSLSettingsFragment() {
class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback {
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__make_a_recurring_monthly_donation))
.append(" ")
.append(
SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_button_secondary_text)) {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeLearnMoreBottomSheetDialog())
}
)
}
private val viewModel: ManageDonationsViewModel by viewModels(
factoryProducer = {
@@ -36,8 +54,6 @@ class ManageDonationsFragment : DSLSettingsFragment() {
}
)
private val lifecycleDisposable = LifecycleDisposable()
override fun onResume() {
super.onResume()
viewModel.refresh()
@@ -47,24 +63,23 @@ class ManageDonationsFragment : DSLSettingsFragment() {
ActiveSubscriptionPreference.register(adapter)
IndeterminateLoadingCircle.register(adapter)
BadgePreview.register(adapter)
NetworkFailure.register(adapter)
val expiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge()
if (expiredGiftBadge != null) {
SignalStore.donationsValues().setExpiredBadge(null)
ExpiredGiftSheet.show(childFragmentManager, expiredGiftBadge)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: ManageDonationsEvent ->
when (event) {
ManageDonationsEvent.NOT_SUBSCRIBED -> handleUserIsNotSubscribed()
ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION -> handleErrorGettingSubscription()
}
}
}
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
return configure {
customPref(
BadgePreview.Model(
BadgePreview.BadgeModel.FeaturedModel(
badge = state.featuredBadge
)
)
@@ -78,91 +93,176 @@ class ManageDonationsFragment : DSLSettingsFragment() {
)
)
space(DimensionUnit.DP.toPixels(32f).toInt())
noPadTextPref(
title = DSLSettingsText.from(
R.string.ManageDonationsFragment__my_support,
DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier
)
)
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
val activeSubscription = state.transactionState.activeSubscription.activeSubscription
if (activeSubscription != null) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.level == it.level }
if (subscription != null) {
space(DimensionUnit.DP.toPixels(12f).toInt())
val activeCurrency = Currency.getInstance(activeSubscription.currency)
val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
customPref(
ActiveSubscriptionPreference.Model(
price = FiatMoney(activeAmount, activeCurrency),
subscription = subscription,
onAddBoostClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
},
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod),
redemptionState = state.getRedemptionState(),
onContactSupport = {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
},
activeSubscription = activeSubscription
)
)
dividerPref()
presentSubscriptionSettings(activeSubscription, subscription, state.getRedemptionState())
} else {
customPref(IndeterminateLoadingCircle)
}
} else {
customPref(IndeterminateLoadingCircle)
presentNoSubscriptionSettings()
}
} else if (state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
presentNetworkFailureSettings(state.getRedemptionState())
} else {
customPref(IndeterminateLoadingCircle)
}
}
}
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
isEnabled = state.getRedemptionState() != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}
private fun DSLConfiguration.presentNetworkFailureSettings(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
if (SignalStore.donationsValues().isLikelyASustainer()) {
presentSubscriptionSettingsWithNetworkError(redemptionState)
} else {
presentNoSubscriptionSettings()
}
}
private fun DSLConfiguration.presentSubscriptionSettingsWithNetworkError(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
presentSubscriptionSettingsWithState(redemptionState) {
customPref(
NetworkFailure.Model(
onRetryClick = {
viewModel.retry()
}
)
)
}
}
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges())
}
private fun DSLConfiguration.presentSubscriptionSettings(
activeSubscription: ActiveSubscription.Subscription,
subscription: Subscription,
redemptionState: ManageDonationsState.SubscriptionRedemptionState
) {
presentSubscriptionSettingsWithState(redemptionState) {
val activeCurrency = Currency.getInstance(activeSubscription.currency)
val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
customPref(
ActiveSubscriptionPreference.Model(
price = FiatMoney(activeAmount, activeCurrency),
subscription = subscription,
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod),
redemptionState = redemptionState,
onContactSupport = {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
},
activeSubscription = activeSubscription
)
)
}
}
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
linkId = R.string.donate_url
private fun DSLConfiguration.presentSubscriptionSettingsWithState(
redemptionState: ManageDonationsState.SubscriptionRedemptionState,
subscriptionBlock: DSLConfiguration.() -> Unit
) {
space(DimensionUnit.DP.toPixels(32f).toInt())
noPadTextPref(
title = DSLSettingsText.from(
R.string.ManageDonationsFragment__my_subscription,
DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier
)
)
space(DimensionUnit.DP.toPixels(12f).toInt())
subscriptionBlock()
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
isEnabled = redemptionState != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}
)
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges())
}
)
presentOtherWaysToGive()
sectionHeaderPref(R.string.ManageDonationsFragment__more)
presentDonationReceipts()
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
linkId = R.string.donate_url
)
}
private fun DSLConfiguration.presentNoSubscriptionSettings() {
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
primaryButton(
text = DSLSettingsText.from(R.string.ManageDonationsFragment__make_a_monthly_donation),
onClick = {
findNavController().safeNavigate(R.id.action_manageDonationsFragment_to_subscribeFragment)
}
)
presentOtherWaysToGive()
sectionHeaderPref(R.string.ManageDonationsFragment__receipts)
presentDonationReceipts()
}
private fun DSLConfiguration.presentOtherWaysToGive() {
dividerPref()
sectionHeaderPref(R.string.ManageDonationsFragment__other_ways_to_give)
clickPref(
title = DSLSettingsText.from(R.string.preferences__one_time_donation),
icon = DSLSettingsIcon.from(R.drawable.ic_boost_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
}
)
if (FeatureFlags.giftBadges()) {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts),
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
title = DSLSettingsText.from(R.string.ManageDonationsFragment__gift_a_badge),
icon = DSLSettingsIcon.from(R.drawable.ic_gift_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
}
)
}
}
private fun handleUserIsNotSubscribed() {
findNavController().popBackStack()
private fun DSLConfiguration.presentDonationReceipts() {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts),
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
}
)
}
private fun handleErrorGettingSubscription() {
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
override fun onMakeAMonthlyDonation() {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}
}

View File

@@ -14,6 +14,7 @@ data class ManageDonationsState(
fun getRedemptionState(): SubscriptionRedemptionState {
return when (transactionState) {
TransactionState.Init -> subscriptionRedemptionState
TransactionState.NetworkFailure -> subscriptionRedemptionState
TransactionState.InTransaction -> SubscriptionRedemptionState.IN_PROGRESS
is TransactionState.NotInTransaction -> getStateFromActiveSubscription(transactionState.activeSubscription) ?: subscriptionRedemptionState
}
@@ -29,6 +30,7 @@ data class ManageDonationsState(
sealed class TransactionState {
object Init : TransactionState()
object NetworkFailure : TransactionState()
object InTransaction : TransactionState()
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
}

View File

@@ -3,18 +3,18 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
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.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.jobmanager.JobTracker
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
@@ -23,22 +23,37 @@ class ManageDonationsViewModel(
) : ViewModel() {
private val store = Store(ManageDonationsState())
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
private val disposables = CompositeDisposable()
private val networkDisposable: Disposable
val state: LiveData<ManageDonationsState> = store.stateLiveData
val events: Observable<ManageDonationsEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
init {
store.update(Recipient.self().live().liveDataResolved) { self, state ->
state.copy(featuredBadge = self.featuredBadge)
}
networkDisposable = InternetConnectionObserver
.observe()
.distinctUntilChanged()
.subscribe { isConnected ->
if (isConnected) {
retry()
}
}
}
override fun onCleared() {
disposables.clear()
}
fun retry() {
if (!disposables.isDisposed && store.state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
store.update { it.copy(transactionState = ManageDonationsState.TransactionState.Init) }
refresh()
}
}
fun refresh() {
disposables.clear()
@@ -72,13 +87,13 @@ class ManageDonationsViewModel(
store.update {
it.copy(transactionState = transactionState)
}
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && transactionState.activeSubscription.activeSubscription == null) {
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
}
},
onError = {
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
onError = { throwable ->
Log.w(TAG, "Error retrieving subscription transaction state", throwable)
store.update {
it.copy(transactionState = ManageDonationsState.TransactionState.NetworkFailure)
}
}
)

View File

@@ -71,6 +71,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
val type: String = when (record.type) {
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
DonationReceiptRecord.Type.GIFT -> getString(R.string.DonationReceiptListFragment__gift)
}
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
@@ -142,6 +143,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
when (record.type) {
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
DonationReceiptRecord.Type.GIFT -> getString(R.string.DonationReceiptListFragment__gift)
}
)
)

View File

@@ -44,6 +44,7 @@ object DonationReceiptListItem {
when (model.record.type) {
DonationReceiptRecord.Type.RECURRING -> R.string.DonationReceiptListFragment__recurring
DonationReceiptRecord.Type.BOOST -> R.string.DonationReceiptListFragment__one_time
DonationReceiptRecord.Type.GIFT -> R.string.DonationReceiptListFragment__gift
}
)
moneyView.text = FiatMoneyUtil.format(context.resources, model.record.amount)

View File

@@ -120,7 +120,7 @@ class SubscribeFragment : DSLSettingsFragment(
override fun onDestroyView() {
super.onDestroyView()
processingDonationPaymentDialog.hide()
processingDonationPaymentDialog.dismiss()
}
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
@@ -133,7 +133,7 @@ class SubscribeFragment : DSLSettingsFragment(
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
return configure {
customPref(BadgePreview.SubscriptionModel(state.selectedSubscription?.badge))
customPref(BadgePreview.BadgeModel.SubscriptionModel(state.selectedSubscription?.badge))
sectionHeaderPref(
title = DSLSettingsText.from(

View File

@@ -95,11 +95,12 @@ class DSLConfiguration {
title: DSLSettingsText,
summary: DSLSettingsText? = null,
icon: DSLSettingsIcon? = null,
iconEnd: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
onClick: () -> Unit,
onLongClick: (() -> Boolean)? = null
) {
val preference = ClickPreference(title, summary, icon, isEnabled, onClick, onLongClick)
val preference = ClickPreference(title, summary, icon, iconEnd, isEnabled, onClick, onLongClick)
children.add(preference)
}
@@ -182,6 +183,7 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
open val title: DSLSettingsText? = null,
open val summary: DSLSettingsText? = null,
open val icon: DSLSettingsIcon? = null,
open val iconEnd: DSLSettingsIcon? = null,
open val isEnabled: Boolean = true,
) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean {
@@ -197,7 +199,8 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
return areItemsTheSame(newItem) &&
newItem.summary == summary &&
newItem.icon == icon &&
newItem.isEnabled == isEnabled
newItem.isEnabled == isEnabled &&
newItem.iconEnd == iconEnd
}
}
@@ -269,6 +272,7 @@ class ClickPreference(
override val title: DSLSettingsText,
override val summary: DSLSettingsText? = null,
override val icon: DSLSettingsIcon? = null,
override val iconEnd: DSLSettingsIcon? = null,
override val isEnabled: Boolean = true,
val onClick: () -> Unit,
val onLongClick: (() -> Boolean)? = null

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import android.widget.TextView
import com.google.android.material.switchmaterial.SwitchMaterial
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object OutlinedSwitch {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.outlined_switch))
}
class Model(
val key: String = "OutlinedSwitch",
val text: DSLSettingsText,
val isChecked: Boolean,
val isEnabled: Boolean,
val onClick: (Model) -> Unit
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = newItem.key == key
override fun areContentsTheSame(newItem: Model): Boolean {
return areItemsTheSame(newItem) &&
text == newItem.text &&
isChecked == newItem.isChecked &&
isEnabled == newItem.isEnabled
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val text: TextView = findViewById(R.id.outlined_switch_control_text)
private val switch: SwitchMaterial = findViewById(R.id.outlined_switch_switch)
override fun bind(model: Model) {
text.text = model.text.resolve(context)
switch.isChecked = model.isChecked
switch.setOnClickListener { model.onClick(model) }
itemView.isEnabled = model.isEnabled
}
}
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.KeyEvent
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import com.google.android.material.textfield.TextInputLayout
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.text.AfterTextChanged
object TextInput {
sealed class TextInputEvent {
data class OnKeyEvent(val keyEvent: KeyEvent) : TextInputEvent()
data class OnEmojiEvent(val emoji: CharSequence) : TextInputEvent()
}
fun register(adapter: MappingAdapter, events: Observable<TextInputEvent>) {
adapter.registerFactory(MultilineModel::class.java, LayoutFactory({ MultilineViewHolder(it, events) }, R.layout.dsl_multiline_text_input))
}
class MultilineModel(
val text: CharSequence?,
val hint: DSLSettingsText? = null,
val onEmojiToggleClicked: (EditText) -> Unit,
val onTextChanged: (CharSequence) -> Unit
) : MappingModel<MultilineModel> {
override fun areItemsTheSame(newItem: MultilineModel): Boolean = true
override fun areContentsTheSame(newItem: MultilineModel): Boolean = text == newItem.text
}
class MultilineViewHolder(itemView: View, private val events: Observable<TextInputEvent>) : MappingViewHolder<MultilineModel>(itemView) {
private val inputLayout: TextInputLayout = itemView.findViewById(R.id.input_layout)
private val input: EditText = itemView.findViewById<EditText>(R.id.input).apply {
EditTextUtil.addGraphemeClusterLimitFilter(this, 700)
}
private val emojiToggle: ImageView = itemView.findViewById(R.id.emoji_toggle)
private var textChangedListener: AfterTextChanged? = null
private var eventDisposable: Disposable? = null
override fun onAttachedToWindow() {
eventDisposable = events.subscribe {
when (it) {
is TextInputEvent.OnEmojiEvent -> input.append(it.emoji)
is TextInputEvent.OnKeyEvent -> input.dispatchKeyEvent(it.keyEvent)
}
}
}
override fun onDetachedFromWindow() {
eventDisposable?.dispose()
}
override fun bind(model: MultilineModel) {
inputLayout.hint = model.hint?.resolve(context)
if (textChangedListener != null) {
input.removeTextChangedListener(textChangedListener)
}
if (model.text.toString() != input.text.toString()) {
input.setText(model.text)
}
textChangedListener = AfterTextChanged { model.onTextChanged(it.toString()) }
input.addTextChangedListener(textChangedListener)
// Set Emoji Toggle according to state.
emojiToggle.setOnClickListener {
model.onEmojiToggleClicked(input)
}
}
}
}