mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 10:20:25 +01:00
Implement further features for badges.
* Add Subscriptions API * Add Accept-Language header to profile requests * Fix several UI bugs, add error dialogs, etc.
This commit is contained in:
committed by
Greyson Parrelli
parent
d88999d6d4
commit
c1820459b7
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.Subscript
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeViewModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -32,7 +33,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
private val donationRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
private val subscribeViewModel: SubscribeViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
SubscribeViewModel.Factory(SubscriptionsRepository(), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
|
||||
SubscribeViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -43,7 +44,6 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
|
||||
warmDonationViewModels()
|
||||
|
||||
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
|
||||
@@ -63,6 +63,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
|
||||
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
|
||||
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToSubscriptions()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +135,9 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
@JvmStatic
|
||||
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
|
||||
|
||||
@JvmStatic
|
||||
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.SUBSCRIPTIONS)
|
||||
|
||||
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
|
||||
return Intent(context, AppSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
@@ -154,7 +158,8 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
HELP(2),
|
||||
PROXY(3),
|
||||
NOTIFICATIONS(4),
|
||||
CHANGE_NUMBER(5);
|
||||
CHANGE_NUMBER(5),
|
||||
SUBSCRIPTIONS(6);
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int?): StartLocation {
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -15,7 +15,9 @@ 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
|
||||
@@ -25,17 +27,27 @@ import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
|
||||
|
||||
private val viewModel: AppSettingsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
AppSettingsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
adapter.registerFactory(BioPreference::class.java, MappingAdapter.LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
|
||||
adapter.registerFactory(PaymentsPreference::class.java, MappingAdapter.LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
|
||||
|
||||
val viewModel = ViewModelProvider(this)[AppSettingsViewModel::class.java]
|
||||
adapter.registerFactory(SubscriptionPreference::class.java, MappingAdapter.LayoutFactory(::SubscriptionPreferenceViewHolder, R.layout.dsl_preference_item))
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refreshActiveSubscription()
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
|
||||
@@ -132,16 +144,19 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
)
|
||||
|
||||
if (FeatureFlags.donorBadges()) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
onClick = {
|
||||
findNavController()
|
||||
.navigate(
|
||||
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
|
||||
.setSkipToSubscribe(true /* TODO [alex] -- Check state to see if user has active subscription or not. */)
|
||||
)
|
||||
}
|
||||
customPref(
|
||||
SubscriptionPreference(
|
||||
title = DSLSettingsText.from(R.string.preferences__subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
isActive = state.hasActiveSubscription,
|
||||
onClick = { isActive ->
|
||||
findNavController()
|
||||
.navigate(
|
||||
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
|
||||
.setSkipToSubscribe(!isActive)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
// TODO [alex] -- clap
|
||||
clickPref(
|
||||
@@ -172,6 +187,29 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
}
|
||||
}
|
||||
|
||||
private class SubscriptionPreference(
|
||||
override val title: DSLSettingsText,
|
||||
override val summary: DSLSettingsText? = null,
|
||||
override val icon: DSLSettingsIcon? = null,
|
||||
override val isEnabled: Boolean = true,
|
||||
val isActive: Boolean = false,
|
||||
val onClick: (Boolean) -> Unit
|
||||
) : PreferenceModel<SubscriptionPreference>() {
|
||||
override fun areItemsTheSame(newItem: SubscriptionPreference): Boolean {
|
||||
return true
|
||||
}
|
||||
override fun areContentsTheSame(newItem: SubscriptionPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && isActive == newItem.isActive
|
||||
}
|
||||
}
|
||||
|
||||
private class SubscriptionPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SubscriptionPreference>(itemView) {
|
||||
override fun bind(model: SubscriptionPreference) {
|
||||
super.bind(model)
|
||||
itemView.setOnClickListener { model.onClick(model.isActive) }
|
||||
}
|
||||
}
|
||||
|
||||
private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel<BioPreference>() {
|
||||
override fun areContentsTheSame(newItem: BioPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
|
||||
|
||||
@@ -2,4 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class AppSettingsState(val self: Recipient, val unreadPaymentsCount: Int)
|
||||
data class AppSettingsState(
|
||||
val self: Recipient,
|
||||
val unreadPaymentsCount: Int,
|
||||
val hasActiveSubscription: Boolean
|
||||
)
|
||||
|
||||
@@ -2,18 +2,47 @@ 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.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.livedata.LiveDataUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AppSettingsViewModel : ViewModel() {
|
||||
class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
|
||||
|
||||
val unreadPaymentsLiveData = UnreadPaymentsLiveData()
|
||||
val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
|
||||
private val store = Store(AppSettingsState(Recipient.self(), 0, false))
|
||||
|
||||
val state: LiveData<AppSettingsState> = LiveDataUtil.combineLatest(unreadPaymentsLiveData, selfLiveData) { payments, self ->
|
||||
val unreadPaymentsCount = payments.transform { it.unreadCount }.or(0)
|
||||
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
|
||||
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
|
||||
|
||||
AppSettingsState(self, unreadPaymentsCount)
|
||||
val state: LiveData<AppSettingsState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.transform { it.unreadCount }.or(0)) }
|
||||
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.isActive) } },
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ sealed class DonationEvent {
|
||||
object RequestTokenError : DonationEvent()
|
||||
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
|
||||
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
|
||||
class SubscriptionCancellationFailed(val throwable: Throwable) : DonationEvent()
|
||||
object SubscriptionCancelled : DonationEvent()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
class DonationExceptions {
|
||||
object TimedOutWaitingForTokenRedemption : Exception()
|
||||
}
|
||||
@@ -5,18 +5,44 @@ import android.content.Intent
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
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.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher {
|
||||
/**
|
||||
* Manages bindings with payment APIs
|
||||
*
|
||||
* Steps for setting up payments for a subscription:
|
||||
* 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. Generate and send a SubscriberId, which is a 32 byte ID representing this user, to Signal Service, which creates a Stripe Customer
|
||||
* 1. Create a SetupIntent via the Stripe API
|
||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
||||
* 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:
|
||||
* 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
|
||||
* 1. Confirm the PaymentIntent via the Stripe API
|
||||
*/
|
||||
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
|
||||
private val configuration = StripeApi.Configuration(publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY)
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(configuration))
|
||||
private val stripeApi = StripeApi(configuration, this, ApplicationDependencies.getOkHttpClient())
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
|
||||
|
||||
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
|
||||
|
||||
@@ -46,10 +72,153 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
}
|
||||
}
|
||||
|
||||
fun continueSubscriptionSetup(paymentData: PaymentData): Completable {
|
||||
return stripeApi.createSetupIntent()
|
||||
.flatMapCompletable { result ->
|
||||
stripeApi.confirmSetupIntent(GooglePayPaymentSource(paymentData), result.setupIntent)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelActiveSubscription(): Completable {
|
||||
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
return ApplicationDependencies.getDonationsService().cancelSubscription(localSubscriber.subscriberId).flatMapCompletable {
|
||||
when {
|
||||
it.status == 200 -> Completable.complete()
|
||||
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
|
||||
it.executionError.isPresent -> Completable.error(it.executionError.get())
|
||||
else -> Completable.error(AssertionError("Something bad happened"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureSubscriberId(): Completable {
|
||||
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.putSubscription(subscriberId)
|
||||
.flatMapCompletable {
|
||||
when {
|
||||
it.status == 200 -> Completable.complete()
|
||||
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
|
||||
it.executionError.isPresent -> Completable.error(it.executionError.get())
|
||||
else -> Completable.error(AssertionError("Something bad happened"))
|
||||
}
|
||||
}
|
||||
.doOnComplete {
|
||||
SignalStore
|
||||
.donationsValues()
|
||||
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
|
||||
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
|
||||
subscriber.subscriberId,
|
||||
subscriptionLevel,
|
||||
subscriber.currencyCode,
|
||||
levelUpdateOperation.idempotencyKey.serialize()
|
||||
).flatMapCompletable { response ->
|
||||
when {
|
||||
response.status == 200 -> Completable.complete()
|
||||
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
|
||||
response.executionError.isPresent -> Completable.error(response.executionError.get())
|
||||
else -> Completable.error(AssertionError("should never happen"))
|
||||
}
|
||||
}.andThen {
|
||||
SignalStore.donationsValues().clearLevelOperation(levelUpdateOperation)
|
||||
it.onComplete()
|
||||
}.andThen {
|
||||
val jobIds = SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation()
|
||||
val countDownLatch = CountDownLatch(2)
|
||||
|
||||
val firstJobListener = JobTracker.JobListener { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val secondJobListener = JobTracker.JobListener { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationDependencies.getJobManager().addListener(jobIds.first(), firstJobListener)
|
||||
ApplicationDependencies.getJobManager().addListener(jobIds.second(), secondJobListener)
|
||||
|
||||
try {
|
||||
if (!countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
} else {
|
||||
it.onComplete()
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
}
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
|
||||
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation()
|
||||
if (levelUpdateOperation == null || subscriptionLevel != levelUpdateOperation.level) {
|
||||
val newOperation = LevelUpdateOperation(
|
||||
idempotencyKey = IdempotencyKey.generate(),
|
||||
level = subscriptionLevel
|
||||
)
|
||||
|
||||
SignalStore.donationsValues().setLevelOperation(newOperation)
|
||||
newOperation
|
||||
} else {
|
||||
levelUpdateOperation
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
|
||||
.map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
|
||||
.flatMap { response ->
|
||||
when {
|
||||
response.status == 200 -> Single.just(StripeApi.PaymentIntent(response.result.get().id, response.result.get().clientSecret))
|
||||
response.executionError.isPresent -> Single.error(response.executionError.get())
|
||||
response.applicationError.isPresent -> Single.error(response.applicationError.get())
|
||||
else -> Single.error(AssertionError("should never get here"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> {
|
||||
return Single.fromCallable {
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
ApplicationDependencies.getDonationsService().createSubscriptionPaymentMethod(it.subscriberId)
|
||||
}.flatMap { response ->
|
||||
when {
|
||||
response.status == 200 -> Single.just(StripeApi.SetupIntent(response.result.get().id, response.result.get().clientSecret))
|
||||
response.executionError.isPresent -> Single.error(response.executionError.get())
|
||||
response.applicationError.isPresent -> Single.error(response.applicationError.get())
|
||||
else -> Single.error(AssertionError("should never get here"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||
return Single.fromCallable {
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
ApplicationDependencies.getDonationsService().setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
|
||||
}.flatMapCompletable { response ->
|
||||
when {
|
||||
response.status == 200 -> Completable.complete()
|
||||
response.executionError.isPresent -> Completable.error(response.executionError.get())
|
||||
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
|
||||
else -> Completable.error(AssertionError("Should never get here"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
|
||||
* in the currency indicated.
|
||||
*/
|
||||
class SubscriptionsRepository {
|
||||
class SubscriptionsRepository(private val donationsService: DonationsService) {
|
||||
|
||||
fun getActiveSubscription(currency: Currency): Maybe<Subscription> = Maybe.empty()
|
||||
fun getActiveSubscription(): Single<ActiveSubscription> {
|
||||
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
||||
return if (localSubscription != null) {
|
||||
donationsService.getSubscription(localSubscription.subscriberId).flatMap {
|
||||
when {
|
||||
it.status == 200 -> Single.just(it.result.get())
|
||||
it.applicationError.isPresent -> Single.error(it.applicationError.get())
|
||||
it.executionError.isPresent -> Single.error(it.executionError.get())
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Single.just(ActiveSubscription(null))
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = Single.fromCallable {
|
||||
listOf()
|
||||
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = donationsService.subscriptionLevels.map { response ->
|
||||
response.result.transform { subscriptionLevels ->
|
||||
subscriptionLevels.levels.map { (code, level) ->
|
||||
Subscription(
|
||||
id = code,
|
||||
title = level.badge.name,
|
||||
badge = Badges.fromServiceBadge(level.badge),
|
||||
price = FiatMoney(level.currencies[currency.currencyCode]!!, currency),
|
||||
level = code.toInt()
|
||||
)
|
||||
}.sortedBy {
|
||||
it.level
|
||||
}
|
||||
}.or(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -15,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
@@ -24,11 +28,15 @@ import org.thoughtcrime.securesms.util.SpanUtil
|
||||
/**
|
||||
* UX to allow users to donate ephemerally.
|
||||
*/
|
||||
class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
class BoostFragment : DSLSettingsBottomSheetFragment(
|
||||
layoutId = R.layout.boost_bottom_sheet
|
||||
) {
|
||||
|
||||
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private lateinit var processingDonationPaymentDialog: AlertDialog
|
||||
|
||||
private val sayThanks: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
|
||||
.append(" ")
|
||||
@@ -45,6 +53,11 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
Boost.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.processing_payment_dialog)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
@@ -52,17 +65,24 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
|
||||
when (event) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", event.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", event.throwable)
|
||||
is DonationEvent.GooglePayUnavailableError -> onGooglePayUnavailable(event.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> onPaymentError(event.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.RequestTokenError -> onPaymentError(null)
|
||||
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> Unit
|
||||
is DonationEvent.SubscriptionCancellationFailed -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: BoostState): DSLConfiguration {
|
||||
if (state.stage == BoostState.Stage.PAYMENT_PIPELINE) {
|
||||
processingDonationPaymentDialog.show()
|
||||
} else {
|
||||
processingDonationPaymentDialog.hide()
|
||||
}
|
||||
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.boostBadge))
|
||||
|
||||
@@ -87,7 +107,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == BoostState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment())
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment(true))
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -137,7 +157,43 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(boostBadge: Badge) {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true))
|
||||
findNavController().navigate(
|
||||
BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true),
|
||||
NavOptions.Builder().setPopUpTo(R.id.boostFragment, true).build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPaymentError(throwable: Throwable?) {
|
||||
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
|
||||
Log.w(TAG, "Error occurred while redeeming token", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__redemption_still_pending)
|
||||
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Error occurred while processing payment", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__payment_failed)
|
||||
.setMessage(R.string.DonationsErrors__your_payment)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayUnavailable(throwable: Throwable?) {
|
||||
Log.w(TAG, "Google Pay error", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__google_pay_unavailable)
|
||||
.setMessage(R.string.DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -14,11 +14,12 @@ data class BoostState(
|
||||
val selectedBoost: Boost? = null,
|
||||
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, Currency.getInstance(currencySelection.selectedCurrencyCode)),
|
||||
val isCustomAmountFocused: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
val stage: Stage = Stage.INIT,
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
TOKEN_REQUEST,
|
||||
PAYMENT_PIPELINE,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
@@ -31,7 +32,7 @@ class BoostViewModel(
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<BoostState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private var boostToPurchase: Boost? = null
|
||||
|
||||
@@ -40,7 +41,7 @@ class BoostViewModel(
|
||||
}
|
||||
|
||||
init {
|
||||
val currencyObservable = SignalStore.donationsValues().observableCurrency
|
||||
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
|
||||
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
|
||||
val boostBadge = boostRepository.getBoostBadge()
|
||||
|
||||
@@ -91,22 +92,28 @@ class BoostViewModel(
|
||||
if (boost != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
onError = { throwable ->
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
},
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
// TODO [alex] Now we need to do the whole query for a token, submit token rigamarole
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -13,7 +13,11 @@ import java.util.Locale
|
||||
*/
|
||||
class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: SetCurrencyViewModel by viewModels()
|
||||
private val viewModel: SetCurrencyViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
SetCurrencyViewModel.Factory(SetCurrencyFragmentArgs.fromBundle(requireArguments()).isBoost)
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -10,14 +11,14 @@ import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
class SetCurrencyViewModel : ViewModel() {
|
||||
class SetCurrencyViewModel(val isBoost: Boolean) : ViewModel() {
|
||||
|
||||
private val store = Store(SetCurrencyState())
|
||||
|
||||
val state: LiveData<SetCurrencyState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
val defaultCurrency = SignalStore.donationsValues().getCurrency()
|
||||
val defaultCurrency = SignalStore.donationsValues().getSubscriptionCurrency()
|
||||
|
||||
store.update { state ->
|
||||
val platformCurrencies = Currency.getAvailableCurrencies()
|
||||
@@ -34,7 +35,12 @@ class SetCurrencyViewModel : ViewModel() {
|
||||
|
||||
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
||||
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
||||
SignalStore.donationsValues().setCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
|
||||
if (isBoost) {
|
||||
SignalStore.donationsValues().setBoostCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
} else {
|
||||
SignalStore.donationsValues().setSubscriptionCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -65,4 +71,10 @@ class SetCurrencyViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val isBoost: Boolean) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(SetCurrencyViewModel(isBoost))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,17 @@ object ActiveSubscriptionPreference {
|
||||
|
||||
class Model(
|
||||
val subscription: Subscription,
|
||||
val onAddBoostClick: () -> Unit
|
||||
val onAddBoostClick: () -> Unit,
|
||||
val renewalTimestamp: Long = -1L
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return subscription.id == newItem.subscription.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && subscription == newItem.subscription
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
subscription == newItem.subscription &&
|
||||
renewalTimestamp == newItem.renewalTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +60,7 @@ object ActiveSubscriptionPreference {
|
||||
R.string.MySupportPreference__renews_s,
|
||||
DateUtils.formatDate(
|
||||
Locale.getDefault(),
|
||||
model.subscription.renewalTimestamp
|
||||
model.renewalTimestamp
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -13,7 +14,11 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
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.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
|
||||
@@ -23,7 +28,7 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
ManageDonationsViewModel.Factory(SubscriptionsRepository())
|
||||
ManageDonationsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,6 +58,7 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
}
|
||||
|
||||
ActiveSubscriptionPreference.register(adapter)
|
||||
IndeterminateLoadingCircle.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
@@ -83,17 +89,32 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
)
|
||||
)
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
subscription = state.activeSubscription,
|
||||
onAddBoostClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
}
|
||||
)
|
||||
)
|
||||
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
|
||||
val activeSubscription = state.transactionState.activeSubscription
|
||||
if (activeSubscription.isActive) {
|
||||
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.activeSubscription.level == it.level }
|
||||
if (subscription != null) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
dividerPref()
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
subscription = subscription,
|
||||
onAddBoostClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
},
|
||||
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.activeSubscription.endOfCurrentPeriod)
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
|
||||
@@ -2,8 +2,16 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
data class ManageDonationsState(
|
||||
val featuredBadge: Badge? = null,
|
||||
val activeSubscription: Subscription? = null
|
||||
)
|
||||
val transactionState: TransactionState = TransactionState.Init,
|
||||
val availableSubscriptions: List<Subscription> = emptyList()
|
||||
) {
|
||||
sealed class TransactionState {
|
||||
object Init : TransactionState()
|
||||
object InTransaction : TransactionState()
|
||||
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ 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.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
@@ -11,7 +13,10 @@ import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
class ManageDonationsViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
@@ -22,7 +27,7 @@ class ManageDonationsViewModel(
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
||||
val events: Observable<ManageDonationsEvent> = eventPublisher
|
||||
val events: Observable<ManageDonationsEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
init {
|
||||
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
||||
@@ -36,16 +41,34 @@ class ManageDonationsViewModel(
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
disposables += subscriptionsRepository.getActiveSubscription(SignalStore.donationsValues().getCurrency()).subscribeBy(
|
||||
onSuccess = { subscription -> store.update { it.copy(activeSubscription = subscription) } },
|
||||
onComplete = {
|
||||
store.update { it.copy(activeSubscription = null) }
|
||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
||||
|
||||
val levelUpdateOperationEdges: Observable<Optional<LevelUpdateOperation>> = SignalStore.donationsValues().levelUpdateOperationObservable.distinctUntilChanged()
|
||||
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription()
|
||||
|
||||
disposables += levelUpdateOperationEdges.flatMapSingle { optionalKey ->
|
||||
if (optionalKey.isPresent) {
|
||||
Single.just(ManageDonationsState.TransactionState.InTransaction)
|
||||
} else {
|
||||
activeSubscription.map { ManageDonationsState.TransactionState.NotInTransaction(it) }
|
||||
}
|
||||
}.subscribeBy(
|
||||
onNext = { transactionState ->
|
||||
store.update {
|
||||
it.copy(transactionState = transactionState)
|
||||
}
|
||||
|
||||
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && !transactionState.activeSubscription.isActive) {
|
||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
|
||||
}
|
||||
)
|
||||
|
||||
disposables += subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency()).subscribeBy { subs ->
|
||||
store.update { it.copy(availableSubscriptions = subs) }
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -38,7 +38,10 @@ data class CurrencySelection(
|
||||
|
||||
override fun bind(model: Model) {
|
||||
spinner.text = model.currencySelection.selectedCurrencyCode
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
|
||||
if (model.isEnabled) {
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.graphics.Color
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
@@ -16,18 +18,26 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
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.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* UX for creating and changing a subscription
|
||||
*/
|
||||
class SubscribeFragment : DSLSettingsFragment() {
|
||||
class SubscribeFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.subscribe_fragment
|
||||
) {
|
||||
|
||||
private val viewModel: SubscribeViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
@@ -43,9 +53,11 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
)
|
||||
}
|
||||
|
||||
private lateinit var processingDonationPaymentDialog: AlertDialog
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
viewModel.refreshActiveSubscription()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
@@ -54,6 +66,11 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
Subscription.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.processing_payment_dialog)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
@@ -61,19 +78,28 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe {
|
||||
when (it) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", it.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", it.throwable)
|
||||
is DonationEvent.GooglePayUnavailableError -> onGooglePayUnavailable(it.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> onPaymentError(it.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenError -> onPaymentError(null)
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
|
||||
is DonationEvent.SubscriptionCancellationFailed -> onSubscriptionFailedToCancel(it.throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
|
||||
if (state.hasInProgressSubscriptionTransaction || state.stage == SubscribeState.Stage.PAYMENT_PIPELINE) {
|
||||
processingDonationPaymentDialog.show()
|
||||
} else {
|
||||
processingDonationPaymentDialog.hide()
|
||||
}
|
||||
|
||||
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
|
||||
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.previewBadge))
|
||||
customPref(BadgePreview.SubscriptionModel(state.selectedSubscription?.badge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
@@ -91,35 +117,63 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
isEnabled = areFieldsEnabled && state.activeSubscription?.isActive != true,
|
||||
onClick = {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment())
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment(false))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
state.subscriptions.forEach {
|
||||
val isActive = state.activeSubscription?.activeSubscription?.level == it.level
|
||||
customPref(
|
||||
Subscription.Model(
|
||||
subscription = it,
|
||||
isSelected = state.selectedSubscription == it,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
isActive = state.activeSubscription == it,
|
||||
onClick = { viewModel.setSelectedSubscription(it) }
|
||||
isEnabled = areFieldsEnabled,
|
||||
isActive = isActive,
|
||||
willRenew = isActive && state.activeSubscription?.activeSubscription?.willCancelAtPeriodEnd() ?: false,
|
||||
onClick = { viewModel.setSelectedSubscription(it) },
|
||||
renewalTimestamp = TimeUnit.SECONDS.toMillis(state.activeSubscription?.activeSubscription?.endOfCurrentPeriod ?: 0L)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
if (state.activeSubscription?.isActive == true) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
val activeAndSameLevel = state.activeSubscription.isActive &&
|
||||
state.selectedSubscription?.level == state.activeSubscription.activeSubscription?.level
|
||||
val isExpiring = state.activeSubscription.isActive && state.activeSubscription.activeSubscription?.willCancelAtPeriodEnd() == true
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
|
||||
isEnabled = areFieldsEnabled && (!activeAndSameLevel || isExpiring),
|
||||
onClick = {
|
||||
// TODO [alex] -- Dunno what the update process requires.
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.add(Calendar.MONTH, 1)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__update_subscription_question)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.SubscribeFragment__you_will_be_charged_the_full_amount,
|
||||
DateUtils.formatDateWithYear(Locale.getDefault(), calendar.timeInMillis)
|
||||
)
|
||||
)
|
||||
.setPositiveButton(R.string.SubscribeFragment__update) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
viewModel.updateSubscription()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
isEnabled = areFieldsEnabled,
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
@@ -141,7 +195,7 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
onClick = this@SubscribeFragment::onGooglePayButtonClicked,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY
|
||||
isEnabled = areFieldsEnabled && state.selectedSubscription != null
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -150,7 +204,7 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
|
||||
onClick = {
|
||||
// TODO
|
||||
// TODO [alex] support page
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -162,11 +216,62 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(badge: Badge) {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false))
|
||||
findNavController().navigate(
|
||||
SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPaymentError(throwable: Throwable?) {
|
||||
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
|
||||
Log.w(TAG, "Error occurred while redeeming token", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__redemption_still_pending)
|
||||
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Error occurred while processing payment", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__payment_failed)
|
||||
.setMessage(R.string.DonationsErrors__your_payment)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayUnavailable(throwable: Throwable?) {
|
||||
Log.w(TAG, "Google Pay error", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__google_pay_unavailable)
|
||||
.setMessage(R.string.DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubscriptionCancelled() {
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG)
|
||||
.setTextColor(Color.WHITE)
|
||||
.show()
|
||||
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.home(requireContext()))
|
||||
}
|
||||
|
||||
private fun onSubscriptionFailedToCancel(throwable: Throwable) {
|
||||
Log.w(TAG, "Failed to cancel subscription", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
|
||||
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
data class SubscribeState(
|
||||
val previewBadge: Badge? = null,
|
||||
val currencySelection: CurrencySelection = CurrencySelection("USD"),
|
||||
val subscriptions: List<Subscription> = listOf(),
|
||||
val selectedSubscription: Subscription? = null,
|
||||
val activeSubscription: Subscription? = null,
|
||||
val activeSubscription: ActiveSubscription? = null,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
val stage: Stage = Stage.INIT,
|
||||
val hasInProgressSubscriptionTransaction: Boolean = false,
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
TOKEN_REQUEST,
|
||||
PAYMENT_PIPELINE,
|
||||
CANCELLING
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
@@ -18,7 +19,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Cu
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Currency
|
||||
|
||||
class SubscribeViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
@@ -31,31 +33,35 @@ class SubscribeViewModel(
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<SubscribeState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private var subscriptionToPurchase: Subscription? = null
|
||||
private val activeSubscriptionSubject = PublishSubject.create<ActiveSubscription>()
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
init {
|
||||
val currency: Observable<Currency> = SignalStore.donationsValues().observableSubscriptionCurrency
|
||||
val allSubscriptions: Observable<List<Subscription>> = currency.switchMapSingle { subscriptionsRepository.getSubscriptions(it) }
|
||||
refreshActiveSubscription()
|
||||
|
||||
val currency = SignalStore.donationsValues().getCurrency()
|
||||
disposables += SignalStore.donationsValues().levelUpdateOperationObservable.subscribeBy {
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
hasInProgressSubscriptionTransaction = it.isPresent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val allSubscriptions = subscriptionsRepository.getSubscriptions(currency)
|
||||
val activeSubscription = subscriptionsRepository.getActiveSubscription(currency)
|
||||
.map { Optional.of(it) }
|
||||
.defaultIfEmpty(Optional.absent())
|
||||
|
||||
disposables += allSubscriptions.zipWith(activeSubscription, ::Pair).subscribe { (subs, active) ->
|
||||
disposables += Observable.combineLatest(allSubscriptions, activeSubscriptionSubject, ::Pair).subscribe { (subs, active) ->
|
||||
store.update {
|
||||
it.copy(
|
||||
subscriptions = subs,
|
||||
selectedSubscription = it.selectedSubscription ?: active.orNull() ?: subs.firstOrNull(),
|
||||
activeSubscription = active.orNull(),
|
||||
stage = SubscribeState.Stage.READY
|
||||
selectedSubscription = it.selectedSubscription ?: resolveSelectedSubscription(active, subs),
|
||||
activeSubscription = active,
|
||||
stage = if (it.stage == SubscribeState.Stage.INIT) SubscribeState.Stage.READY else it.stage,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,11 +71,39 @@ class SubscribeViewModel(
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
store.update { it.copy(currencySelection = CurrencySelection(SignalStore.donationsValues().getCurrency().currencyCode)) }
|
||||
disposables += currency.map { CurrencySelection(it.currencyCode) }.subscribe { selection ->
|
||||
store.update { it.copy(currencySelection = selection) }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshActiveSubscription() {
|
||||
subscriptionsRepository
|
||||
.getActiveSubscription()
|
||||
.subscribeBy { activeSubscriptionSubject.onNext(it) }
|
||||
}
|
||||
|
||||
private fun resolveSelectedSubscription(activeSubscription: ActiveSubscription, subscriptions: List<Subscription>): Subscription? {
|
||||
return if (activeSubscription.isActive) {
|
||||
subscriptions.firstOrNull { it.level == activeSubscription.activeSubscription.level }
|
||||
} else {
|
||||
subscriptions.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
|
||||
// TODO [alex] -- cancel api call
|
||||
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
|
||||
onComplete = {
|
||||
eventPublisher.onNext(DonationEvent.SubscriptionCancelled)
|
||||
SignalStore.donationsValues().setLastEndOfPeriod(0L)
|
||||
refreshActiveSubscription()
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
},
|
||||
onError = { throwable ->
|
||||
eventPublisher.onNext(DonationEvent.SubscriptionCancellationFailed(throwable))
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
@@ -77,44 +111,72 @@ class SubscribeViewModel(
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
val subscription = subscriptionToPurchase
|
||||
subscriptionToPurchase = null
|
||||
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode, resultCode, data, this.fetchTokenRequestCode,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
val subscription = subscriptionToPurchase
|
||||
subscriptionToPurchase = null
|
||||
|
||||
if (subscription != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(subscription.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
|
||||
val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId()
|
||||
val continueSetup = donationPaymentRepository.continueSubscriptionSetup(paymentData)
|
||||
val setLevel = donationPaymentRepository.setSubscriptionLevel(subscription.level.toString())
|
||||
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
ensureSubscriberId.andThen(continueSetup).andThen(setLevel).subscribeBy(
|
||||
onError = { throwable ->
|
||||
refreshActiveSubscription()
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
},
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSubscription() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
donationPaymentRepository.setSubscriptionLevel(store.state.selectedSubscription!!.level.toString())
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.selectedSubscription!!.badge))
|
||||
},
|
||||
onError = { throwable ->
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedSubscription == null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = SubscribeState.Stage.TOKEN_REQUEST) }
|
||||
|
||||
subscriptionToPurchase = snapshot.selectedSubscription
|
||||
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.price, snapshot.selectedSubscription.title, fetchTokenRequestCode)
|
||||
|
||||
@@ -1,54 +1,122 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
|
||||
|
||||
import android.animation.Animator
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.airbnb.lottie.LottieDrawable
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private lateinit var displayOnProfileSwitch: SwitchMaterial
|
||||
private lateinit var switch: SwitchMaterial
|
||||
private lateinit var heading: TextView
|
||||
|
||||
private lateinit var badgeRepository: BadgeRepository
|
||||
private lateinit var controlState: ControlState
|
||||
|
||||
private enum class ControlState {
|
||||
FEATURE,
|
||||
DISPLAY
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.thanks_for_your_support_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
badgeRepository = BadgeRepository(requireContext())
|
||||
|
||||
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
|
||||
val lottie: LottieAnimationView = view.findViewById(R.id.thanks_bottom_sheet_lottie)
|
||||
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
|
||||
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
|
||||
val controlText: TextView = view.findViewById(R.id.thanks_bottom_sheet_control_text)
|
||||
val controlNote: View = view.findViewById(R.id.thanks_bottom_sheet_featured_note)
|
||||
|
||||
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
|
||||
displayOnProfileSwitch = view.findViewById(R.id.thanks_bottom_sheet_display_on_profile)
|
||||
switch = view.findViewById(R.id.thanks_bottom_sheet_switch)
|
||||
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
badgeView.setBadge(args.badge)
|
||||
badgeName.text = args.badge.name
|
||||
displayOnProfileSwitch.isChecked = true
|
||||
|
||||
val otherBadges = Recipient.self().badges.filterNot { it.id == args.badge.id }
|
||||
val hasOtherBadges = otherBadges.isNotEmpty()
|
||||
val displayingBadges = otherBadges.all { it.visible }
|
||||
|
||||
if (hasOtherBadges && displayingBadges) {
|
||||
switch.isChecked = false
|
||||
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge)
|
||||
controlNote.visible = true
|
||||
controlState = ControlState.FEATURE
|
||||
} else if (hasOtherBadges && !displayingBadges) {
|
||||
switch.isChecked = false
|
||||
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
|
||||
controlNote.visible = false
|
||||
controlState = ControlState.DISPLAY
|
||||
} else {
|
||||
switch.isChecked = true
|
||||
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
|
||||
controlNote.visible = false
|
||||
controlState = ControlState.DISPLAY
|
||||
}
|
||||
|
||||
if (args.isBoost) {
|
||||
presentBoostCopy()
|
||||
lottie.visible = true
|
||||
lottie.playAnimation()
|
||||
lottie.addAnimatorListener(object : AnimationCompleteListener() {
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
lottie.removeAnimatorListener(this)
|
||||
lottie.setMinAndMaxFrame(30, 91)
|
||||
lottie.repeatMode = LottieDrawable.RESTART
|
||||
lottie.repeatCount = LottieDrawable.INFINITE
|
||||
lottie.frame = 30
|
||||
lottie.playAnimation()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
presentSubscriptionCopy()
|
||||
lottie.visible = false
|
||||
}
|
||||
|
||||
done.setOnClickListener { dismissAllowingStateLoss() }
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
val isDisplayOnProfile = displayOnProfileSwitch.isChecked
|
||||
// TODO [alex] -- Not sure what state we're in with regards to submitting the token.
|
||||
val controlChecked = switch.isChecked
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
if (controlState == ControlState.DISPLAY) {
|
||||
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe()
|
||||
} else {
|
||||
badgeRepository.setFeaturedBadge(args.badge).subscribe()
|
||||
}
|
||||
|
||||
if (args.isBoost) {
|
||||
findNavController().popBackStack()
|
||||
} else {
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentBoostCopy() {
|
||||
|
||||
@@ -194,7 +194,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
val recipientId = args.recipientId
|
||||
if (recipientId != null) {
|
||||
Badge.register(adapter) { badge, _ ->
|
||||
Badge.register(adapter) { badge, _, _ ->
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, recipientId, badge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object IndeterminateLoadingCircle : PreferenceModel<IndeterminateLoadingCircle>() {
|
||||
override fun areItemsTheSame(newItem: IndeterminateLoadingCircle): Boolean = true
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<IndeterminateLoadingCircle>(itemView) {
|
||||
override fun bind(model: IndeterminateLoadingCircle) = Unit
|
||||
}
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(IndeterminateLoadingCircle::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.indeterminate_loading_circle_pref))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user