mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-26 11:51:10 +01:00
Implement badge gifting behind feature flag.
This commit is contained in:
committed by
Greyson Parrelli
parent
5d16d1cd23
commit
a4a4665aaa
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
enum class ManageDonationsEvent {
|
||||
NOT_SUBSCRIBED,
|
||||
ERROR_GETTING_SUBSCRIPTION
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user