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:
Alex Hart
2021-10-21 16:39:02 -03:00
committed by Greyson Parrelli
parent d88999d6d4
commit c1820459b7
91 changed files with 2765 additions and 696 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
class DonationExceptions {
object TimedOutWaitingForTokenRedemption : Exception()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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