From daa3721145e2c6c17200218de2608ecfeeaa0bdb Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 29 Sep 2022 09:32:49 -0300 Subject: [PATCH] Add new joined donations screen. --- .../badges/gifts/flow/GiftFlowViewModel.kt | 3 +- .../badges/gifts/flow/GiftRowItem.kt | 30 +- .../securesms/badges/models/BadgePreview.kt | 18 +- .../ViewBadgeBottomSheetDialogFragment.kt | 38 +- .../components/settings/DSLSettingsText.kt | 2 +- .../settings/app/AppSettingsActivity.kt | 5 +- .../subscription/DonationPaymentComponent.kt | 5 +- .../subscription/DonationPaymentRepository.kt | 24 +- .../subscription/SubscriptionsRepository.kt | 11 + .../settings/app/subscription/boost/Boost.kt | 12 +- .../app/subscription/boost/BoostFragment.kt | 303 ---------- .../app/subscription/boost/BoostState.kt | 26 - .../app/subscription/boost/BoostViewModel.kt | 244 -------- .../donate/DonateToSignalAction.kt | 10 + .../donate/DonateToSignalFragment.kt | 519 ++++++++++++++++++ .../donate/DonateToSignalState.kt | 103 ++++ .../subscription/donate/DonateToSignalType.kt | 10 + .../donate/DonateToSignalViewModel.kt | 372 +++++++++++++ .../subscription/donate/DonationPillToggle.kt | 50 ++ .../donate/gateway/GatewayRequest.kt | 21 + .../donate/gateway/GatewayResponse.kt | 13 + .../gateway/GatewaySelectorBottomSheet.kt | 127 +++++ .../donate/gateway/GatewaySelectorState.kt | 10 + .../gateway/GatewaySelectorViewModel.kt | 45 ++ .../donate/stripe/StripeAction.kt | 11 + .../donate/stripe/StripeActionResult.kt | 17 + .../stripe/StripePaymentInProgressFragment.kt | 100 ++++ .../StripePaymentInProgressViewModel.kt | 178 ++++++ .../subscription/donate/stripe/StripeStage.kt | 9 + .../manage/ActiveSubscriptionPreference.kt | 5 +- .../manage/ManageDonationsFragment.kt | 165 +++--- .../manage/ManageDonationsState.kt | 4 +- .../manage/ManageDonationsViewModel.kt | 14 + .../subscribe/SubscribeFragment.kt | 346 ------------ .../subscription/subscribe/SubscribeState.kt | 29 - .../subscribe/SubscribeViewModel.kt | 307 ----------- ...ForYourSupportBottomSheetDialogFragment.kt | 2 +- .../securesms/components/settings/dsl.kt | 9 + .../components/settings/models/Button.kt | 14 + .../conversation/ConversationFragment.java | 9 +- .../database/DonationReceiptDatabase.kt | 7 + .../securesms/subscription/Subscription.kt | 54 +- .../donation_pill_toggle_background_tint.xml | 5 + ...gnal_selectable_button_background_tint.xml | 4 +- .../color/signal_selectable_button_stroke.xml | 4 +- .../currency_selection_background.xml | 21 - .../boost_loading_preference_background.xml | 4 +- .../currency_selection_background.xml | 6 +- .../custom_donation_amount_background.xml | 5 + ...tom_donation_amount_background_normal.xml} | 4 +- ...om_donation_amount_background_selected.xml | 6 + app/src/main/res/drawable/ic_check_20.xml | 9 + app/src/main/res/drawable/ic_chevron_16.xml | 10 + .../drawable/rounded_outline_accent_38dp.xml | 6 - .../rounded_outline_focusable_38dp.xml | 5 - ...l => subscription_row_item_background.xml} | 9 +- .../res/layout/boost_loading_preference.xml | 24 +- app/src/main/res/layout/boost_preference.xml | 37 +- .../res/layout/checkout_dialog_fragment.xml | 15 + .../res/layout/donate_to_signal_fragment.xml | 68 +++ .../main/res/layout/donation_pill_toggle.xml | 158 ++++++ .../res/layout/dsl_button_primary_wrapped.xml | 15 + .../res/layout/manage_donations_fragment.xml | 22 + .../main/res/layout/my_support_preference.xml | 41 +- .../stripe_payment_in_progress_fragment.xml | 36 ++ .../main/res/layout/subscribe_activity.xml | 13 - .../main/res/layout/subscribe_fragment.xml | 54 -- .../subscription_currency_selection.xml | 32 +- .../res/layout/subscription_preference.xml | 26 +- .../layout/subscription_preference_loader.xml | 12 +- ...iew_badge_bottom_sheet_dialog_fragment.xml | 8 +- ...adge_bottom_sheet_dialog_fragment_page.xml | 16 +- app/src/main/res/navigation/app_settings.xml | 129 ++--- app/src/main/res/navigation/boosts.xml | 57 -- .../main/res/navigation/donate_to_signal.xml | 110 ++++ app/src/main/res/values-ldrtl/themes.xml | 19 + app/src/main/res/values/signal_styles.xml | 3 + app/src/main/res/values/strings.xml | 45 +- app/src/main/res/values/themes.xml | 16 + 79 files changed, 2516 insertions(+), 1819 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalAction.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeAction.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeActionResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeStage.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt create mode 100644 app/src/main/res/color/donation_pill_toggle_background_tint.xml delete mode 100644 app/src/main/res/drawable-night/currency_selection_background.xml create mode 100644 app/src/main/res/drawable/custom_donation_amount_background.xml rename app/src/main/res/drawable/{rounded_outline_38dp.xml => custom_donation_amount_background_normal.xml} (52%) create mode 100644 app/src/main/res/drawable/custom_donation_amount_background_selected.xml create mode 100644 app/src/main/res/drawable/ic_check_20.xml create mode 100644 app/src/main/res/drawable/ic_chevron_16.xml delete mode 100644 app/src/main/res/drawable/rounded_outline_accent_38dp.xml delete mode 100644 app/src/main/res/drawable/rounded_outline_focusable_38dp.xml rename app/src/main/res/drawable/{selectable_rounded_outline.xml => subscription_row_item_background.xml} (57%) create mode 100644 app/src/main/res/layout/checkout_dialog_fragment.xml create mode 100644 app/src/main/res/layout/donate_to_signal_fragment.xml create mode 100644 app/src/main/res/layout/donation_pill_toggle.xml create mode 100644 app/src/main/res/layout/dsl_button_primary_wrapped.xml create mode 100644 app/src/main/res/layout/manage_donations_fragment.xml create mode 100644 app/src/main/res/layout/stripe_payment_in_progress_fragment.xml delete mode 100644 app/src/main/res/layout/subscribe_activity.xml delete mode 100644 app/src/main/res/layout/subscribe_fragment.xml delete mode 100644 app/src/main/res/navigation/boosts.xml create mode 100644 app/src/main/res/navigation/donate_to_signal.xml create mode 100644 app/src/main/res/values-ldrtl/themes.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt index 2a140a5012..d756117be6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt @@ -15,6 +15,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.GooglePayApi +import org.signal.donations.GooglePayPaymentSource import org.thoughtcrime.securesms.badges.gifts.Gifts import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent @@ -164,7 +165,7 @@ class GiftFlowViewModel( store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) } - donationPaymentRepository.continuePayment(gift.price, paymentData, recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy( + donationPaymentRepository.continuePayment(gift.price, GooglePayPaymentSource(paymentData), recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy( onError = this@GiftFlowViewModel::onPaymentFlowError, onComplete = { store.update { it.copy(stage = GiftFlowState.Stage.READY) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftRowItem.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftRowItem.kt index bd54bf4063..d5f1bda129 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftRowItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftRowItem.kt @@ -1,16 +1,14 @@ package org.thoughtcrime.securesms.badges.gifts.flow -import android.view.View -import android.widget.TextView import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.badges.BadgeImageView import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.databinding.SubscriptionPreferenceBinding import org.thoughtcrime.securesms.payments.FiatMoneyUtil -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory +import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder import org.thoughtcrime.securesms.util.visible import java.util.concurrent.TimeUnit @@ -19,7 +17,7 @@ import java.util.concurrent.TimeUnit */ object GiftRowItem { fun register(mappingAdapter: MappingAdapter) { - mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.subscription_preference)) + mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, SubscriptionPreferenceBinding::inflate)) } class Model(val giftBadge: Badge, val price: FiatMoney) : MappingModel { @@ -28,23 +26,15 @@ object GiftRowItem { override fun areContentsTheSame(newItem: Model): Boolean = giftBadge == newItem.giftBadge && price == newItem.price } - class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val badgeView = itemView.findViewById(R.id.badge) - private val titleView = itemView.findViewById(R.id.title) - private val checkView = itemView.findViewById(R.id.check) - private val taglineView = itemView.findViewById(R.id.tagline) - private val priceView = itemView.findViewById(R.id.price) - + class ViewHolder(binding: SubscriptionPreferenceBinding) : BindingViewHolder(binding) { init { - itemView.isSelected = true + binding.root.isSelected = true } override fun bind(model: Model) { - checkView.visible = false - badgeView.setBadge(model.giftBadge) - titleView.text = model.giftBadge.name - taglineView.setText(R.string.GiftRowItem__send_a_gift_badge) + binding.check.visible = false + binding.badge.setBadge(model.giftBadge) + binding.tagline.setText(R.string.GiftRowItem__send_a_gift_badge) val price = FiatMoneyUtil.format( context.resources, @@ -56,7 +46,7 @@ object GiftRowItem { val duration = TimeUnit.MILLISECONDS.toDays(model.giftBadge.duration) - priceView.text = context.resources.getQuantityString(R.plurals.GiftRowItem_s_dot_d_day_duration, duration.toInt(), price, duration) + binding.title.text = context.resources.getQuantityString(R.plurals.GiftRowItem_s_dot_d_day_duration, duration.toInt(), price, duration) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt index 5a778ef11a..7df8948934 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt @@ -10,6 +10,9 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +/** + * "Hero Image" for displaying an Avatar and badge. Allows the user to see what their profile will look like with a particular badge applied. + */ object BadgePreview { fun register(mappingAdapter: MappingAdapter) { @@ -19,6 +22,11 @@ object BadgePreview { } sealed class BadgeModel> : MappingModel { + + companion object { + const val PAYLOAD_BADGE = "badge" + } + abstract val badge: Badge? abstract val recipient: Recipient @@ -33,12 +41,20 @@ object BadgePreview { data class GiftedBadgeModel(override val badge: Badge?, override val recipient: Recipient) : BadgeModel() override fun areItemsTheSame(newItem: T): Boolean { - return badge?.id == newItem.badge?.id && recipient.id == newItem.recipient.id + return recipient.id == newItem.recipient.id } override fun areContentsTheSame(newItem: T): Boolean { return badge == newItem.badge && recipient.hasSameContent(newItem.recipient) } + + override fun getChangePayload(newItem: T): Any? { + return if (recipient.hasSameContent(newItem.recipient) && badge != newItem.badge) { + PAYLOAD_BADGE + } else { + null + } + } } class ViewHolder>(itemView: View) : MappingViewHolder(itemView) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt index 2e4df2bc9a..fceff079df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt @@ -10,15 +10,15 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.button.MaterialButton -import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.badges.BadgeRepository import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.badges.models.LargeBadge import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.databinding.ViewBadgeBottomSheetDialogFragmentBinding import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.BottomSheetUtil @@ -43,6 +43,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr textSize = ViewUtil.spToPx(16f).toFloat() } + private val binding by ViewBinderDelegate(ViewBadgeBottomSheetDialogFragmentBinding::bind) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.view_badge_bottom_sheet_dialog_fragment, container, false) } @@ -50,41 +52,36 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr override fun onViewCreated(view: View, savedInstanceState: Bundle?) { postponeEnterTransition() - val pager: ViewPager2 = view.findViewById(R.id.pager) - val tabs: TabLayout = view.findViewById(R.id.tab_layout) - val action: MaterialButton = view.findViewById(R.id.action) - val noSupport: View = view.findViewById(R.id.no_support) - if (getRecipientId() == Recipient.self().id) { - action.visible = false + binding.action.visible = false } @Suppress("CascadeIf") if (PlayServicesUtil.getPlayServicesStatus(requireContext()) != PlayServicesUtil.PlayServicesStatus.SUCCESS) { - noSupport.visible = true - action.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_open_20) - action.setText(R.string.preferences__donate_to_signal) - action.setOnClickListener { + binding.noSupport.visible = true + binding.action.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_open_20) + binding.action.setText(R.string.preferences__donate_to_signal) + binding.action.setOnClickListener { CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url)) } } else if (Recipient.self().badges.none { it.category == Badge.Category.Donor && !it.isBoost() && !it.isExpired() }) { - action.setOnClickListener { + binding.action.setOnClickListener { startActivity(AppSettingsActivity.subscriptions(requireContext())) } } else { - action.visible = false + binding.action.visible = false } val adapter = MappingAdapter() LargeBadge.register(adapter) - pager.adapter = adapter + binding.pager.adapter = adapter adapter.submitList(listOf(LargeBadge.EmptyModel())) - TabLayoutMediator(tabs, pager) { _, _ -> + TabLayoutMediator(binding.tabLayout, binding.pager) { _, _ -> }.attach() - pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + binding.pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { if (adapter.getModel(position).map { it is LargeBadge.Model }.orElse(false)) { viewModel.onPageSelected(position) @@ -101,7 +98,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr dismissAllowingStateLoss() } - tabs.visible = state.allBadgesVisibleOnProfile.size > 1 + binding.tabLayout.visible = state.allBadgesVisibleOnProfile.size > 1 + binding.singlePageSpace.visible = state.allBadgesVisibleOnProfile.size > 1 var maxLines = 3 state.allBadgesVisibleOnProfile.forEach { badge -> @@ -117,8 +115,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr } ) { val stateSelectedIndex = state.allBadgesVisibleOnProfile.indexOf(state.selectedBadge) - if (state.selectedBadge != null && pager.currentItem != stateSelectedIndex) { - pager.currentItem = stateSelectedIndex + if (state.selectedBadge != null && binding.pager.currentItem != stateSelectedIndex) { + binding.pager.currentItem = stateSelectedIndex } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt index 3ff3ba08e0..b986d62592 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt @@ -67,7 +67,7 @@ sealed class DSLSettingsText { object TitleLargeModifier : TextAppearanceModifier(R.style.Signal_Text_TitleLarge) object TitleMediumModifier : TextAppearanceModifier(R.style.Signal_Text_TitleMedium) - object Body1BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Body1_Bold) + object BodyLargeModifier : TextAppearanceModifier(R.style.Signal_Text_BodyLarge) open class TextAppearanceModifier(@StyleRes private val textAppearance: Int) : Modifier { override fun modify(context: Context, charSequence: CharSequence): CharSequence { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index 9c2d2a3175..1f846abfed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType import org.thoughtcrime.securesms.help.HelpFragment import org.thoughtcrime.securesms.keyvalue.SettingsValues import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -54,8 +55,8 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent { StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment() StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment() StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment() - StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToSubscriptions() - StartLocation.BOOST -> AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment() + StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(DonateToSignalType.MONTHLY) + StartLocation.BOOST -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(DonateToSignalType.ONE_TIME) StartLocation.MANAGE_SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations() StartLocation.NOTIFICATION_PROFILES -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles() StartLocation.CREATE_NOTIFICATION_PROFILE -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentComponent.kt index 0ba7c540d3..b425161041 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentComponent.kt @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.components.settings.app.subscription import android.content.Intent +import android.os.Parcelable import io.reactivex.rxjava3.subjects.Subject +import kotlinx.parcelize.Parcelize interface DonationPaymentComponent { val donationPaymentRepository: DonationPaymentRepository val googlePayResultPublisher: Subject - class GooglePayResult(val requestCode: Int, val resultCode: Int, val data: Intent?) + @Parcelize + class GooglePayResult(val requestCode: Int, val resultCode: Int, val data: Intent?) : Parcelable } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt index 0a316fc441..a104fd066a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription import android.app.Activity 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 @@ -10,7 +9,6 @@ import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log 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.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource @@ -63,6 +61,10 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet 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 { + return googlePayApi.queryIsReadyToPay() + } + fun scheduleSyncForAccountRecordChange() { SignalExecutors.BOUNDED.execute { scheduleSyncForAccountRecordChangeSync() @@ -129,7 +131,13 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet * @param badgeRecipient Who will be getting the badge * @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self) */ - fun continuePayment(price: FiatMoney, paymentData: PaymentData, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable { + fun continuePayment( + price: FiatMoney, + paymentSource: StripeApi.PaymentSource, + badgeRecipient: RecipientId, + additionalMessage: String?, + badgeLevel: Long + ): Completable { Log.d(TAG, "Creating payment intent for $price...", true) return stripeApi.createPaymentIntent(price, badgeLevel) @@ -151,17 +159,17 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.oneTimeDonationAmountTooSmall(errorSource)) is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.oneTimeDonationAmountTooLarge(errorSource)) is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource)) - is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel) + is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentSource, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel) } }.subscribeOn(Schedulers.io()) } - fun continueSubscriptionSetup(paymentData: PaymentData): Completable { + fun continueSubscriptionSetup(paymentSource: StripeApi.PaymentSource): Completable { Log.d(TAG, "Continuing subscription setup...", true) return stripeApi.createSetupIntent() .flatMapCompletable { result -> Log.d(TAG, "Retrieved SetupIntent, confirming...", true) - stripeApi.confirmSetupIntent(GooglePayPaymentSource(paymentData), result.setupIntent).doOnComplete { + stripeApi.confirmSetupIntent(paymentSource, result.setupIntent).doOnComplete { Log.d(TAG, "Confirmed SetupIntent...", true) } } @@ -203,12 +211,12 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } } - private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable { + private fun confirmPayment(price: FiatMoney, paymentSource: StripeApi.PaymentSource, paymentIntent: StripeApi.PaymentIntent, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable { val isBoost = badgeRecipient == Recipient.self().id val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT Log.d(TAG, "Confirming payment intent...", true) - val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext { + val confirmPayment = stripeApi.confirmPaymentIntent(paymentSource, paymentIntent).onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(donationErrorSource, it)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt index 67e242e10c..ab3bff75e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt @@ -1,10 +1,14 @@ package org.thoughtcrime.securesms.components.settings.app.subscription +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.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.subscription.Subscription import org.thoughtcrime.securesms.util.PlatformCurrencyUtil import org.whispersystems.signalservice.api.services.DonationsService @@ -54,4 +58,11 @@ class SubscriptionsRepository(private val donationsService: DonationsService) { it.level } } + + fun syncAccountRecord(): Completable { + return Completable.fromAction { + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + }.subscribeOn(Schedulers.io()) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt index 79acb9e738..9511d7b98e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.boost import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator +import android.graphics.Typeface +import android.os.Build import android.text.Editable import android.text.Spanned import android.text.TextWatcher @@ -144,7 +146,8 @@ data class Boost( itemView.isEnabled = model.isEnabled model.boosts.zip(boostButtons).forEach { (boost, button) -> - button.isSelected = boost == model.selectedBoost && !model.isCustomAmountFocused + val isSelected = boost == model.selectedBoost && !model.isCustomAmountFocused + button.isSelected = isSelected button.text = FiatMoneyUtil.format( context.resources, boost.price, @@ -156,6 +159,13 @@ data class Boost( model.onBoostClick(it, boost) custom.clearFocus() } + + if (Build.VERSION.SDK_INT >= 28) { + val weight = if (isSelected) 500 else 400 + button.typeface = Typeface.create(null, weight, false) + } else { + button.typeface = if (isSelected) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + } } if (filter == null || filter?.currency != model.currency) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt deleted file mode 100644 index 4bca66b50d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt +++ /dev/null @@ -1,303 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.boost - -import android.content.DialogInterface -import android.text.SpannableStringBuilder -import android.view.View -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 androidx.recyclerview.widget.RecyclerView -import com.airbnb.lottie.LottieAnimationView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import org.signal.core.util.DimensionUnit -import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.badges.models.Badge -import org.thoughtcrime.securesms.badges.models.BadgePreview -import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter -import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment -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.DonationPaymentComponent -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource -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.app.subscription.models.NetworkFailure -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.components.settings.models.Progress -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout -import org.thoughtcrime.securesms.util.CommunicationActions -import org.thoughtcrime.securesms.util.LifecycleDisposable -import org.thoughtcrime.securesms.util.Projection -import org.thoughtcrime.securesms.util.SpanUtil -import org.thoughtcrime.securesms.util.fragments.requireListener -import org.thoughtcrime.securesms.util.navigation.safeNavigate - -/** - * UX to allow users to donate ephemerally. - */ -class BoostFragment : DSLSettingsBottomSheetFragment( - layoutId = R.layout.boost_bottom_sheet -) { - - private val viewModel: BoostViewModel by viewModels( - factoryProducer = { - BoostViewModel.Factory(BoostRepository(ApplicationDependencies.getDonationsService()), donationPaymentComponent.donationPaymentRepository, FETCH_BOOST_TOKEN_REQUEST_CODE) - } - ) - - private val lifecycleDisposable = LifecycleDisposable() - - private lateinit var boost1AnimationView: LottieAnimationView - private lateinit var boost2AnimationView: LottieAnimationView - private lateinit var boost3AnimationView: LottieAnimationView - private lateinit var boost4AnimationView: LottieAnimationView - private lateinit var boost5AnimationView: LottieAnimationView - private lateinit var boost6AnimationView: LottieAnimationView - - private lateinit var processingDonationPaymentDialog: AlertDialog - private lateinit var donationPaymentComponent: DonationPaymentComponent - - private var errorDialog: DialogInterface? = null - - private val sayThanks: CharSequence by lazy { - SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__make_a_one_time, 30)) - .append(" ") - .append( - SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) { - CommunicationActions.openBrowserLink(requireContext(), getString(R.string.sustainer_boost_and_badges)) - } - ) - } - - override fun bindAdapter(adapter: DSLSettingsAdapter) { - donationPaymentComponent = requireListener() - viewModel.refresh() - - CurrencySelection.register(adapter) - BadgePreview.register(adapter) - Boost.register(adapter) - GooglePayButton.register(adapter) - Progress.register(adapter) - NetworkFailure.register(adapter) - BoostAnimation.register(adapter) - - processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext()) - .setView(R.layout.processing_payment_dialog) - .setCancelable(false) - .create() - - recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS - - boost1AnimationView = requireView().findViewById(R.id.boost1_animation) - boost2AnimationView = requireView().findViewById(R.id.boost2_animation) - boost3AnimationView = requireView().findViewById(R.id.boost3_animation) - boost4AnimationView = requireView().findViewById(R.id.boost4_animation) - boost5AnimationView = requireView().findViewById(R.id.boost5_animation) - boost6AnimationView = requireView().findViewById(R.id.boost6_animation) - - KeyboardAwareLinearLayout(requireContext()).apply { - addOnKeyboardHiddenListener { - recyclerView.post { recyclerView.requestLayout() } - } - - addOnKeyboardShownListener { - recyclerView.post { recyclerView.scrollToPosition(adapter.itemCount - 1) } - } - - requireCoordinatorLayout().addView(this) - } - - viewModel.state.observe(viewLifecycleOwner) { state -> - adapter.submitList(getConfiguration(state).toMappingModelList()) - } - - lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle) - lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent -> - when (event) { - is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge) - DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay") - DonationEvent.SubscriptionCancelled -> Unit - is DonationEvent.SubscriptionCancellationFailed -> Unit - } - } - lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe { - viewModel.onActivityResult(it.requestCode, it.resultCode, it.data) - } - - lifecycleDisposable += DonationError - .getErrorsForSource(DonationErrorSource.BOOST) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { donationError -> - onPaymentError(donationError) - } - } - - override fun onDestroyView() { - super.onDestroyView() - processingDonationPaymentDialog.hide() - } - - private fun getConfiguration(state: BoostState): DSLConfiguration { - if (state.stage == BoostState.Stage.PAYMENT_PIPELINE) { - processingDonationPaymentDialog.show() - } else { - processingDonationPaymentDialog.hide() - } - - return configure { - customPref(BoostAnimation.Model()) - - sectionHeaderPref( - title = DSLSettingsText.from( - R.string.BoostFragment__give_signal_a_boost, - DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier - ) - ) - - noPadTextPref( - title = DSLSettingsText.from( - sayThanks, - DSLSettingsText.CenterModifier - ) - ) - - space(DimensionUnit.DP.toPixels(28f).toInt()) - - customPref( - CurrencySelection.Model( - selectedCurrency = state.currencySelection, - isEnabled = state.stage == BoostState.Stage.READY, - onClick = { - findNavController().safeNavigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment(true, viewModel.getSupportedCurrencyCodes().toTypedArray())) - } - ) - ) - - @Suppress("CascadeIf") - if (state.stage == BoostState.Stage.INIT) { - customPref( - Boost.LoadingModel() - ) - } else if (state.stage == BoostState.Stage.FAILURE) { - space(DimensionUnit.DP.toPixels(20f).toInt()) - customPref( - NetworkFailure.Model { - viewModel.retry() - } - ) - } else { - customPref( - Boost.SelectionModel( - boosts = state.boosts, - selectedBoost = state.selectedBoost, - currency = state.customAmount.currency, - isCustomAmountFocused = state.isCustomAmountFocused, - isEnabled = state.stage == BoostState.Stage.READY, - onBoostClick = { view, boost -> - startAnimationAboveSelectedBoost(view) - viewModel.setSelectedBoost(boost) - }, - onCustomAmountChanged = { - viewModel.setCustomAmount(it) - }, - onCustomAmountFocusChanged = { - if (it) { - viewModel.setCustomAmountFocused() - } - } - ) - ) - } - - space(DimensionUnit.DP.toPixels(16f).toInt()) - - customPref( - GooglePayButton.Model( - onClick = this@BoostFragment::onGooglePayButtonClicked, - isEnabled = state.stage == BoostState.Stage.READY - ) - ) - - secondaryButtonNoOutline( - text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options), - icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary), - onClick = { - CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url)) - } - ) - } - } - - private fun onGooglePayButtonClicked() { - viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time_donation)) - } - - private fun onPaymentConfirmed(boostBadge: Badge) { - findNavController().safeNavigate( - BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true), - NavOptions.Builder().setPopUpTo(R.id.boostFragment, true).build() - ) - } - - private fun onPaymentError(throwable: Throwable?) { - Log.w(TAG, "onPaymentError", throwable, true) - - if (errorDialog != null) { - Log.i(TAG, "Already displaying an error dialog. Skipping.") - return - } - - errorDialog = DonationErrorDialogs.show( - requireContext(), throwable, - object : DonationErrorDialogs.DialogCallback() { - override fun onDialogDismissed() { - findNavController().popBackStack() - } - } - ) - } - - private fun startAnimationAboveSelectedBoost(view: View) { - val animationView = getAnimationContainer(view) - val viewProjection = Projection.relativeToViewRoot(view, null) - val animationProjection = Projection.relativeToViewRoot(animationView, null) - val viewHorizontalCenter = viewProjection.x + viewProjection.width / 2f - val animationHorizontalCenter = animationProjection.x + animationProjection.width / 2f - val animationBottom = animationProjection.y + animationProjection.height - - animationView.translationY = -(animationBottom - viewProjection.y) + (viewProjection.height / 2f) - animationView.translationX = viewHorizontalCenter - animationHorizontalCenter - - animationView.playAnimation() - - viewProjection.release() - animationProjection.release() - } - - private fun getAnimationContainer(view: View): LottieAnimationView { - return when (view.id) { - R.id.boost_1 -> boost1AnimationView - R.id.boost_2 -> boost2AnimationView - R.id.boost_3 -> boost3AnimationView - R.id.boost_4 -> boost4AnimationView - R.id.boost_5 -> boost5AnimationView - R.id.boost_6 -> boost6AnimationView - else -> throw AssertionError() - } - } - - companion object { - private val TAG = Log.tag(BoostFragment::class.java) - private const val FETCH_BOOST_TOKEN_REQUEST_CODE = 2000 - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt deleted file mode 100644 index 5684baa8c7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.boost - -import org.signal.core.util.money.FiatMoney -import org.thoughtcrime.securesms.badges.models.Badge -import java.math.BigDecimal -import java.util.Currency - -data class BoostState( - val boostBadge: Badge? = null, - val currencySelection: Currency, - val isGooglePayAvailable: Boolean = false, - val boosts: List = listOf(), - val selectedBoost: Boost? = null, - val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, currencySelection), - val isCustomAmountFocused: Boolean = false, - val stage: Stage = Stage.INIT, - val supportedCurrencyCodes: List = emptyList() -) { - enum class Stage { - INIT, - READY, - TOKEN_REQUEST, - PAYMENT_PIPELINE, - FAILURE - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt deleted file mode 100644 index 6e31a04978..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt +++ /dev/null @@ -1,244 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.boost - -import android.content.Intent -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.disposables.Disposable -import io.reactivex.rxjava3.kotlin.plusAssign -import io.reactivex.rxjava3.kotlin.subscribeBy -import io.reactivex.rxjava3.subjects.PublishSubject -import org.signal.core.util.StringUtil -import org.signal.core.util.logging.Log -import org.signal.core.util.money.FiatMoney -import org.signal.donations.GooglePayApi -import org.thoughtcrime.securesms.badges.models.Badge -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.util.InternetConnectionObserver -import org.thoughtcrime.securesms.util.PlatformCurrencyUtil -import org.thoughtcrime.securesms.util.livedata.Store -import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels -import java.math.BigDecimal -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols -import java.util.Currency - -class BoostViewModel( - private val boostRepository: BoostRepository, - private val donationPaymentRepository: DonationPaymentRepository, - private val fetchTokenRequestCode: Int -) : ViewModel() { - - private val store = Store(BoostState(currencySelection = SignalStore.donationsValues().getOneTimeCurrency())) - private val eventPublisher: PublishSubject = PublishSubject.create() - private val disposables = CompositeDisposable() - private val networkDisposable: Disposable - - val state: LiveData = store.stateLiveData - val events: Observable = eventPublisher.observeOn(AndroidSchedulers.mainThread()) - - private var boostToPurchase: Boost? = null - - init { - networkDisposable = InternetConnectionObserver - .observe() - .distinctUntilChanged() - .subscribe { isConnected -> - if (isConnected) { - retry() - } - } - } - - override fun onCleared() { - networkDisposable.dispose() - disposables.dispose() - } - - fun getSupportedCurrencyCodes(): List { - return store.state.supportedCurrencyCodes - } - - fun retry() { - if (!disposables.isDisposed && store.state.stage == BoostState.Stage.FAILURE) { - store.update { it.copy(stage = BoostState.Stage.INIT) } - refresh() - } - } - - fun refresh() { - disposables.clear() - - val currencyObservable = SignalStore.donationsValues().observableOneTimeCurrency - val allBoosts = boostRepository.getBoosts() - val boostBadge = boostRepository.getBoostBadge() - - disposables += Observable.combineLatest(currencyObservable, allBoosts.toObservable(), boostBadge.toObservable()) { currency, boostMap, badge -> - val boostList = if (currency in boostMap) { - boostMap[currency]!! - } else { - SignalStore.donationsValues().setOneTimeCurrency(PlatformCurrencyUtil.USD) - listOf() - } - - BoostInfo(boostList, boostList[2], badge, boostMap.keys) - }.subscribeBy( - onNext = { info -> - store.update { - it.copy( - boosts = info.boosts, - selectedBoost = if (it.selectedBoost in info.boosts) it.selectedBoost else info.defaultBoost, - boostBadge = it.boostBadge ?: info.boostBadge, - stage = if (it.stage == BoostState.Stage.INIT || it.stage == BoostState.Stage.FAILURE) BoostState.Stage.READY else it.stage, - supportedCurrencyCodes = info.supportedCurrencies.map(Currency::getCurrencyCode) - ) - } - }, - onError = { throwable -> - Log.w(TAG, "Could not load boost information", throwable) - store.update { - it.copy(stage = BoostState.Stage.FAILURE) - } - } - ) - - disposables += currencyObservable.subscribeBy { currency -> - store.update { - it.copy( - currencySelection = currency, - isCustomAmountFocused = false, - customAmount = FiatMoney( - BigDecimal.ZERO, currency - ) - ) - } - } - } - - fun onActivityResult( - requestCode: Int, - resultCode: Int, - data: Intent? - ) { - val boost = boostToPurchase - boostToPurchase = null - - donationPaymentRepository.onActivityResult( - requestCode, resultCode, data, this.fetchTokenRequestCode, - object : GooglePayApi.PaymentRequestCallback { - override fun onSuccess(paymentData: PaymentData) { - if (boost != null) { - eventPublisher.onNext(DonationEvent.RequestTokenSuccess) - - store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) } - - donationPaymentRepository.continuePayment(boost.price, paymentData, Recipient.self().id, null, SubscriptionLevels.BOOST_LEVEL.toLong()).subscribeBy( - onError = { throwable -> - store.update { it.copy(stage = BoostState.Stage.READY) } - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - Log.w(TAG, "Failed to complete payment or redemption", throwable, true) - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST) - } - DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) - }, - onComplete = { - 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(googlePayException: GooglePayApi.GooglePayException) { - store.update { it.copy(stage = BoostState.Stage.READY) } - DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.BOOST, googlePayException)) - } - - override fun onCancelled() { - store.update { it.copy(stage = BoostState.Stage.READY) } - } - } - ) - } - - fun requestTokenFromGooglePay(label: String) { - val snapshot = store.state - if (snapshot.selectedBoost == null) { - return - } - - store.update { it.copy(stage = BoostState.Stage.TOKEN_REQUEST) } - - val boost = if (snapshot.isCustomAmountFocused) { - Log.d(TAG, "Boosting with custom amount ${snapshot.customAmount}") - Boost(snapshot.customAmount) - } else { - Log.d(TAG, "Boosting with preset amount ${snapshot.selectedBoost.price}") - snapshot.selectedBoost - } - - boostToPurchase = boost - donationPaymentRepository.requestTokenFromGooglePay(boost.price, label, fetchTokenRequestCode) - } - - fun setSelectedBoost(boost: Boost) { - store.update { - it.copy( - isCustomAmountFocused = false, - selectedBoost = boost - ) - } - } - - fun setCustomAmount(rawAmount: String) { - val amount = StringUtil.stripBidiIndicator(rawAmount) - val bigDecimalAmount: BigDecimal = if (amount.isEmpty() || amount == DecimalFormatSymbols.getInstance().decimalSeparator.toString()) { - BigDecimal.ZERO - } else { - val decimalFormat = DecimalFormat.getInstance() as DecimalFormat - decimalFormat.isParseBigDecimal = true - - try { - decimalFormat.parse(amount) as BigDecimal - } catch (e: NumberFormatException) { - BigDecimal.ZERO - } - } - - store.update { it.copy(customAmount = FiatMoney(bigDecimalAmount, it.customAmount.currency)) } - } - - fun setCustomAmountFocused() { - store.update { it.copy(isCustomAmountFocused = true) } - } - - private data class BoostInfo(val boosts: List, val defaultBoost: Boost?, val boostBadge: Badge, val supportedCurrencies: Set) - - class Factory( - private val boostRepository: BoostRepository, - private val donationPaymentRepository: DonationPaymentRepository, - private val fetchTokenRequestCode: Int - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!! - } - } - - companion object { - private val TAG = Log.tag(BoostViewModel::class.java) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalAction.kt new file mode 100644 index 0000000000..21edbabdd2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalAction.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest + +sealed class DonateToSignalAction { + data class DisplayCurrencySelectionDialog(val donateToSignalType: DonateToSignalType, val supportedCurrencies: List) : DonateToSignalAction() + data class DisplayGatewaySelectorDialog(val gatewayRequest: GatewayRequest) : DonateToSignalAction() + data class CancelSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction() + data class UpdateSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt new file mode 100644 index 0000000000..a51f6eb168 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -0,0 +1,519 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import android.text.SpannableStringBuilder +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.navigation.navGraphViewModels +import androidx.recyclerview.widget.RecyclerView +import com.airbnb.lottie.LottieAnimationView +import com.google.android.gms.wallet.PaymentData +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.dp +import org.signal.core.util.logging.Log +import org.signal.core.util.money.FiatMoney +import org.signal.donations.GooglePayApi +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.models.BadgePreview +import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.components.WrapperDialogFragment +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripeAction +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripeActionResult +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection +import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.databinding.DonateToSignalFragmentBinding +import org.thoughtcrime.securesms.payments.FiatMoneyUtil +import org.thoughtcrime.securesms.subscription.Subscription +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.Material3OnScrollHelper +import org.thoughtcrime.securesms.util.Projection +import org.thoughtcrime.securesms.util.SpanUtil +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.fragments.requireListener +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import java.util.Currency + +/** + * Unified donation fragment which allows users to choose between monthly or one-time donations. + */ +class DonateToSignalFragment : DSLSettingsFragment( + layoutId = R.layout.donate_to_signal_fragment +) { + + companion object { + private val TAG = Log.tag(DonateToSignalFragment::class.java) + } + + class Dialog : WrapperDialogFragment() { + + override fun getWrappedFragment(): Fragment { + return NavHostFragment.create( + R.navigation.donate_to_signal, + arguments + ) + } + + companion object { + @JvmStatic + fun create(donateToSignalType: DonateToSignalType): DialogFragment { + return Dialog().apply { + arguments = DonateToSignalFragmentArgs.Builder(donateToSignalType).build().toBundle() + } + } + } + } + + private val args: DonateToSignalFragmentArgs by navArgs() + private val viewModel: DonateToSignalViewModel by viewModels(factoryProducer = { + DonateToSignalViewModel.Factory(args.startType) + }) + + private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels( + R.id.donate_to_signal, + factoryProducer = { + donationPaymentComponent = requireListener() + StripePaymentInProgressViewModel.Factory(donationPaymentComponent.donationPaymentRepository) + } + ) + + private val disposables = LifecycleDisposable() + private val binding by ViewBinderDelegate(DonateToSignalFragmentBinding::bind) + + private lateinit var donationPaymentComponent: DonationPaymentComponent + + private val supportTechSummary: CharSequence by lazy { + SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__support_technology))) + .append(" ") + .append( + SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary)) { + findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToSubscribeLearnMoreBottomSheetDialog()) + } + ) + } + + override fun onToolbarNavigationClicked() { + findNavController().popBackStack() + } + + override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper { + return object : Material3OnScrollHelper(requireActivity(), toolbar!!) { + override val activeColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground) + override val inactiveColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground) + } + } + + override fun bindAdapter(adapter: MappingAdapter) { + donationPaymentComponent = requireListener() + registerGooglePayCallback() + + setFragmentResultListener(GatewaySelectorBottomSheet.REQUEST_KEY) { _, bundle -> + val response: GatewayResponse = bundle.getParcelable(GatewaySelectorBottomSheet.REQUEST_KEY)!! + handleGatewaySelectionResponse(response) + } + + setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle -> + val result: StripeActionResult = bundle.getParcelable(StripePaymentInProgressFragment.REQUEST_KEY)!! + handleStripeActionResult(result) + } + + val recyclerView = this.recyclerView!! + recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS + + KeyboardAwareLinearLayout(requireContext()).apply { + addOnKeyboardHiddenListener { + recyclerView.post { recyclerView.requestLayout() } + } + + addOnKeyboardShownListener { + recyclerView.post { recyclerView.scrollToPosition(adapter.itemCount - 1) } + } + + (view as ViewGroup).addView(this) + } + + Boost.register(adapter) + Subscription.register(adapter) + NetworkFailure.register(adapter) + BadgePreview.register(adapter) + CurrencySelection.register(adapter) + DonationPillToggle.register(adapter) + + disposables.bindTo(viewLifecycleOwner) + + disposables += DonationError.getErrorsForSource(DonationErrorSource.BOOST).subscribe { error -> + showErrorDialog(error) + } + + disposables += DonationError.getErrorsForSource(DonationErrorSource.SUBSCRIPTION).subscribe { error -> + showErrorDialog(error) + } + + disposables += viewModel.actions.subscribe { action -> + when (action) { + is DonateToSignalAction.DisplayCurrencySelectionDialog -> { + val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToSetDonationCurrencyFragment( + action.donateToSignalType == DonateToSignalType.ONE_TIME, + action.supportedCurrencies.toTypedArray() + ) + + findNavController().safeNavigate(navAction) + } + is DonateToSignalAction.DisplayGatewaySelectorDialog -> { + Log.d(TAG, "Presenting gateway selector for ${action.gatewayRequest}") + val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.gatewayRequest) + + findNavController().safeNavigate(navAction) + } + is DonateToSignalAction.CancelSubscription -> { + findNavController().safeNavigate( + DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment( + StripeAction.CANCEL_SUBSCRIPTION, + action.gatewayRequest + ) + ) + } + is DonateToSignalAction.UpdateSubscription -> { + findNavController().safeNavigate( + DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment( + StripeAction.UPDATE_SUBSCRIPTION, + action.gatewayRequest + ) + ) + } + } + } + + disposables += viewModel.state.subscribe { state -> + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + } + + private fun getConfiguration(state: DonateToSignalState): DSLConfiguration { + return configure { + space(36.dp) + + customPref(BadgePreview.BadgeModel.SubscriptionModel(state.badge)) + + noPadTextPref( + title = DSLSettingsText.from( + R.string.DonateToSignalFragment__powered_by, + DSLSettingsText.CenterModifier, + DSLSettingsText.TitleLargeModifier + ) + ) + + noPadTextPref( + title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier) + ) + + space(24.dp) + + customPref( + CurrencySelection.Model( + selectedCurrency = state.selectedCurrency, + isEnabled = state.canSetCurrency, + onClick = { + viewModel.requestChangeCurrency() + } + ) + ) + + space(16.dp) + + customPref( + DonationPillToggle.Model( + isEnabled = state.areFieldsEnabled, + selected = state.donateToSignalType, + onClick = { + viewModel.toggleDonationType() + } + ) + ) + + space(10.dp) + + when (state.donateToSignalType) { + DonateToSignalType.ONE_TIME -> displayOneTimeSelection(state.areFieldsEnabled, state.oneTimeDonationState) + DonateToSignalType.MONTHLY -> displayMonthlySelection(state.areFieldsEnabled, state.monthlyDonationState) + } + + space(20.dp) + + if (state.donateToSignalType == DonateToSignalType.MONTHLY && state.monthlyDonationState.isSubscriptionActive) { + primaryButton( + text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription), + isEnabled = state.canContinue, + onClick = { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.SubscribeFragment__update_subscription_question) + .setMessage( + getString( + R.string.SubscribeFragment__you_will_be_charged_the_full_amount_s_of, + FiatMoneyUtil.format( + requireContext().resources, + viewModel.getSelectedSubscriptionCost(), + FiatMoneyUtil.formatOptions().trimZerosAfterDecimal() + ) + ) + ) + .setPositiveButton(R.string.SubscribeFragment__update) { _, _ -> + viewModel.updateSubscription() + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .show() + } + ) + + space(4.dp) + + secondaryButtonNoOutline( + text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription), + isEnabled = state.areFieldsEnabled, + onClick = { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.SubscribeFragment__confirm_cancellation) + .setMessage(R.string.SubscribeFragment__you_wont_be_charged_again) + .setPositiveButton(R.string.SubscribeFragment__confirm) { _, _ -> + viewModel.cancelSubscription() + } + .setNegativeButton(R.string.SubscribeFragment__not_now) { _, _ -> } + .show() + } + ) + } else { + primaryButton( + text = DSLSettingsText.from(R.string.DonateToSignalFragment__continue), + isEnabled = state.canContinue, + onClick = { + viewModel.requestSelectGateway() + } + ) + } + } + } + + private fun DSLConfiguration.displayOneTimeSelection(areFieldsEnabled: Boolean, state: DonateToSignalState.OneTimeDonationState) { + when (state.donationStage) { + DonateToSignalState.DonationStage.INIT -> customPref(Boost.LoadingModel()) + DonateToSignalState.DonationStage.FAILURE -> customPref(NetworkFailure.Model { viewModel.retryOneTimeDonationState() }) + DonateToSignalState.DonationStage.READY -> { + customPref( + Boost.SelectionModel( + boosts = state.boosts, + selectedBoost = state.selectedBoost, + currency = state.customAmount.currency, + isCustomAmountFocused = state.isCustomAmountFocused, + isEnabled = areFieldsEnabled, + onBoostClick = { view, boost -> + startAnimationAboveSelectedBoost(view) + viewModel.setSelectedBoost(boost) + }, + onCustomAmountChanged = { + viewModel.setCustomAmount(it) + }, + onCustomAmountFocusChanged = { + if (it) { + viewModel.setCustomAmountFocused() + } + } + ) + ) + } + } + } + + private fun DSLConfiguration.displayMonthlySelection(areFieldsEnabled: Boolean, state: DonateToSignalState.MonthlyDonationState) { + if (state.transactionState.isTransactionJobPending) { + customPref(Subscription.LoaderModel()) + return + } + + when (state.donationStage) { + DonateToSignalState.DonationStage.INIT -> customPref(Subscription.LoaderModel()) + DonateToSignalState.DonationStage.FAILURE -> customPref(NetworkFailure.Model { viewModel.retryMonthlyDonationState() }) + else -> { + state.subscriptions.forEach { subscription -> + + val isActive = state.activeLevel == subscription.level && state.isSubscriptionActive + + val activePrice = state.activeSubscription?.let { sub -> + val activeCurrency = Currency.getInstance(sub.currency) + val activeAmount = sub.amount.movePointLeft(activeCurrency.defaultFractionDigits) + + FiatMoney(activeAmount, activeCurrency) + } + + customPref( + Subscription.Model( + activePrice = if (isActive) activePrice else null, + subscription = subscription, + isSelected = state.selectedSubscription == subscription, + isEnabled = areFieldsEnabled, + isActive = isActive, + willRenew = isActive && !state.isActiveSubscriptionEnding, + onClick = { viewModel.setSelectedSubscription(it) }, + renewalTimestamp = state.renewalTimestamp, + selectedCurrency = state.selectedCurrency + ) + ) + } + } + } + } + + private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) { + when (gatewayResponse.gateway) { + GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse) + GatewayResponse.Gateway.PAYPAL -> error("PayPal is not currently supported.") + GatewayResponse.Gateway.CREDIT_CARD -> error("Credit cards are not currently supported.") + } + } + + private fun handleStripeActionResult(result: StripeActionResult) { + when (result.status) { + StripeActionResult.Status.SUCCESS -> handleSuccessfulStripeActionResult(result) + StripeActionResult.Status.FAILURE -> handleFailedStripeActionResult(result) + } + + viewModel.refreshActiveSubscription() + } + + private fun handleSuccessfulStripeActionResult(result: StripeActionResult) { + if (result.action == StripeAction.CANCEL_SUBSCRIPTION) { + Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show() + } else { + findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(result.request.badge)) + } + } + + private fun handleFailedStripeActionResult(result: StripeActionResult) { + if (result.action == StripeAction.CANCEL_SUBSCRIPTION) { + 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) { _, _ -> + findNavController().popBackStack() + } + .show() + } else { + Log.w(TAG, "Stripe action failed: ${result.action}") + } + } + + private fun launchGooglePay(gatewayResponse: GatewayResponse) { + viewModel.provideGatewayRequest(gatewayResponse.request) + donationPaymentComponent.donationPaymentRepository.requestTokenFromGooglePay( + price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)), + label = gatewayResponse.request.label, + requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt() + ) + } + + private fun registerGooglePayCallback() { + donationPaymentComponent.googlePayResultPublisher.subscribeBy( + onNext = { paymentResult -> + viewModel.consumeGatewayRequest()?.let { + donationPaymentComponent.donationPaymentRepository.onActivityResult( + paymentResult.requestCode, + paymentResult.resultCode, + paymentResult.data, + paymentResult.requestCode, + GooglePayRequestCallback(it) + ) + } + } + ) + } + + private fun showErrorDialog(throwable: Throwable) { + Log.d(TAG, "Displaying donation error dialog.", true) + DonationErrorDialogs.show( + requireContext(), throwable, + object : DonationErrorDialogs.DialogCallback() { + override fun onDialogDismissed() { + findNavController().popBackStack() + } + } + ) + } + + private fun startAnimationAboveSelectedBoost(view: View) { + val animationView = getAnimationContainer(view) + val viewProjection = Projection.relativeToViewRoot(view, null) + val animationProjection = Projection.relativeToViewRoot(animationView, null) + val viewHorizontalCenter = viewProjection.x + viewProjection.width / 2f + val animationHorizontalCenter = animationProjection.x + animationProjection.width / 2f + val animationBottom = animationProjection.y + animationProjection.height + + animationView.translationY = -(animationBottom - viewProjection.y) + (viewProjection.height / 2f) + animationView.translationX = viewHorizontalCenter - animationHorizontalCenter + + animationView.playAnimation() + + viewProjection.release() + animationProjection.release() + } + + private fun getAnimationContainer(view: View): LottieAnimationView { + return when (view.id) { + R.id.boost_1 -> binding.boost1Animation + R.id.boost_2 -> binding.boost2Animation + R.id.boost_3 -> binding.boost3Animation + R.id.boost_4 -> binding.boost4Animation + R.id.boost_5 -> binding.boost5Animation + R.id.boost_6 -> binding.boost6Animation + else -> throw AssertionError() + } + } + + inner class GooglePayRequestCallback(private val request: GatewayRequest) : GooglePayApi.PaymentRequestCallback { + override fun onSuccess(paymentData: PaymentData) { + Log.d(TAG, "Successfully retrieved payment data from Google Pay", true) + stripePaymentViewModel.providePaymentData(paymentData) + findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(StripeAction.PROCESS_NEW_DONATION, request)) + } + + override fun onError(googlePayException: GooglePayApi.GooglePayException) { + Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true) + + val error = DonationError.getGooglePayRequestTokenError( + source = when (request.donateToSignalType) { + DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION + DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST + }, + throwable = googlePayException + ) + + DonationError.routeDonationError(requireContext(), error) + } + + override fun onCancelled() { + Log.d(TAG, "Cancelled Google Pay.", true) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt new file mode 100644 index 0000000000..9f5a581a58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import org.signal.core.util.money.FiatMoney +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.subscription.Subscription +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription +import java.math.BigDecimal +import java.util.Currency +import java.util.concurrent.TimeUnit + +data class DonateToSignalState( + val donateToSignalType: DonateToSignalType, + val oneTimeDonationState: OneTimeDonationState = OneTimeDonationState(), + val monthlyDonationState: MonthlyDonationState = MonthlyDonationState() +) { + + val areFieldsEnabled: Boolean + get() = when (donateToSignalType) { + DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY + DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress + } + + val badge: Badge? + get() = when (donateToSignalType) { + DonateToSignalType.ONE_TIME -> oneTimeDonationState.badge + DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription?.badge + } + + val canSetCurrency: Boolean + get() = when (donateToSignalType) { + DonateToSignalType.ONE_TIME -> areFieldsEnabled + DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive + } + + val selectedCurrency: Currency + get() = when (donateToSignalType) { + DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectedCurrency + DonateToSignalType.MONTHLY -> monthlyDonationState.selectedCurrency + } + + val selectableCurrencyCodes: List + get() = when (donateToSignalType) { + DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectableCurrencyCodes + DonateToSignalType.MONTHLY -> monthlyDonationState.selectableCurrencyCodes + } + + val level: Int + get() = when (donateToSignalType) { + DonateToSignalType.ONE_TIME -> 1 + DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription!!.level + } + + val canContinue: Boolean + get() = when (donateToSignalType) { + DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid + DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid + } + + data class OneTimeDonationState( + val badge: Badge? = null, + val selectedCurrency: Currency = SignalStore.donationsValues().getOneTimeCurrency(), + val boosts: List = emptyList(), + val selectedBoost: Boost? = null, + val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, selectedCurrency), + val isCustomAmountFocused: Boolean = false, + val donationStage: DonationStage = DonationStage.INIT, + val selectableCurrencyCodes: List = emptyList() + ) { + val isSelectionValid: Boolean = if (isCustomAmountFocused) customAmount.amount > BigDecimal.ZERO else selectedBoost != null + } + + data class MonthlyDonationState( + val selectedCurrency: Currency = SignalStore.donationsValues().getSubscriptionCurrency(), + val subscriptions: List = emptyList(), + private val _activeSubscription: ActiveSubscription? = null, + val selectedSubscription: Subscription? = null, + val donationStage: DonationStage = DonationStage.INIT, + val selectableCurrencyCodes: List = emptyList(), + val transactionState: TransactionState = TransactionState() + ) { + val isSubscriptionActive: Boolean = _activeSubscription?.isActive == true + val activeLevel: Int? = _activeSubscription?.activeSubscription?.level + val activeSubscription: ActiveSubscription.Subscription? = _activeSubscription?.activeSubscription + val isActiveSubscriptionEnding: Boolean = _activeSubscription?.isActive == true && _activeSubscription.activeSubscription.willCancelAtPeriodEnd() + val renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription?.endOfCurrentPeriod ?: 0L) + val isSelectionValid = selectedSubscription != null && (!isSubscriptionActive || selectedSubscription.level != activeSubscription?.level) + } + + enum class DonationStage { + INIT, + READY, + FAILURE + } + + data class TransactionState( + val isTransactionJobPending: Boolean = false, + val isLevelUpdateInProgress: Boolean = false + ) { + val isInProgress: Boolean = isTransactionJobPending || isLevelUpdateInProgress + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt new file mode 100644 index 0000000000..d449383e25 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class DonateToSignalType(val requestCode: Short) : Parcelable { + ONE_TIME(16141), + MONTHLY(16142); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt new file mode 100644 index 0000000000..bd5b5f78cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -0,0 +1,372 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +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 +import io.reactivex.rxjava3.subjects.PublishSubject +import org.signal.core.util.StringUtil +import org.signal.core.util.logging.Log +import org.signal.core.util.money.FiatMoney +import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost +import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest +import org.thoughtcrime.securesms.components.settings.app.subscription.manage.SubscriptionRedemptionJobWatcher +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.JobTracker +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.subscription.LevelUpdate +import org.thoughtcrime.securesms.subscription.Subscriber +import org.thoughtcrime.securesms.subscription.Subscription +import org.thoughtcrime.securesms.util.InternetConnectionObserver +import org.thoughtcrime.securesms.util.PlatformCurrencyUtil +import org.thoughtcrime.securesms.util.next +import org.thoughtcrime.securesms.util.rx.RxStore +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import org.whispersystems.signalservice.api.util.Preconditions +import java.math.BigDecimal +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Currency + +/** + * Contains the logic to manage the UI of the unified donations screen. + * Does not directly deal with performing payments, this ViewModel is + * only in charge of rendering our "current view of the world." + */ +class DonateToSignalViewModel( + startType: DonateToSignalType, + private val subscriptionsRepository: SubscriptionsRepository, + private val boostRepository: BoostRepository +) : ViewModel() { + + companion object { + private val TAG = Log.tag(DonateToSignalViewModel::class.java) + } + + private val store = RxStore(DonateToSignalState(donateToSignalType = startType)) + private val oneTimeDonationDisposables = CompositeDisposable() + private val monthlyDonationDisposables = CompositeDisposable() + private val networkDisposable = CompositeDisposable() + private val _actions = PublishSubject.create() + private val _activeSubscription = PublishSubject.create() + + private var gatewayRequest: GatewayRequest? = null + + val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) + val actions: Observable = _actions.observeOn(AndroidSchedulers.mainThread()) + + init { + initializeOneTimeDonationState(boostRepository) + initializeMonthlyDonationState(subscriptionsRepository) + + networkDisposable += InternetConnectionObserver + .observe() + .distinctUntilChanged() + .subscribe { isConnected -> + if (isConnected) { + retryMonthlyDonationState() + retryOneTimeDonationState() + } + } + } + + fun retryMonthlyDonationState() { + if (!monthlyDonationDisposables.isDisposed && store.state.monthlyDonationState.donationStage == DonateToSignalState.DonationStage.FAILURE) { + store.update { it.copy(monthlyDonationState = it.monthlyDonationState.copy(donationStage = DonateToSignalState.DonationStage.INIT)) } + initializeMonthlyDonationState(subscriptionsRepository) + } + } + + fun retryOneTimeDonationState() { + if (!oneTimeDonationDisposables.isDisposed && store.state.oneTimeDonationState.donationStage == DonateToSignalState.DonationStage.FAILURE) { + store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(donationStage = DonateToSignalState.DonationStage.INIT)) } + initializeOneTimeDonationState(boostRepository) + } + } + + fun requestChangeCurrency() { + val snapshot = store.state + if (snapshot.canSetCurrency) { + _actions.onNext(DonateToSignalAction.DisplayCurrencySelectionDialog(snapshot.donateToSignalType, snapshot.selectableCurrencyCodes)) + } + } + + fun requestSelectGateway() { + val snapshot = store.state + if (snapshot.areFieldsEnabled) { + _actions.onNext(DonateToSignalAction.DisplayGatewaySelectorDialog(createGatewayRequest(snapshot))) + } + } + + fun updateSubscription() { + val snapshot = store.state + if (snapshot.areFieldsEnabled) { + _actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot))) + } + } + + fun cancelSubscription() { + val snapshot = store.state + if (snapshot.areFieldsEnabled) { + _actions.onNext(DonateToSignalAction.CancelSubscription(createGatewayRequest(snapshot))) + } + } + + fun toggleDonationType() { + store.update { it.copy(donateToSignalType = it.donateToSignalType.next()) } + } + + fun setSelectedSubscription(subscription: Subscription) { + store.update { it.copy(monthlyDonationState = it.monthlyDonationState.copy(selectedSubscription = subscription)) } + } + + fun setSelectedBoost(boost: Boost) { + store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(selectedBoost = boost, isCustomAmountFocused = false)) } + } + + fun setCustomAmountFocused() { + store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(isCustomAmountFocused = true)) } + } + + fun setCustomAmount(rawAmount: String) { + val amount = StringUtil.stripBidiIndicator(rawAmount) + val bigDecimalAmount: BigDecimal = if (amount.isEmpty() || amount == DecimalFormatSymbols.getInstance().decimalSeparator.toString()) { + BigDecimal.ZERO + } else { + val decimalFormat = DecimalFormat.getInstance() as DecimalFormat + decimalFormat.isParseBigDecimal = true + + try { + decimalFormat.parse(amount) as BigDecimal + } catch (e: NumberFormatException) { + BigDecimal.ZERO + } + } + + store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(customAmount = FiatMoney(bigDecimalAmount, it.oneTimeDonationState.customAmount.currency))) } + } + + fun getSelectedSubscriptionCost(): FiatMoney { + return store.state.monthlyDonationState.selectedSubscription!!.prices.first { it.currency == store.state.selectedCurrency } + } + + fun refreshActiveSubscription() { + subscriptionsRepository + .getActiveSubscription() + .subscribeBy( + onSuccess = { + _activeSubscription.onNext(it) + }, + onError = { + _activeSubscription.onNext(ActiveSubscription.EMPTY) + } + ) + } + + private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest { + val amount = getAmount(snapshot) + return GatewayRequest( + donateToSignalType = snapshot.donateToSignalType, + badge = snapshot.badge!!, + label = snapshot.badge!!.description, + price = amount.amount, + currencyCode = amount.currency.currencyCode, + level = snapshot.level.toLong() + ) + } + + private fun getAmount(snapshot: DonateToSignalState): FiatMoney { + return when (snapshot.donateToSignalType) { + DonateToSignalType.ONE_TIME -> getOneTimeAmount(snapshot.oneTimeDonationState) + DonateToSignalType.MONTHLY -> getSelectedSubscriptionCost() + } + } + + private fun getOneTimeAmount(snapshot: DonateToSignalState.OneTimeDonationState): FiatMoney { + return if (snapshot.isCustomAmountFocused) { + snapshot.customAmount + } else { + snapshot.selectedBoost!!.price + } + } + + private fun initializeOneTimeDonationState(boostRepository: BoostRepository) { + oneTimeDonationDisposables += boostRepository.getBoostBadge().subscribeBy( + onSuccess = { badge -> + store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) } + }, + onError = { + Log.w(TAG, "Could not load boost badge", it) + } + ) + + val boosts: Observable>> = boostRepository.getBoosts().toObservable() + val oneTimeCurrency: Observable = SignalStore.donationsValues().observableOneTimeCurrency + + oneTimeDonationDisposables += Observable.combineLatest(boosts, oneTimeCurrency) { boostMap, currency -> + val boostList = if (currency in boostMap) { + boostMap[currency]!! + } else { + SignalStore.donationsValues().setOneTimeCurrency(PlatformCurrencyUtil.USD) + listOf() + } + + Triple(boostList, currency, boostMap.keys) + }.subscribeBy( + onNext = { (boostList, currency, availableCurrencies) -> + store.update { state -> + state.copy( + oneTimeDonationState = state.oneTimeDonationState.copy( + boosts = boostList, + selectedCurrency = currency, + donationStage = DonateToSignalState.DonationStage.READY, + selectableCurrencyCodes = availableCurrencies.map(Currency::getCurrencyCode), + isCustomAmountFocused = false, + customAmount = FiatMoney( + BigDecimal.ZERO, currency + ) + ) + ) + } + }, + onError = { + Log.w(TAG, "Could not load boost information", it) + store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(donationStage = DonateToSignalState.DonationStage.FAILURE)) } + } + ) + } + + private fun initializeMonthlyDonationState(subscriptionsRepository: SubscriptionsRepository) { + monitorLevelUpdateProcessing() + + val allSubscriptions = subscriptionsRepository.getSubscriptions() + ensureValidSubscriptionCurrency(allSubscriptions) + monitorSubscriptionCurrency() + monitorSubscriptionState(allSubscriptions) + refreshActiveSubscription() + } + + private fun monitorLevelUpdateProcessing() { + val isTransactionJobInProgress: Observable = SubscriptionRedemptionJobWatcher.watch().map { + it.map { jobState -> + when (jobState) { + JobTracker.JobState.PENDING -> true + JobTracker.JobState.RUNNING -> true + else -> false + } + }.orElse(false) + } + + monthlyDonationDisposables += Observable + .combineLatest(isTransactionJobInProgress, LevelUpdate.isProcessing, DonateToSignalState::TransactionState) + .subscribeBy { transactionState -> + store.update { state -> + state.copy( + monthlyDonationState = state.monthlyDonationState.copy( + transactionState = transactionState + ) + ) + } + } + } + + private fun monitorSubscriptionState(allSubscriptions: Single>) { + monthlyDonationDisposables += Observable.combineLatest(allSubscriptions.toObservable(), _activeSubscription, ::Pair).subscribeBy( + onNext = { (subs, active) -> + store.update { state -> + state.copy( + monthlyDonationState = state.monthlyDonationState.copy( + subscriptions = subs, + selectedSubscription = state.monthlyDonationState.selectedSubscription ?: resolveSelectedSubscription(active, subs), + _activeSubscription = active, + donationStage = DonateToSignalState.DonationStage.READY, + selectableCurrencyCodes = subs.firstOrNull()?.prices?.map { it.currency.currencyCode } ?: emptyList() + ) + ) + } + }, + onError = { + store.update { state -> + state.copy( + monthlyDonationState = state.monthlyDonationState.copy( + donationStage = DonateToSignalState.DonationStage.FAILURE + ) + ) + } + } + ) + } + + private fun resolveSelectedSubscription(activeSubscription: ActiveSubscription, subscriptions: List): Subscription? { + return if (activeSubscription.isActive) { + subscriptions.firstOrNull { it.level == activeSubscription.activeSubscription.level } + } else { + subscriptions.firstOrNull() + } + } + + private fun ensureValidSubscriptionCurrency(allSubscriptions: Single>) { + monthlyDonationDisposables += allSubscriptions.subscribeBy( + onSuccess = { subscriptions -> + if (subscriptions.isNotEmpty()) { + val priceCurrencies = subscriptions[0].prices.map { it.currency } + val selectedCurrency = SignalStore.donationsValues().getSubscriptionCurrency() + + if (selectedCurrency !in priceCurrencies) { + Log.w(TAG, "Unsupported currency selection. Defaulting to USD. $selectedCurrency isn't supported.") + val usd = PlatformCurrencyUtil.USD + val newSubscriber = SignalStore.donationsValues().getSubscriber(usd) ?: Subscriber(SubscriberId.generate(), usd.currencyCode) + SignalStore.donationsValues().setSubscriber(newSubscriber) + subscriptionsRepository.syncAccountRecord().subscribe() + } + } + }, + onError = {} + ) + } + + private fun monitorSubscriptionCurrency() { + monthlyDonationDisposables += SignalStore.donationsValues().observableSubscriptionCurrency.subscribe { + store.update { state -> + state.copy(monthlyDonationState = state.monthlyDonationState.copy(selectedCurrency = it)) + } + } + } + + override fun onCleared() { + super.onCleared() + oneTimeDonationDisposables.clear() + monthlyDonationDisposables.clear() + networkDisposable.clear() + store.dispose() + } + + fun provideGatewayRequest(request: GatewayRequest) { + Log.d(TAG, "Provided with a gateway request.") + Preconditions.checkState(gatewayRequest == null) + gatewayRequest = request + } + + fun consumeGatewayRequest(): GatewayRequest? { + val request = gatewayRequest + gatewayRequest = null + return request + } + + class Factory( + private val startType: DonateToSignalType, + private val subscriptionsRepository: SubscriptionsRepository = SubscriptionsRepository(ApplicationDependencies.getDonationsService()), + private val boostRepository: BoostRepository = BoostRepository(ApplicationDependencies.getDonationsService()) + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, boostRepository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt new file mode 100644 index 0000000000..6dbc738efe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import com.google.android.material.button.MaterialButton +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.databinding.DonationPillToggleBinding +import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory +import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel + +object DonationPillToggle { + + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, DonationPillToggleBinding::inflate)) + } + + class Model( + val isEnabled: Boolean, + val selected: DonateToSignalType, + val onClick: () -> Unit + ) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean = true + + override fun areContentsTheSame(newItem: Model): Boolean { + return isEnabled == newItem.isEnabled && selected == newItem.selected + } + } + + private class ViewHolder(binding: DonationPillToggleBinding) : BindingViewHolder(binding) { + override fun bind(model: Model) { + when (model.selected) { + DonateToSignalType.ONE_TIME -> { + presentButtons(model, binding.oneTime, binding.monthly) + } + DonateToSignalType.MONTHLY -> { + presentButtons(model, binding.monthly, binding.oneTime) + } + } + } + + private fun presentButtons(model: Model, selected: MaterialButton, notSelected: MaterialButton) { + selected.setOnClickListener(null) + notSelected.setOnClickListener { model.onClick() } + selected.isSelected = true + notSelected.isSelected = false + selected.setIconResource(R.drawable.ic_check_24) + notSelected.icon = null + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt new file mode 100644 index 0000000000..a43b803f39 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.signal.core.util.money.FiatMoney +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType +import java.math.BigDecimal +import java.util.Currency + +@Parcelize +data class GatewayRequest( + val donateToSignalType: DonateToSignalType, + val badge: Badge, + val label: String, + val price: BigDecimal, + val currencyCode: String, + val level: Long +) : Parcelable { + val fiat: FiatMoney = FiatMoney(price, Currency.getInstance(currencyCode)) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt new file mode 100644 index 0000000000..01537328da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) : Parcelable { + enum class Gateway { + GOOGLE_PAY, + PAYPAL, + CREDIT_CARD + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt new file mode 100644 index 0000000000..beaec82175 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import org.signal.core.util.dp +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.models.BadgeDisplay112 +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType +import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.payments.FiatMoneyUtil +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.fragments.requireListener + +/** + * Entry point to capturing the necessary payment token to pay for a donation + */ +class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() { + + private val lifecycleDisposable = LifecycleDisposable() + + private val args: GatewaySelectorBottomSheetArgs by navArgs() + + private val viewModel: GatewaySelectorViewModel by viewModels(factoryProducer = { + GatewaySelectorViewModel.Factory(args, requireListener().donationPaymentRepository) + }) + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + BadgeDisplay112.register(adapter) + GooglePayButton.register(adapter) + + lifecycleDisposable.bindTo(viewLifecycleOwner) + + lifecycleDisposable += viewModel.state.subscribe { state -> + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + } + + private fun getConfiguration(state: GatewaySelectorState): DSLConfiguration { + return configure { + customPref( + BadgeDisplay112.Model( + badge = state.badge, + withDisplayText = false + ) + ) + + space(12.dp) + + when (args.request.donateToSignalType) { + DonateToSignalType.MONTHLY -> presentMonthlyText() + DonateToSignalType.ONE_TIME -> presentOneTimeText() + } + + space(68.dp) + + if (state.isGooglePayAvailable) { + customPref( + GooglePayButton.Model( + isEnabled = true, + onClick = { + findNavController().popBackStack() + val response = GatewayResponse(GatewayResponse.Gateway.GOOGLE_PAY, args.request) + setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response)) + } + ) + ) + } + + // PayPal + // Credit Card + + space(16.dp) + } + } + + private fun DSLConfiguration.presentMonthlyText() { + noPadTextPref( + title = DSLSettingsText.from( + getString(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, FiatMoneyUtil.format(resources, args.request.fiat)), + DSLSettingsText.CenterModifier, + DSLSettingsText.TitleLargeModifier + ) + ) + space(6.dp) + noPadTextPref( + title = DSLSettingsText.from( + getString(R.string.GatewaySelectorBottomSheet__get_a_s_badge, args.request.badge.name), + DSLSettingsText.CenterModifier, + DSLSettingsText.BodyLargeModifier, + DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant)) + ) + ) + } + + private fun DSLConfiguration.presentOneTimeText() { + noPadTextPref( + title = DSLSettingsText.from( + getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(resources, args.request.fiat)), + DSLSettingsText.CenterModifier, + DSLSettingsText.TitleLargeModifier + ) + ) + space(6.dp) + noPadTextPref( + title = DSLSettingsText.from( + getString(R.string.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, args.request.badge.name, 30), + DSLSettingsText.CenterModifier, + DSLSettingsText.BodyLargeModifier, + DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant)) + ) + ) + } + + companion object { + const val REQUEST_KEY = "payment_checkout_mode" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt new file mode 100644 index 0000000000..96c50b5879 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +import org.thoughtcrime.securesms.badges.models.Badge + +data class GatewaySelectorState( + val badge: Badge, + val isGooglePayAvailable: Boolean = false, + val isPayPalAvailable: Boolean = false, + val isCreditCardAvailable: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt new file mode 100644 index 0000000000..6b2d905517 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository +import org.thoughtcrime.securesms.util.rx.RxStore + +class GatewaySelectorViewModel( + args: GatewaySelectorBottomSheetArgs, + private val repository: DonationPaymentRepository +) : ViewModel() { + + private val store = RxStore(GatewaySelectorState(args.request.badge)) + + val state = store.stateFlowable + + init { + checkIfGooglePayIsAvailable() + } + + override fun onCleared() { + store.dispose() + } + + private fun checkIfGooglePayIsAvailable() { + repository.isGooglePayAvailable().subscribeBy( + onComplete = { + store.update { it.copy(isGooglePayAvailable = true) } + }, + onError = { + store.update { it.copy(isGooglePayAvailable = false) } + } + ) + } + + class Factory( + private val args: GatewaySelectorBottomSheetArgs, + private val repository: DonationPaymentRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(GatewaySelectorViewModel(args, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeAction.kt new file mode 100644 index 0000000000..3cc65e673f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeAction.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class StripeAction : Parcelable { + PROCESS_NEW_DONATION, + UPDATE_SUBSCRIPTION, + CANCEL_SUBSCRIPTION +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeActionResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeActionResult.kt new file mode 100644 index 0000000000..1e91bc3e26 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeActionResult.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest + +@Parcelize +class StripeActionResult( + val action: StripeAction, + val request: GatewayRequest, + val status: Status +) : Parcelable { + enum class Status { + SUCCESS, + FAILURE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt new file mode 100644 index 0000000000..86d3395a18 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt @@ -0,0 +1,100 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import androidx.navigation.navGraphViewModels +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent +import org.thoughtcrime.securesms.databinding.StripePaymentInProgressFragmentBinding +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.fragments.requireListener + +class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_in_progress_fragment) { + + companion object { + const val REQUEST_KEY = "REQUEST_KEY" + } + + private val binding by ViewBinderDelegate(StripePaymentInProgressFragmentBinding::bind) + private val args: StripePaymentInProgressFragmentArgs by navArgs() + private val disposables = LifecycleDisposable() + + private val viewModel: StripePaymentInProgressViewModel by navGraphViewModels( + R.id.donate_to_signal, + factoryProducer = { + StripePaymentInProgressViewModel.Factory(requireListener().donationPaymentRepository) + } + ) + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + isCancelable = false + return super.onCreateDialog(savedInstanceState).apply { + window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + disposables.bindTo(viewLifecycleOwner) + disposables += viewModel.state.subscribeBy { stage -> + presentUiState(stage) + } + + if (savedInstanceState == null) { + when (args.action) { + StripeAction.PROCESS_NEW_DONATION -> { + viewModel.processNewDonation(args.request) + } + StripeAction.UPDATE_SUBSCRIPTION -> { + viewModel.updateSubscription(args.request) + } + StripeAction.CANCEL_SUBSCRIPTION -> { + viewModel.cancelSubscription() + } + } + } + } + + private fun presentUiState(stage: StripeStage) { + when (stage) { + StripeStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment) + StripeStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment) + StripeStage.FAILED -> { + findNavController().popBackStack() + setFragmentResult( + REQUEST_KEY, + bundleOf( + REQUEST_KEY to StripeActionResult( + action = args.action, + request = args.request, + status = StripeActionResult.Status.FAILURE + ) + ) + ) + } + StripeStage.COMPLETE -> { + findNavController().popBackStack() + setFragmentResult( + REQUEST_KEY, + bundleOf( + REQUEST_KEY to StripeActionResult( + action = args.action, + request = args.request, + status = StripeActionResult.Status.SUCCESS + ) + ) + ) + } + StripeStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt new file mode 100644 index 0000000000..d25976966a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -0,0 +1,178 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe + +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.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.logging.Log +import org.signal.donations.GooglePayPaymentSource +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.rx.RxStore +import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels +import org.whispersystems.signalservice.api.util.Preconditions + +class StripePaymentInProgressViewModel( + private val donationPaymentRepository: DonationPaymentRepository +) : ViewModel() { + + companion object { + private val TAG = Log.tag(StripePaymentInProgressViewModel::class.java) + } + + private val store = RxStore(StripeStage.INIT) + val state: Flowable = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) + + private val disposables = CompositeDisposable() + private var paymentData: PaymentData? = null + + override fun onCleared() { + disposables.clear() + store.dispose() + } + + fun processNewDonation(request: GatewayRequest) { + val paymentData = this.paymentData ?: error("Cannot process new donation without payment data") + this.paymentData = null + + Preconditions.checkState(store.state != StripeStage.PAYMENT_PIPELINE) + Log.d(TAG, "Proceeding with donation...") + + return when (request.donateToSignalType) { + DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentData) + DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentData) + } + } + + fun providePaymentData(paymentData: PaymentData) { + this.paymentData = paymentData + } + + private fun proceedMonthly(request: GatewayRequest, paymentData: PaymentData) { + val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId() + val continueSetup = donationPaymentRepository.continueSubscriptionSetup(GooglePayPaymentSource(paymentData)) + val setLevel = donationPaymentRepository.setSubscriptionLevel(request.level.toString()) + + Log.d(TAG, "Starting subscription payment pipeline...", true) + store.update { StripeStage.PAYMENT_PIPELINE } + + val setup = ensureSubscriberId + .andThen(cancelActiveSubscriptionIfNecessary()) + .andThen(continueSetup) + .onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) } + + setup.andThen(setLevel).subscribeBy( + onError = { throwable -> + Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true) + store.update { StripeStage.FAILED } + + val donationError: DonationError = if (throwable is DonationError) { + throwable + } else { + DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) + } + DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) + }, + onComplete = { + Log.d(TAG, "Finished subscription payment pipeline...", true) + store.update { StripeStage.COMPLETE } + } + ) + } + + private fun cancelActiveSubscriptionIfNecessary(): Completable { + return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable { + if (it) { + Log.d(TAG, "Cancelling active subscription...", true) + donationPaymentRepository.cancelActiveSubscription().doOnComplete { + SignalStore.donationsValues().updateLocalStateForManualCancellation() + MultiDeviceSubscriptionSyncRequestJob.enqueue() + } + } else { + Completable.complete() + } + } + } + + private fun proceedOneTime(request: GatewayRequest, paymentData: PaymentData) { + Log.w(TAG, "Beginning one-time payment pipeline...", true) + + donationPaymentRepository.continuePayment(request.fiat, GooglePayPaymentSource(paymentData), Recipient.self().id, null, SubscriptionLevels.BOOST_LEVEL.toLong()).subscribeBy( + onError = { throwable -> + Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) + store.update { StripeStage.FAILED } + + val donationError: DonationError = if (throwable is DonationError) { + throwable + } else { + DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST) + } + DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) + }, + onComplete = { + Log.w(TAG, "Completed one-time payment pipeline...", true) + store.update { StripeStage.COMPLETE } + } + ) + } + + fun cancelSubscription() { + store.update { StripeStage.CANCELLING } + disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy( + onComplete = { + Log.d(TAG, "Cancellation succeeded", true) + SignalStore.donationsValues().updateLocalStateForManualCancellation() + MultiDeviceSubscriptionSyncRequestJob.enqueue() + donationPaymentRepository.scheduleSyncForAccountRecordChange() + store.update { StripeStage.COMPLETE } + }, + onError = { throwable -> + Log.w(TAG, "Cancellation failed", throwable, true) + store.update { StripeStage.FAILED } + } + ) + } + + fun updateSubscription(request: GatewayRequest) { + store.update { StripeStage.PAYMENT_PIPELINE } + cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString())) + .subscribeBy( + onComplete = { + Log.w(TAG, "Completed subscription update", true) + store.update { StripeStage.COMPLETE } + }, + onError = { throwable -> + Log.w(TAG, "Failed to update subscription", throwable, true) + val donationError: DonationError = if (throwable is DonationError) { + throwable + } else { + DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) + } + DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) + + store.update { StripeStage.FAILED } + } + ) + } + + class Factory( + private val donationPaymentRepository: DonationPaymentRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StripePaymentInProgressViewModel(donationPaymentRepository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeStage.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeStage.kt new file mode 100644 index 0000000000..57c1d72cff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeStage.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe + +enum class StripeStage { + INIT, + PAYMENT_PIPELINE, + CANCELLING, + FAILED, + COMPLETE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt index 5801273ce9..0e1340cb73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt @@ -53,15 +53,13 @@ object ActiveSubscriptionPreference { val badge: BadgeImageView = itemView.findViewById(R.id.my_support_badge) val title: TextView = itemView.findViewById(R.id.my_support_title) - val price: TextView = itemView.findViewById(R.id.my_support_price) val expiry: TextView = itemView.findViewById(R.id.my_support_expiry) val progress: ProgressBar = itemView.findViewById(R.id.my_support_progress) override fun bind(model: Model) { badge.setBadge(model.subscription.badge) - title.text = model.subscription.name - price.text = context.getString( + title.text = context.getString( R.string.MySupportPreference__s_per_month, FiatMoneyUtil.format( context.resources, @@ -69,6 +67,7 @@ object ActiveSubscriptionPreference { FiatMoneyUtil.formatOptions() ) ) + expiry.movementMethod = LinkMovementMethod.getInstance() when (model.redemptionState) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index ac56d34971..ed9ae81458 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -2,10 +2,11 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage import android.content.Intent import android.text.SpannableStringBuilder +import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import org.signal.core.util.DimensionUnit +import org.signal.core.util.dp import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheet @@ -17,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle @@ -26,6 +28,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.subscription.Subscription import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.Material3OnScrollHelper import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -37,13 +40,17 @@ import java.util.concurrent.TimeUnit * Fragment displayed when a user enters "Subscriptions" via app settings but is already * a subscriber. Used to manage their current subscription, view badges, and boost. */ -class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback { +class ManageDonationsFragment : + DSLSettingsFragment( + layoutId = R.layout.manage_donations_fragment + ), + ExpiredGiftSheet.Callback { private val supportTechSummary: CharSequence by lazy { - SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__make_a_recurring_monthly_donation)) + SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__support_technology))) .append(" ") .append( - SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_button_secondary_text)) { + SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary)) { findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeLearnMoreBottomSheetDialog()) } ) @@ -77,48 +84,88 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback } } + override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper { + return object : Material3OnScrollHelper(requireActivity(), toolbar!!) { + override val activeColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground) + override val inactiveColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground) + } + } + private fun getConfiguration(state: ManageDonationsState): DSLConfiguration { return configure { + space(36.dp) + customPref( - BadgePreview.BadgeModel.FeaturedModel( + BadgePreview.BadgeModel.SubscriptionModel( badge = state.featuredBadge ) ) - space(DimensionUnit.DP.toPixels(8f).toInt()) + space(12.dp) - sectionHeaderPref( + noPadTextPref( title = DSLSettingsText.from( - R.string.SubscribeFragment__signal_is_powered_by_people_like_you, + R.string.DonateToSignalFragment__powered_by, DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier ) ) + space(8.dp) + + noPadTextPref( + title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier) + ) + + space(24.dp) + + primaryWrappedButton( + text = DSLSettingsText.from(R.string.ManageDonationsFragment__donate_to_signal), + onClick = { + findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.ONE_TIME)) + } + ) + + space(16.dp) + if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) { val activeSubscription = state.transactionState.activeSubscription.activeSubscription if (activeSubscription != null) { - val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.level == it.level } + val subscription: Subscription? = state.availableSubscriptions.firstOrNull { it.level == activeSubscription.level } if (subscription != null) { - presentSubscriptionSettings(activeSubscription, subscription, state.getRedemptionState()) + presentSubscriptionSettings(activeSubscription, subscription, state.getMonthlyDonorRedemptionState()) } else { customPref(IndeterminateLoadingCircle) } + } else if (state.hasOneTimeBadge) { + presentActiveOneTimeDonorSettings() } else { - presentNoSubscriptionSettings() + presentNotADonorSettings(state.hasReceipts) } } else if (state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) { - presentNetworkFailureSettings(state.getRedemptionState()) + presentNetworkFailureSettings(state.getMonthlyDonorRedemptionState(), state.hasReceipts) } else { customPref(IndeterminateLoadingCircle) } } } - private fun DSLConfiguration.presentNetworkFailureSettings(redemptionState: ManageDonationsState.SubscriptionRedemptionState) { + private fun DSLConfiguration.presentActiveOneTimeDonorSettings() { + dividerPref() + + sectionHeaderPref(R.string.ManageDonationsFragment__my_support) + + presentBadges() + + presentOtherWaysToGive() + + presentMore() + } + + private fun DSLConfiguration.presentNetworkFailureSettings(redemptionState: ManageDonationsState.SubscriptionRedemptionState, hasReceipts: Boolean) { if (SignalStore.donationsValues().isLikelyASustainer()) { presentSubscriptionSettingsWithNetworkError(redemptionState) } else { - presentNoSubscriptionSettings() + presentNotADonorSettings(hasReceipts) } } @@ -163,16 +210,9 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback redemptionState: ManageDonationsState.SubscriptionRedemptionState, subscriptionBlock: DSLConfiguration.() -> Unit ) { - space(DimensionUnit.DP.toPixels(32f).toInt()) + dividerPref() - noPadTextPref( - title = DSLSettingsText.from( - R.string.ManageDonationsFragment__my_subscription, - DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier - ) - ) - - space(DimensionUnit.DP.toPixels(12f).toInt()) + sectionHeaderPref(R.string.ManageDonationsFragment__my_support) subscriptionBlock() @@ -181,52 +221,23 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp), isEnabled = redemptionState != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS, onClick = { - findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment()) + findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY)) } ) - clickPref( - title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges), - icon = DSLSettingsIcon.from(R.drawable.ic_badge_24), - onClick = { - findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges()) - } - ) + presentBadges() presentOtherWaysToGive() - sectionHeaderPref(R.string.ManageDonationsFragment__more) - - presentDonationReceipts() - - externalLinkPref( - title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq), - icon = DSLSettingsIcon.from(R.drawable.ic_help_24), - linkId = R.string.donate_url - ) + presentMore() } - private fun DSLConfiguration.presentNoSubscriptionSettings() { - space(DimensionUnit.DP.toPixels(16f).toInt()) - - noPadTextPref( - title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier) - ) - - space(DimensionUnit.DP.toPixels(16f).toInt()) - - tonalButton( - text = DSLSettingsText.from(R.string.ManageDonationsFragment__make_a_monthly_donation), - onClick = { - findNavController().safeNavigate(R.id.action_manageDonationsFragment_to_subscribeFragment) - } - ) - + private fun DSLConfiguration.presentNotADonorSettings(hasReceipts: Boolean) { presentOtherWaysToGive() - sectionHeaderPref(R.string.ManageDonationsFragment__receipts) - - presentDonationReceipts() + if (hasReceipts) { + presentMore() + } } private fun DSLConfiguration.presentOtherWaysToGive() { @@ -234,14 +245,6 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback sectionHeaderPref(R.string.ManageDonationsFragment__other_ways_to_give) - clickPref( - title = DSLSettingsText.from(R.string.preferences__one_time_donation), - icon = DSLSettingsIcon.from(R.drawable.ic_boost_24), - onClick = { - findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts()) - } - ) - if (FeatureFlags.giftBadgeSendSupport() && Recipient.self().giftBadgesCapability == Recipient.Capability.SUPPORTED) { clickPref( title = DSLSettingsText.from(R.string.ManageDonationsFragment__gift_a_badge), @@ -253,7 +256,17 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback } } - private fun DSLConfiguration.presentDonationReceipts() { + private fun DSLConfiguration.presentBadges() { + clickPref( + title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges), + icon = DSLSettingsIcon.from(R.drawable.ic_badge_24), + onClick = { + findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges()) + } + ) + } + + private fun DSLConfiguration.presentReceipts() { clickPref( title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts), icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24), @@ -263,7 +276,21 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback ) } + private fun DSLConfiguration.presentMore() { + dividerPref() + + sectionHeaderPref(R.string.ManageDonationsFragment__more) + + presentReceipts() + + externalLinkPref( + title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq), + icon = DSLSettingsIcon.from(R.drawable.ic_help_24), + linkId = R.string.donate_url + ) + } + override fun onMakeAMonthlyDonation() { - findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment()) + findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt index 1f4747fc85..a54acd5441 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt @@ -5,13 +5,15 @@ import org.thoughtcrime.securesms.subscription.Subscription import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription data class ManageDonationsState( + val hasOneTimeBadge: Boolean = false, + val hasReceipts: Boolean = false, val featuredBadge: Badge? = null, val transactionState: TransactionState = TransactionState.Init, val availableSubscriptions: List = emptyList(), private val subscriptionRedemptionState: SubscriptionRedemptionState = SubscriptionRedemptionState.NONE ) { - fun getRedemptionState(): SubscriptionRedemptionState { + fun getMonthlyDonorRedemptionState(): SubscriptionRedemptionState { return when (transactionState) { TransactionState.Init -> subscriptionRedemptionState TransactionState.NetworkFailure -> subscriptionRedemptionState diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt index f8e0900161..a132c0d226 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt @@ -9,8 +9,10 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.jobmanager.JobTracker import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.subscription.LevelUpdate @@ -60,6 +62,18 @@ class ManageDonationsViewModel( val levelUpdateOperationEdges: Observable = LevelUpdate.isProcessing.distinctUntilChanged() val activeSubscription: Single = subscriptionsRepository.getActiveSubscription() + disposables += Recipient.observable(Recipient.self().id).map { it.badges }.subscribeBy { badges -> + store.update { state -> + state.copy( + hasOneTimeBadge = badges.any { it.isBoost() } + ) + } + } + + disposables += Single.fromCallable { SignalDatabase.donationReceipts.hasReceipts() }.subscribeOn(Schedulers.io()).subscribe { hasReceipts -> + store.update { it.copy(hasReceipts = hasReceipts) } + } + disposables += SubscriptionRedemptionJobWatcher.watch().subscribeBy { jobStateOptional -> store.update { manageDonationsState -> manageDonationsState.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt deleted file mode 100644 index df862602ee..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt +++ /dev/null @@ -1,346 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe - -import android.content.DialogInterface -import android.text.SpannableStringBuilder -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import org.signal.core.util.DimensionUnit -import org.signal.core.util.logging.Log -import org.signal.core.util.money.FiatMoney -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.badges.models.Badge -import org.thoughtcrime.securesms.badges.models.BadgePreview -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment -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.DonationPaymentComponent -import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource -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.app.subscription.models.NetworkFailure -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.components.settings.models.Button -import org.thoughtcrime.securesms.components.settings.models.Progress -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.payments.FiatMoneyUtil -import org.thoughtcrime.securesms.subscription.Subscription -import org.thoughtcrime.securesms.util.LifecycleDisposable -import org.thoughtcrime.securesms.util.SpanUtil -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.fragments.requireListener -import org.thoughtcrime.securesms.util.navigation.safeNavigate -import org.thoughtcrime.securesms.util.visible -import java.util.Currency -import java.util.concurrent.TimeUnit - -/** - * UX for creating and changing a subscription - */ -class SubscribeFragment : DSLSettingsFragment( - layoutId = R.layout.subscribe_fragment -) { - - private val lifecycleDisposable = LifecycleDisposable() - - private val supportTechSummary: CharSequence by lazy { - SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__make_a_recurring_monthly_donation)) - .append(" ") - .append( - SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_button_secondary_text)) { - findNavController().safeNavigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeLearnMoreBottomSheetDialog()) - } - ) - } - - private lateinit var processingDonationPaymentDialog: AlertDialog - private lateinit var donationPaymentComponent: DonationPaymentComponent - - private lateinit var googlePayButtonViewHolder: GooglePayButton.ViewHolder - private lateinit var updateSubscriptionButtonViewHolder: Button.ViewHolder - private lateinit var cancelSubscriptionButtonViewHolder: Button.ViewHolder - - private var errorDialog: DialogInterface? = null - - private val viewModel: SubscribeViewModel by viewModels( - factoryProducer = { - SubscribeViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()), donationPaymentComponent.donationPaymentRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE) - } - ) - - override fun onResume() { - super.onResume() - viewModel.refreshActiveSubscription() - } - - override fun bindAdapter(adapter: MappingAdapter) { - donationPaymentComponent = requireListener() - viewModel.refresh() - - BadgePreview.register(adapter) - CurrencySelection.register(adapter) - Subscription.register(adapter) - Progress.register(adapter) - NetworkFailure.register(adapter) - - googlePayButtonViewHolder = GooglePayButton.ViewHolder(requireView().findViewById(R.id.pay_button_wrapper)) - updateSubscriptionButtonViewHolder = Button.ViewHolder(requireView().findViewById(R.id.update_button_wrapper)) - cancelSubscriptionButtonViewHolder = Button.ViewHolder(requireView().findViewById(R.id.cancel_button_wrapper)) - - processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext()) - .setView(R.layout.processing_payment_dialog) - .setCancelable(false) - .create() - - viewModel.state.observe(viewLifecycleOwner) { state -> - bindFixedButtons(state) - adapter.submitList(getConfiguration(state).toMappingModelList()) - } - - lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle) - lifecycleDisposable += viewModel.events.subscribe { - when (it) { - is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge) - DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay") - DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled() - is DonationEvent.SubscriptionCancellationFailed -> onSubscriptionFailedToCancel(it.throwable) - } - } - lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe { - viewModel.onActivityResult(it.requestCode, it.resultCode, it.data) - } - - lifecycleDisposable += DonationError - .getErrorsForSource(DonationErrorSource.SUBSCRIPTION) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { donationError -> - onPaymentError(donationError) - } - } - - override fun onDestroyView() { - super.onDestroyView() - processingDonationPaymentDialog.dismiss() - } - - 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.BadgeModel.SubscriptionModel(state.selectedSubscription?.badge)) - - sectionHeaderPref( - title = DSLSettingsText.from( - R.string.SubscribeFragment__signal_is_powered_by_people_like_you, - DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier - ) - ) - - noPadTextPref( - title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier) - ) - - space(DimensionUnit.DP.toPixels(16f).toInt()) - - customPref( - CurrencySelection.Model( - selectedCurrency = state.currencySelection, - isEnabled = areFieldsEnabled && state.activeSubscription?.isActive != true, - onClick = { - val selectableCurrencies = viewModel.getSelectableCurrencyCodes() - if (selectableCurrencies != null) { - findNavController().safeNavigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment(false, selectableCurrencies.toTypedArray())) - } - } - ) - ) - - space(DimensionUnit.DP.toPixels(4f).toInt()) - - @Suppress("CascadeIf") - if (state.stage == SubscribeState.Stage.INIT) { - customPref( - Subscription.LoaderModel() - ) - } else if (state.stage == SubscribeState.Stage.FAILURE) { - space(DimensionUnit.DP.toPixels(69f).toInt()) - customPref( - NetworkFailure.Model { - viewModel.refresh() - } - ) - space(DimensionUnit.DP.toPixels(75f).toInt()) - } else { - state.subscriptions.forEach { - - val isActive = state.activeSubscription?.activeSubscription?.level == it.level && state.activeSubscription.isActive - - val activePrice = state.activeSubscription?.activeSubscription?.let { sub -> - val activeCurrency = Currency.getInstance(sub.currency) - val activeAmount = sub.amount.movePointLeft(activeCurrency.defaultFractionDigits) - - FiatMoney(activeAmount, activeCurrency) - } - - customPref( - Subscription.Model( - activePrice = if (isActive) activePrice else null, - subscription = it, - isSelected = state.selectedSubscription == it, - isEnabled = areFieldsEnabled, - isActive = isActive, - willRenew = isActive && !state.isSubscriptionExpiring(), - onClick = { viewModel.setSelectedSubscription(it) }, - renewalTimestamp = TimeUnit.SECONDS.toMillis(state.activeSubscription?.activeSubscription?.endOfCurrentPeriod ?: 0L), - selectedCurrency = state.currencySelection - ) - ) - } - } - } - } - - private fun bindFixedButtons(state: SubscribeState) { - val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction - - if (state.activeSubscription?.isActive == true) { - val activeAndSameLevel = state.activeSubscription.isActive && - state.selectedSubscription?.level == state.activeSubscription.activeSubscription?.level - - val updateModel = Button.Model.Primary( - title = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription), - icon = null, - isEnabled = areFieldsEnabled && (!activeAndSameLevel || state.isSubscriptionExpiring()), - onClick = { - val price = viewModel.getPriceOfSelectedSubscription() ?: return@Primary - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.SubscribeFragment__update_subscription_question) - .setMessage( - getString( - R.string.SubscribeFragment__you_will_be_charged_the_full_amount_s_of, - FiatMoneyUtil.format( - requireContext().resources, - price, - FiatMoneyUtil.formatOptions().trimZerosAfterDecimal() - ) - ) - ) - .setPositiveButton(R.string.SubscribeFragment__update) { dialog, _ -> - dialog.dismiss() - viewModel.updateSubscription() - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - } - .show() - } - ) - - updateSubscriptionButtonViewHolder.bind(updateModel) - - val cancelModel = Button.Model.SecondaryNoOutline( - title = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription), - icon = null, - isEnabled = areFieldsEnabled, - onClick = { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.SubscribeFragment__confirm_cancellation) - .setMessage(R.string.SubscribeFragment__you_wont_be_charged_again) - .setPositiveButton(R.string.SubscribeFragment__confirm) { d, _ -> - d.dismiss() - viewModel.cancel() - } - .setNegativeButton(R.string.SubscribeFragment__not_now) { d, _ -> - d.dismiss() - } - .show() - } - ) - - cancelSubscriptionButtonViewHolder.bind(cancelModel) - - updateSubscriptionButtonViewHolder.itemView.visible = true - cancelSubscriptionButtonViewHolder.itemView.visible = true - googlePayButtonViewHolder.itemView.visible = false - } else { - val googlePayModel = GooglePayButton.Model( - onClick = this@SubscribeFragment::onGooglePayButtonClicked, - isEnabled = areFieldsEnabled && state.selectedSubscription != null - ) - - googlePayButtonViewHolder.bind(googlePayModel) - - updateSubscriptionButtonViewHolder.itemView.visible = false - cancelSubscriptionButtonViewHolder.itemView.visible = false - googlePayButtonViewHolder.itemView.visible = true - } - } - - private fun onGooglePayButtonClicked() { - viewModel.requestTokenFromGooglePay() - } - - private fun onPaymentConfirmed(badge: Badge) { - findNavController().safeNavigate( - SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false), - ) - } - - private fun onPaymentError(throwable: Throwable?) { - Log.w(TAG, "onPaymentError", throwable, true) - - if (errorDialog != null) { - Log.i(TAG, "Already displaying an error dialog. Skipping.") - return - } - - errorDialog = DonationErrorDialogs.show( - requireContext(), throwable, - object : DonationErrorDialogs.DialogCallback() { - override fun onDialogDismissed() { - findNavController().popBackStack() - } - } - ) - } - - private fun onSubscriptionCancelled() { - Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show() - - requireActivity().finish() - requireActivity().startActivity(AppSettingsActivity.home(requireContext())) - } - - private fun onSubscriptionFailedToCancel(throwable: Throwable) { - Log.w(TAG, "Failed to cancel subscription", throwable, true) - 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() - } - .show() - } - - companion object { - private val TAG = Log.tag(SubscribeFragment::class.java) - private const val FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE = 1000 - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt deleted file mode 100644 index 60b97b1c8e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe - -import org.thoughtcrime.securesms.subscription.Subscription -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription -import java.util.Currency - -data class SubscribeState( - val currencySelection: Currency, - val subscriptions: List = listOf(), - val selectedSubscription: Subscription? = null, - val activeSubscription: ActiveSubscription? = null, - val isGooglePayAvailable: Boolean = false, - val stage: Stage = Stage.INIT, - val hasInProgressSubscriptionTransaction: Boolean = false, -) { - - fun isSubscriptionExpiring(): Boolean { - return activeSubscription?.isActive == true && activeSubscription.activeSubscription.willCancelAtPeriodEnd() - } - - enum class Stage { - INIT, - READY, - TOKEN_REQUEST, - PAYMENT_PIPELINE, - CANCELLING, - FAILURE - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt deleted file mode 100644 index 64e9c3c41a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt +++ /dev/null @@ -1,307 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe - -import android.content.Intent -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.Completable -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.kotlin.plusAssign -import io.reactivex.rxjava3.kotlin.subscribeBy -import io.reactivex.rxjava3.subjects.PublishSubject -import org.signal.core.util.logging.Log -import org.signal.core.util.money.FiatMoney -import org.signal.donations.GooglePayApi -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository -import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.subscription.LevelUpdate -import org.thoughtcrime.securesms.subscription.Subscriber -import org.thoughtcrime.securesms.subscription.Subscription -import org.thoughtcrime.securesms.util.InternetConnectionObserver -import org.thoughtcrime.securesms.util.PlatformCurrencyUtil -import org.thoughtcrime.securesms.util.livedata.Store -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription -import org.whispersystems.signalservice.api.subscriptions.SubscriberId -import java.util.Currency - -class SubscribeViewModel( - private val subscriptionsRepository: SubscriptionsRepository, - private val donationPaymentRepository: DonationPaymentRepository, - private val fetchTokenRequestCode: Int -) : ViewModel() { - - private val store = Store(SubscribeState(currencySelection = SignalStore.donationsValues().getSubscriptionCurrency())) - private val eventPublisher: PublishSubject = PublishSubject.create() - private val disposables = CompositeDisposable() - private val networkDisposable: Disposable - - val state: LiveData = store.stateLiveData - val events: Observable = eventPublisher.observeOn(AndroidSchedulers.mainThread()) - - private var subscriptionToPurchase: Subscription? = null - private val activeSubscriptionSubject = PublishSubject.create() - - init { - networkDisposable = InternetConnectionObserver - .observe() - .distinctUntilChanged() - .subscribe { isConnected -> - if (isConnected) { - retry() - } - } - } - - override fun onCleared() { - networkDisposable.dispose() - disposables.dispose() - } - - fun getPriceOfSelectedSubscription(): FiatMoney? { - return store.state.selectedSubscription?.prices?.first { it.currency == store.state.currencySelection } - } - - fun getSelectableCurrencyCodes(): List? { - return store.state.subscriptions.firstOrNull()?.prices?.map { it.currency.currencyCode } - } - - fun retry() { - if (!disposables.isDisposed && store.state.stage == SubscribeState.Stage.FAILURE) { - store.update { it.copy(stage = SubscribeState.Stage.INIT) } - refresh() - } - } - - fun refresh() { - disposables.clear() - - val currency: Observable = SignalStore.donationsValues().observableSubscriptionCurrency - val allSubscriptions: Single> = subscriptionsRepository.getSubscriptions() - - refreshActiveSubscription() - - disposables += LevelUpdate.isProcessing.subscribeBy { - store.update { state -> - state.copy( - hasInProgressSubscriptionTransaction = it - ) - } - } - - disposables += allSubscriptions.subscribeBy( - onSuccess = { subscriptions -> - if (subscriptions.isNotEmpty()) { - val priceCurrencies = subscriptions[0].prices.map { it.currency } - val selectedCurrency = SignalStore.donationsValues().getSubscriptionCurrency() - - if (selectedCurrency !in priceCurrencies) { - Log.w(TAG, "Unsupported currency selection. Defaulting to USD. $currency isn't supported.") - val usd = PlatformCurrencyUtil.USD - val newSubscriber = SignalStore.donationsValues().getSubscriber(usd) ?: Subscriber(SubscriberId.generate(), usd.currencyCode) - SignalStore.donationsValues().setSubscriber(newSubscriber) - donationPaymentRepository.scheduleSyncForAccountRecordChange() - } - } - }, - onError = {} - ) - - disposables += Observable.combineLatest(allSubscriptions.toObservable(), activeSubscriptionSubject, ::Pair).subscribeBy( - onNext = { (subs, active) -> - store.update { - it.copy( - subscriptions = subs, - selectedSubscription = it.selectedSubscription ?: resolveSelectedSubscription(active, subs), - activeSubscription = active, - stage = if (it.stage == SubscribeState.Stage.INIT || it.stage == SubscribeState.Stage.FAILURE) SubscribeState.Stage.READY else it.stage, - ) - } - }, - onError = this::handleSubscriptionDataLoadFailure - ) - - disposables += currency.subscribe { selection -> - store.update { it.copy(currencySelection = selection) } - } - } - - private fun handleSubscriptionDataLoadFailure(throwable: Throwable) { - Log.w(TAG, "Could not load subscription data", throwable) - store.update { - it.copy(stage = SubscribeState.Stage.FAILURE) - } - } - - fun refreshActiveSubscription() { - subscriptionsRepository - .getActiveSubscription() - .subscribeBy( - onSuccess = { activeSubscriptionSubject.onNext(it) }, - onError = { activeSubscriptionSubject.onNext(ActiveSubscription.EMPTY) } - ) - } - - private fun resolveSelectedSubscription(activeSubscription: ActiveSubscription, subscriptions: List): Subscription? { - return if (activeSubscription.isActive) { - subscriptions.firstOrNull { it.level == activeSubscription.activeSubscription.level } - } else { - subscriptions.firstOrNull() - } - } - - private fun cancelActiveSubscriptionIfNecessary(): Completable { - return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable { - if (it) { - donationPaymentRepository.cancelActiveSubscription().doOnComplete { - SignalStore.donationsValues().updateLocalStateForManualCancellation() - MultiDeviceSubscriptionSyncRequestJob.enqueue() - } - } else { - Completable.complete() - } - } - } - - fun cancel() { - store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) } - disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy( - onComplete = { - eventPublisher.onNext(DonationEvent.SubscriptionCancelled) - SignalStore.donationsValues().updateLocalStateForManualCancellation() - refreshActiveSubscription() - MultiDeviceSubscriptionSyncRequestJob.enqueue() - donationPaymentRepository.scheduleSyncForAccountRecordChange() - 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( - requestCode: Int, - resultCode: Int, - data: Intent? - ) { - val subscription = subscriptionToPurchase - subscriptionToPurchase = null - - donationPaymentRepository.onActivityResult( - requestCode, resultCode, data, this.fetchTokenRequestCode, - object : GooglePayApi.PaymentRequestCallback { - override fun onSuccess(paymentData: PaymentData) { - if (subscription != null) { - eventPublisher.onNext(DonationEvent.RequestTokenSuccess) - - 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) } - - val setup = ensureSubscriberId - .andThen(cancelActiveSubscriptionIfNecessary()) - .andThen(continueSetup) - .onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) } - - setup.andThen(setLevel).subscribeBy( - onError = { throwable -> - refreshActiveSubscription() - store.update { it.copy(stage = SubscribeState.Stage.READY) } - - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - Log.w(TAG, "Failed to complete payment or redemption", throwable, true) - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) - } - DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) - }, - onComplete = { - 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(googlePayException: GooglePayApi.GooglePayException) { - store.update { it.copy(stage = SubscribeState.Stage.READY) } - DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.SUBSCRIPTION, googlePayException)) - } - - override fun onCancelled() { - store.update { it.copy(stage = SubscribeState.Stage.READY) } - } - } - ) - } - - fun updateSubscription() { - store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) } - cancelActiveSubscriptionIfNecessary().andThen(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) } - val donationError: DonationError = if (throwable is DonationError) { - throwable - } else { - Log.w(TAG, "Failed to complete payment or redemption", throwable, true) - DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION) - } - DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError) - } - ) - } - - fun requestTokenFromGooglePay() { - val snapshot = store.state - if (snapshot.selectedSubscription == null) { - return - } - - store.update { it.copy(stage = SubscribeState.Stage.TOKEN_REQUEST) } - - val selectedCurrency = snapshot.currencySelection - - subscriptionToPurchase = snapshot.selectedSubscription - donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.prices.first { it.currency == selectedCurrency }, snapshot.selectedSubscription.name, fetchTokenRequestCode) - } - - fun setSelectedSubscription(subscription: Subscription) { - store.update { it.copy(selectedSubscription = subscription) } - } - - class Factory( - private val subscriptionsRepository: SubscriptionsRepository, - private val donationPaymentRepository: DonationPaymentRepository, - private val fetchTokenRequestCode: Int - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!! - } - } - - companion object { - private val TAG = Log.tag(SubscribeViewModel::class.java) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt index dd166ede5e..be293a0811 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt @@ -96,7 +96,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge) controlNote.visible = true controlState = ControlState.FEATURE - } else if (hasOtherBadges && !displayingBadges) { + } else if (hasOtherBadges) { switch.isChecked = false controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile) controlNote.visible = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt index 5440c26a60..17f577a0d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt @@ -158,6 +158,15 @@ class DSLConfiguration { children.add(preference) } + fun primaryWrappedButton( + text: DSLSettingsText, + isEnabled: Boolean = true, + onClick: () -> Unit + ) { + val preference = Button.Model.PrimaryWrapped(text, null, isEnabled, onClick) + children.add(preference) + } + fun tonalButton( text: DSLSettingsText, isEnabled: Boolean = true, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt index 6d76190a7d..86ec8fd73b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt @@ -14,6 +14,7 @@ object Button { fun register(mappingAdapter: MappingAdapter) { mappingAdapter.registerFactory(Model.Primary::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.dsl_button_primary)) + mappingAdapter.registerFactory(Model.PrimaryWrapped::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.dsl_button_primary_wrapped)) mappingAdapter.registerFactory(Model.Tonal::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.dsl_button_tonal)) mappingAdapter.registerFactory(Model.SecondaryNoOutline::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.dsl_button_secondary)) } @@ -28,6 +29,9 @@ object Button { icon = icon, isEnabled = isEnabled ) { + /** + * Large primary button with width set to match_parent + */ class Primary( title: DSLSettingsText?, icon: DSLSettingsIcon?, @@ -35,6 +39,16 @@ object Button { onClick: () -> Unit ) : Model(title, icon, isEnabled, onClick) + /** + * Large primary button with width set to wrap_content + */ + class PrimaryWrapped( + title: DSLSettingsText?, + icon: DSLSettingsIcon?, + isEnabled: Boolean, + onClick: () -> Unit + ) : Model(title, icon, isEnabled, onClick) + class Tonal( title: DSLSettingsText?, icon: DSLSettingsIcon?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index d07df08612..c5f7941fad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -60,7 +60,6 @@ import androidx.core.view.ViewKt; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import androidx.lifecycle.ViewModelProvider; -import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.OnScrollListener; @@ -72,6 +71,7 @@ import com.google.android.material.snackbar.Snackbar; import org.jetbrains.annotations.NotNull; import org.signal.core.util.DimensionUnit; +import org.signal.core.util.Stopwatch; import org.signal.core.util.StreamUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SimpleTask; @@ -90,6 +90,8 @@ import org.thoughtcrime.securesms.components.menu.ActionItem; import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar; import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment; +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contactshare.Contact; @@ -179,7 +181,6 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SignalProxyUtil; import org.thoughtcrime.securesms.util.SnapToTopDataObserver; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; -import org.signal.core.util.Stopwatch; import org.thoughtcrime.securesms.util.StorageUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TopToastPopup; @@ -2057,11 +2058,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect @Override public void onDonateClicked() { - NavHostFragment navHostFragment = NavHostFragment.create(R.navigation.boosts); - requireActivity().getSupportFragmentManager() .beginTransaction() - .add(navHostFragment, "boost_nav") + .add(DonateToSignalFragment.Dialog.create(DonateToSignalType.ONE_TIME), "one_time_nav") .commitNow(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptDatabase.kt index 2bdb8cc2a7..3d2110afbc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptDatabase.kt @@ -39,6 +39,13 @@ class DonationReceiptDatabase(context: Context, databaseHelper: SignalDatabase) ) } + fun hasReceipts(): Boolean { + return readableDatabase.query(TABLE_NAME, SqlUtil.COUNT, null, null, null, null, null, null).use { + it.moveToFirst() + it.getInt(0) > 0 + } + } + fun addReceipt(record: DonationReceiptRecord) { require(record.id == -1L) diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt index 1bdd4eacb8..2b1ce74945 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt @@ -4,17 +4,17 @@ import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.view.View -import android.widget.ImageView -import android.widget.TextView import androidx.core.animation.doOnEnd import androidx.lifecycle.DefaultLifecycleObserver import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.badges.BadgeImageView import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.databinding.SubscriptionPreferenceBinding import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory +import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder @@ -35,7 +35,7 @@ data class Subscription( companion object { fun register(adapter: MappingAdapter) { - adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_preference)) + adapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, SubscriptionPreferenceBinding::inflate)) adapter.registerFactory(LoaderModel::class.java, LayoutFactory({ LoaderViewHolder(it) }, R.layout.subscription_preference_loader)) } } @@ -86,7 +86,7 @@ data class Subscription( val isActive: Boolean, val willRenew: Boolean, override val isEnabled: Boolean, - val onClick: () -> Unit, + val onClick: (Subscription) -> Unit, val renewalTimestamp: Long, val selectedCurrency: Currency ) : PreferenceModel(isEnabled = isEnabled) { @@ -114,27 +114,18 @@ data class Subscription( } } - class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val badge: BadgeImageView = itemView.findViewById(R.id.badge) - private val title: TextView = itemView.findViewById(R.id.title) - private val tagline: TextView = itemView.findViewById(R.id.tagline) - private val price: TextView = itemView.findViewById(R.id.price) - private val check: ImageView = itemView.findViewById(R.id.check) + class ViewHolder(binding: SubscriptionPreferenceBinding) : BindingViewHolder(binding) { override fun bind(model: Model) { - itemView.isEnabled = model.isEnabled - itemView.setOnClickListener { model.onClick() } - itemView.isSelected = model.isSelected + binding.root.isEnabled = model.isEnabled + binding.root.setOnClickListener { model.onClick(model.subscription) } + binding.root.isSelected = model.isSelected if (payload.isEmpty()) { - badge.setBadge(model.subscription.badge) - badge.isClickable = false + binding.badge.setBadge(model.subscription.badge) + binding.badge.isClickable = false } - title.text = model.subscription.name - tagline.text = context.getString(R.string.Subscription__earn_a_s_badge, model.subscription.badge.name) - val formattedPrice = FiatMoneyUtil.format( context.resources, model.activePrice ?: model.subscription.prices.first { it.currency == model.selectedCurrency }, @@ -142,25 +133,18 @@ data class Subscription( ) if (model.isActive && model.willRenew) { - price.text = context.getString( - R.string.Subscription__s_per_month_dot_renews_s, - formattedPrice, - DateUtils.formatDateWithYear(Locale.getDefault(), model.renewalTimestamp) - ) + binding.tagline.text = context.getString(R.string.Subscription__renews_s, DateUtils.formatDateWithYear(Locale.getDefault(), model.renewalTimestamp)) } else if (model.isActive) { - price.text = context.getString( - R.string.Subscription__s_per_month_dot_expires_s, - formattedPrice, - DateUtils.formatDateWithYear(Locale.getDefault(), model.renewalTimestamp) - ) + binding.tagline.text = context.getString(R.string.Subscription__expires_s, DateUtils.formatDateWithYear(Locale.getDefault(), model.renewalTimestamp)) } else { - price.text = context.getString( - R.string.Subscription__s_per_month, - formattedPrice - ) + binding.tagline.text = context.getString(R.string.Subscription__get_a_s_badge, model.subscription.badge.name) } - check.visible = model.isActive + binding.title.text = context.getString( + R.string.Subscription__s_per_month, + formattedPrice + ) + binding.check.visible = model.isActive } } } diff --git a/app/src/main/res/color/donation_pill_toggle_background_tint.xml b/app/src/main/res/color/donation_pill_toggle_background_tint.xml new file mode 100644 index 0000000000..436626daba --- /dev/null +++ b/app/src/main/res/color/donation_pill_toggle_background_tint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/signal_selectable_button_background_tint.xml b/app/src/main/res/color/signal_selectable_button_background_tint.xml index 7802167549..4f89894b06 100644 --- a/app/src/main/res/color/signal_selectable_button_background_tint.xml +++ b/app/src/main/res/color/signal_selectable_button_background_tint.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/color/signal_selectable_button_stroke.xml b/app/src/main/res/color/signal_selectable_button_stroke.xml index 598467db65..bfe5d32712 100644 --- a/app/src/main/res/color/signal_selectable_button_stroke.xml +++ b/app/src/main/res/color/signal_selectable_button_stroke.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/currency_selection_background.xml b/app/src/main/res/drawable-night/currency_selection_background.xml deleted file mode 100644 index 22a847024b..0000000000 --- a/app/src/main/res/drawable-night/currency_selection_background.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/boost_loading_preference_background.xml b/app/src/main/res/drawable/boost_loading_preference_background.xml index d53d4797ed..d7aaf04686 100644 --- a/app/src/main/res/drawable/boost_loading_preference_background.xml +++ b/app/src/main/res/drawable/boost_loading_preference_background.xml @@ -1,6 +1,6 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/currency_selection_background.xml b/app/src/main/res/drawable/currency_selection_background.xml index aacb93c3d9..9152fa07bd 100644 --- a/app/src/main/res/drawable/currency_selection_background.xml +++ b/app/src/main/res/drawable/currency_selection_background.xml @@ -1,8 +1,6 @@ - - + + diff --git a/app/src/main/res/drawable/custom_donation_amount_background.xml b/app/src/main/res/drawable/custom_donation_amount_background.xml new file mode 100644 index 0000000000..9f8712da96 --- /dev/null +++ b/app/src/main/res/drawable/custom_donation_amount_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_outline_38dp.xml b/app/src/main/res/drawable/custom_donation_amount_background_normal.xml similarity index 52% rename from app/src/main/res/drawable/rounded_outline_38dp.xml rename to app/src/main/res/drawable/custom_donation_amount_background_normal.xml index b606ab33d8..456df9c755 100644 --- a/app/src/main/res/drawable/rounded_outline_38dp.xml +++ b/app/src/main/res/drawable/custom_donation_amount_background_normal.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/custom_donation_amount_background_selected.xml b/app/src/main/res/drawable/custom_donation_amount_background_selected.xml new file mode 100644 index 0000000000..c7a4c4b911 --- /dev/null +++ b/app/src/main/res/drawable/custom_donation_amount_background_selected.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check_20.xml b/app/src/main/res/drawable/ic_check_20.xml new file mode 100644 index 0000000000..5379895ad8 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_16.xml b/app/src/main/res/drawable/ic_chevron_16.xml new file mode 100644 index 0000000000..9bd8fab333 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/rounded_outline_accent_38dp.xml b/app/src/main/res/drawable/rounded_outline_accent_38dp.xml deleted file mode 100644 index d23a1b75e9..0000000000 --- a/app/src/main/res/drawable/rounded_outline_accent_38dp.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_outline_focusable_38dp.xml b/app/src/main/res/drawable/rounded_outline_focusable_38dp.xml deleted file mode 100644 index 1f903e8e21..0000000000 --- a/app/src/main/res/drawable/rounded_outline_focusable_38dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/selectable_rounded_outline.xml b/app/src/main/res/drawable/subscription_row_item_background.xml similarity index 57% rename from app/src/main/res/drawable/selectable_rounded_outline.xml rename to app/src/main/res/drawable/subscription_row_item_background.xml index 8d9c699935..daef74a7c5 100644 --- a/app/src/main/res/drawable/selectable_rounded_outline.xml +++ b/app/src/main/res/drawable/subscription_row_item_background.xml @@ -2,14 +2,15 @@ - - + + + - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/boost_loading_preference.xml b/app/src/main/res/layout/boost_loading_preference.xml index a423b74a3b..b95600aba6 100644 --- a/app/src/main/res/layout/boost_loading_preference.xml +++ b/app/src/main/res/layout/boost_loading_preference.xml @@ -6,14 +6,14 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="@dimen/dsl_settings_gutter" - android:layout_marginTop="20dp" + android:layout_marginTop="10dp" android:layout_marginEnd="@dimen/dsl_settings_gutter" android:alpha="0.8"> - \ No newline at end of file diff --git a/app/src/main/res/layout/checkout_dialog_fragment.xml b/app/src/main/res/layout/checkout_dialog_fragment.xml new file mode 100644 index 0000000000..9ec4001620 --- /dev/null +++ b/app/src/main/res/layout/checkout_dialog_fragment.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/donate_to_signal_fragment.xml b/app/src/main/res/layout/donate_to_signal_fragment.xml new file mode 100644 index 0000000000..4c87502054 --- /dev/null +++ b/app/src/main/res/layout/donate_to_signal_fragment.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/donation_pill_toggle.xml b/app/src/main/res/layout/donation_pill_toggle.xml new file mode 100644 index 0000000000..dc9654f15d --- /dev/null +++ b/app/src/main/res/layout/donation_pill_toggle.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_button_primary_wrapped.xml b/app/src/main/res/layout/dsl_button_primary_wrapped.xml new file mode 100644 index 0000000000..a26526d996 --- /dev/null +++ b/app/src/main/res/layout/dsl_button_primary_wrapped.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/layout/manage_donations_fragment.xml b/app/src/main/res/layout/manage_donations_fragment.xml new file mode 100644 index 0000000000..8c43346ca4 --- /dev/null +++ b/app/src/main/res/layout/manage_donations_fragment.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/my_support_preference.xml b/app/src/main/res/layout/my_support_preference.xml index f024f24836..8877d1ce3c 100644 --- a/app/src/main/res/layout/my_support_preference.xml +++ b/app/src/main/res/layout/my_support_preference.xml @@ -1,24 +1,24 @@ + app:cardElevation="0dp" + tools:viewBindingIgnore="true"> + android:background="@drawable/subscription_row_item_background"> - + app:layout_constraintTop_toTopOf="@id/my_support_badge" + tools:visibility="visible" /> - - diff --git a/app/src/main/res/layout/stripe_payment_in_progress_fragment.xml b/app/src/main/res/layout/stripe_payment_in_progress_fragment.xml new file mode 100644 index 0000000000..46ded80b46 --- /dev/null +++ b/app/src/main/res/layout/stripe_payment_in_progress_fragment.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/subscribe_activity.xml b/app/src/main/res/layout/subscribe_activity.xml deleted file mode 100644 index 2cb637c607..0000000000 --- a/app/src/main/res/layout/subscribe_activity.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/subscribe_fragment.xml b/app/src/main/res/layout/subscribe_fragment.xml deleted file mode 100644 index 1f16544761..0000000000 --- a/app/src/main/res/layout/subscribe_fragment.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/subscription_currency_selection.xml b/app/src/main/res/layout/subscription_currency_selection.xml index 496b4e6f80..754275f600 100644 --- a/app/src/main/res/layout/subscription_currency_selection.xml +++ b/app/src/main/res/layout/subscription_currency_selection.xml @@ -1,26 +1,12 @@ - - + android:paddingEnd="@dimen/dsl_settings_gutter" + tools:viewBindingIgnore="true"> \ No newline at end of file diff --git a/app/src/main/res/layout/subscription_preference.xml b/app/src/main/res/layout/subscription_preference.xml index 8346480d23..5b067747ea 100644 --- a/app/src/main/res/layout/subscription_preference.xml +++ b/app/src/main/res/layout/subscription_preference.xml @@ -1,20 +1,20 @@ + android:background="@drawable/subscription_row_item_background" + android:paddingHorizontal="16dp" + android:paddingVertical="12dp"> - - \ No newline at end of file diff --git a/app/src/main/res/layout/subscription_preference_loader.xml b/app/src/main/res/layout/subscription_preference_loader.xml index 8f012d836f..2001e11b7a 100644 --- a/app/src/main/res/layout/subscription_preference_loader.xml +++ b/app/src/main/res/layout/subscription_preference_loader.xml @@ -8,25 +8,25 @@ diff --git a/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml index aaa15334fc..c3c1e71899 100644 --- a/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml +++ b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml @@ -1,10 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment_page.xml b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment_page.xml index 046d109ff8..346a2448d2 100644 --- a/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment_page.xml +++ b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment_page.xml @@ -1,17 +1,18 @@ + android:orientation="vertical" + tools:viewBindingIgnore="true"> @@ -22,7 +23,7 @@ android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="16dp" - android:textAppearance="@style/TextAppearance.Signal.Title2" + android:textAppearance="@style/Signal.Text.TitleLarge" tools:text="Signal Sustainer" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index b487e560f5..06b80bbb76 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -101,20 +101,6 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> - - - - - - + + + + + + + android:label="internal_settings_fragment"> @@ -529,15 +520,6 @@ android:name="org.thoughtcrime.securesms.components.settings.app.subscription.manage.ManageDonationsFragment" android:label="manage_donations_fragment" tools:layout="@layout/dsl_settings_fragment"> - - - - - + + + + - - - - - - - - - - - - - - + - - - - - - - diff --git a/app/src/main/res/navigation/boosts.xml b/app/src/main/res/navigation/boosts.xml deleted file mode 100644 index b7d441e278..0000000000 --- a/app/src/main/res/navigation/boosts.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml new file mode 100644 index 0000000000..0c4507de49 --- /dev/null +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ldrtl/themes.xml b/app/src/main/res/values-ldrtl/themes.xml new file mode 100644 index 0000000000..90e5549e5f --- /dev/null +++ b/app/src/main/res/values-ldrtl/themes.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/signal_styles.xml b/app/src/main/res/values/signal_styles.xml index a78a77924d..adbb1b95ca 100644 --- a/app/src/main/res/values/signal_styles.xml +++ b/app/src/main/res/values/signal_styles.xml @@ -210,6 +210,9 @@ @style/TextSecure.Animation.FullScreenDialog + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5601f36cae..b2c189fa78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4397,10 +4397,8 @@ Badge - Signal is powered by people like you. Support technology that is built for you—not for your data—by joining the community of people that sustain it. Support technology that is built for you, not for your data, by joining the community that sustains Signal. - Make a recurring monthly donation to Signal to support technology built for you, not your data. Currency More Payment Options Cancel Subscription @@ -4415,8 +4413,10 @@ You will be charged the full amount (%1$s) of the new subscription price today. Your subscription will renew monthly. %s/month - %1$s/month · Renews %2$s - %1$s/month · Expires %2$s + + Renews %1$s + + Expires %1$s Signal is a nonprofit with no advertisers or investors, sustained only by the people who use and value it. Make a recurring monthly donation and receive a profile badge to share your support. @@ -4438,14 +4438,14 @@ Get badges for your profile by supporting Signal. Signal is a nonprofit with no advertisers or investors, supported only by people like you. - - Make a monthly donation + + Donate to Signal More Receipts - My subscription + My support Manage subscription Donation Receipts @@ -4457,10 +4457,6 @@ Gift a badge - Give Signal a Boost - - Make a one-time donation and earn a Boost badge for %1$d days. - Enter Custom Amount One-time contribution @@ -4501,7 +4497,7 @@ Please contact support for more information. Contact Support - Earn a %1$s badge + Get a %1$s badge Processing payment… @@ -5485,6 +5481,31 @@ Please try again. If the problem persists, + + + Powered by people like you. + + Continue + + Support technology built for you, not your data, by joining the community that sustains Signal. + + Monthly + + One-time + + + + Donate %1$s/month to Signal + + Get a %1$s badge + + Donate %1$s to Signal + + Get a %1$s badge for %2$d days + + + Cancelling… + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3e6f64f5e9..e6ecc0a6bc 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -538,6 +538,22 @@ 8dp + + + +