Add new joined donations screen.

devel
Alex Hart 2022-09-29 09:32:49 -03:00 zatwierdzone przez Evan Perry Grove
rodzic 92f10b8a86
commit a7ae6e62a3
80 zmienionych plików z 2519 dodań i 1822 usunięć

Wyświetl plik

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

Wyświetl plik

@ -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<Model> {
@ -28,23 +26,15 @@ object GiftRowItem {
override fun areContentsTheSame(newItem: Model): Boolean = giftBadge == newItem.giftBadge && price == newItem.price
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badgeView = itemView.findViewById<BadgeImageView>(R.id.badge)
private val titleView = itemView.findViewById<TextView>(R.id.title)
private val checkView = itemView.findViewById<View>(R.id.check)
private val taglineView = itemView.findViewById<TextView>(R.id.tagline)
private val priceView = itemView.findViewById<TextView>(R.id.price)
class ViewHolder(binding: SubscriptionPreferenceBinding) : BindingViewHolder<Model, SubscriptionPreferenceBinding>(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)
}
}
}

Wyświetl plik

@ -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<T : BadgeModel<T>> : MappingModel<T> {
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<GiftedBadgeModel>()
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<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<GooglePayResult>
class GooglePayResult(val requestCode: Int, val resultCode: Int, val data: Intent?)
@Parcelize
class GooglePayResult(val requestCode: Int, val resultCode: Int, val data: Intent?) : Parcelable
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<Boost> = listOf(),
val selectedBoost: Boost? = null,
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, currencySelection),
val isCustomAmountFocused: Boolean = false,
val stage: Stage = Stage.INIT,
val supportedCurrencyCodes: List<String> = emptyList()
) {
enum class Stage {
INIT,
READY,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
FAILURE
}
}

Wyświetl plik

@ -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<DonationEvent> = PublishSubject.create()
private val disposables = CompositeDisposable()
private val networkDisposable: Disposable
val state: LiveData<BoostState> = store.stateLiveData
val events: Observable<DonationEvent> = 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<String> {
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<Boost>, val defaultBoost: Boost?, val boostBadge: Badge, val supportedCurrencies: Set<Currency>)
class Factory(
private val boostRepository: BoostRepository,
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!!
}
}
companion object {
private val TAG = Log.tag(BoostViewModel::class.java)
}
}

Wyświetl plik

@ -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<String>) : DonateToSignalAction()
data class DisplayGatewaySelectorDialog(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class CancelSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class UpdateSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
}

Wyświetl plik

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

Wyświetl plik

@ -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<String>
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<Boost> = emptyList(),
val selectedBoost: Boost? = null,
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, selectedCurrency),
val isCustomAmountFocused: Boolean = false,
val donationStage: DonationStage = DonationStage.INIT,
val selectableCurrencyCodes: List<String> = 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<Subscription> = emptyList(),
private val _activeSubscription: ActiveSubscription? = null,
val selectedSubscription: Subscription? = null,
val donationStage: DonationStage = DonationStage.INIT,
val selectableCurrencyCodes: List<String> = 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
}
}

Wyświetl plik

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

Wyświetl plik

@ -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<DonateToSignalAction>()
private val _activeSubscription = PublishSubject.create<ActiveSubscription>()
private var gatewayRequest: GatewayRequest? = null
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val actions: Observable<DonateToSignalAction> = _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<Map<Currency, List<Boost>>> = boostRepository.getBoosts().toObservable()
val oneTimeCurrency: Observable<Currency> = 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<Boolean> = 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<List<Subscription>>) {
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>): Subscription? {
return if (activeSubscription.isActive) {
subscriptions.firstOrNull { it.level == activeSubscription.activeSubscription.level }
} else {
subscriptions.firstOrNull()
}
}
private fun ensureValidSubscriptionCurrency(allSubscriptions: Single<List<Subscription>>) {
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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, boostRepository)) as T
}
}
}

Wyświetl plik

@ -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<Model> {
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<Model, DonationPillToggleBinding>(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
}
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(GatewaySelectorViewModel(args, repository)) as T
}
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<StripeStage> = 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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StripePaymentInProgressViewModel(donationPaymentRepository)) as T
}
}
}

Wyświetl plik

@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
enum class StripeStage {
INIT,
PAYMENT_PIPELINE,
CANCELLING,
FAILED,
COMPLETE
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<Subscription> = emptyList(),
private val subscriptionRedemptionState: SubscriptionRedemptionState = SubscriptionRedemptionState.NONE
) {
fun getRedemptionState(): SubscriptionRedemptionState {
fun getMonthlyDonorRedemptionState(): SubscriptionRedemptionState {
return when (transactionState) {
TransactionState.Init -> subscriptionRedemptionState
TransactionState.NetworkFailure -> subscriptionRedemptionState

Wyświetl plik

@ -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<Boolean> = LevelUpdate.isProcessing.distinctUntilChanged()
val activeSubscription: Single<ActiveSubscription> = 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(

Wyświetl plik

@ -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<Button.Model.Primary>
private lateinit var cancelSubscriptionButtonViewHolder: Button.ViewHolder<Button.Model.SecondaryNoOutline>
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
}
}

Wyświetl plik

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

Wyświetl plik

@ -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<DonationEvent> = PublishSubject.create()
private val disposables = CompositeDisposable()
private val networkDisposable: Disposable
val state: LiveData<SubscribeState> = store.stateLiveData
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
private var subscriptionToPurchase: Subscription? = null
private val activeSubscriptionSubject = PublishSubject.create<ActiveSubscription>()
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<String>? {
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<Currency> = SignalStore.donationsValues().observableSubscriptionCurrency
val allSubscriptions: Single<List<Subscription>> = 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>): 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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!!
}
}
companion object {
private val TAG = Log.tag(SubscribeViewModel::class.java)
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<Primary>(title, icon, isEnabled, onClick)
/**
* Large primary button with width set to wrap_content
*/
class PrimaryWrapped(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
onClick: () -> Unit
) : Model<PrimaryWrapped>(title, icon, isEnabled, onClick)
class Tonal(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<Model>(isEnabled = isEnabled) {
@ -114,27 +114,18 @@ data class Subscription(
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(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<Model, SubscriptionPreferenceBinding>(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
}
}
}

Wyświetl plik

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/signal_colorSecondaryContainer" android:state_selected="true" />
<item android:color="@color/signal_colorSurfaceVariant" />
</selector>

Wyświetl plik

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/signal_accent_primary_transparent_15" android:state_selected="true" />
<item android:color="@color/transparent" />
<item android:color="@color/signal_colorSecondaryContainer" android:state_selected="true" />
<item android:color="@color/signal_colorSurface2" />
</selector>

Wyświetl plik

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/signal_accent_primary" android:state_selected="true" />
<item android:color="@color/signal_button_secondary_stroke" />
<item android:color="@color/signal_colorPrimary" android:state_selected="true" />
<item android:color="@color/transparent" />
</selector>

Wyświetl plik

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="false">
<shape
android:shape="rectangle">
<stroke
android:width="1.5dp"
android:color="@color/core_grey_65" />
<corners android:radius="@dimen/material_button_full_round_corner_radius" />
</shape>
</item>
<item>
<shape
android:shape="rectangle">
<stroke
android:width="1.5dp"
android:color="@color/signal_button_secondary_stroke" />
<corners android:radius="@dimen/material_button_full_round_corner_radius" />
</shape>
</item>
</selector>

Wyświetl plik

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/signal_button_secondary_stroke" />
<corners android:radius="38dp" />
<solid android:color="@color/signal_colorSurface2" />
<corners android:radius="18dp" />
</shape>

Wyświetl plik

@ -1,8 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1.5dp"
android:color="@color/signal_button_secondary_stroke" />
<corners android:radius="@dimen/material_button_full_round_corner_radius" />
<solid android:color="@color/signal_colorSurface2" />
<corners android:radius="8dp" />
</shape>

Wyświetl plik

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/custom_donation_amount_background_selected" android:state_selected="true" />
<item android:drawable="@drawable/custom_donation_amount_background_normal" />
</selector>

Wyświetl plik

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/signal_colorSurface2" />
<corners android:radius="18dp" />
</shape>

Wyświetl plik

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/signal_colorSecondaryContainer" />
<stroke android:color="@color/signal_colorPrimary" android:width="2dp" />
<corners android:radius="18dp" />
</shape>

Wyświetl plik

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M7.896,15L3,10.26L4.224,9.075L7.896,12.63L15.776,5L17,6.185L7.896,15Z"
android:fillColor="#151D2C"/>
</vector>

Wyświetl plik

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M2,5.651L3.151,4.5L8,9.349L12.849,4.5L14,5.651L8,11.651L2,5.651Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

Wyświetl plik

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<stroke android:color="@color/signal_button_secondary_stroke" android:width="1.5dp" />
<corners android:radius="38dp" />
</shape>

Wyświetl plik

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/signal_accent_primary_transparent_15" />
<stroke android:color="@color/signal_accent_primary" android:width="1dp" />
<corners android:radius="38dp" />
</shape>

Wyświetl plik

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/rounded_outline_accent_38dp" android:state_selected="true" />
<item android:drawable="@drawable/rounded_outline_38dp" />
</selector>

Wyświetl plik

@ -2,14 +2,15 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<stroke android:color="@color/signal_accent_primary" android:width="2dp" />
<corners android:radius="10dp" />
<solid android:color="@color/signal_colorSecondaryContainer" />
<stroke android:color="@color/signal_colorPrimary" android:width="2dp" />
<corners android:radius="18dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<stroke android:color="@color/signal_divider_minor" android:width="1dp" />
<corners android:radius="10dp" />
<solid android:color="@color/signal_colorSurface2" />
<corners android:radius="18dp" />
</shape>
</item>
</selector>

Wyświetl plik

@ -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">
<View
android:id="@+id/boost_1"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_height="58sp"
android:background="@drawable/boost_loading_preference_background"
app:layout_constraintEnd_toStartOf="@id/boost_2"
app:layout_constraintStart_toStartOf="parent"
@ -22,7 +22,7 @@
<View
android:id="@+id/boost_2"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_height="58sp"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:background="@drawable/boost_loading_preference_background"
@ -33,7 +33,7 @@
<View
android:id="@+id/boost_3"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_height="58sp"
android:background="@drawable/boost_loading_preference_background"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/boost_2"
@ -42,8 +42,8 @@
<View
android:id="@+id/boost_4"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginTop="12dp"
android:layout_height="58sp"
android:layout_marginTop="19dp"
android:background="@drawable/boost_loading_preference_background"
app:layout_constraintEnd_toStartOf="@id/boost_5"
app:layout_constraintStart_toStartOf="parent"
@ -52,9 +52,9 @@
<View
android:id="@+id/boost_5"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_height="58sp"
android:layout_marginStart="10dp"
android:layout_marginTop="12dp"
android:layout_marginTop="19dp"
android:layout_marginEnd="10dp"
android:background="@drawable/boost_loading_preference_background"
app:layout_constraintEnd_toStartOf="@id/boost_6"
@ -64,8 +64,8 @@
<View
android:id="@+id/boost_6"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginTop="12dp"
android:layout_height="58sp"
android:layout_marginTop="19dp"
android:background="@drawable/boost_loading_preference_background"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/boost_5"
@ -74,8 +74,8 @@
<View
android:id="@+id/boost_custom"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginTop="12dp"
android:layout_height="58sp"
android:layout_marginTop="19dp"
android:background="@drawable/boost_loading_preference_background"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

Wyświetl plik

@ -6,7 +6,7 @@
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">
<com.google.android.material.button.MaterialButton
@ -17,12 +17,12 @@
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
app:cornerRadius="18dp"
app:layout_constraintEnd_toStartOf="@id/boost_2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:strokeColor="@color/signal_selectable_button_stroke"
app:strokeWidth="1.5dp"
app:strokeWidth="2dp"
tools:text="$3" />
<com.google.android.material.button.MaterialButton
@ -35,12 +35,12 @@
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
app:cornerRadius="18dp"
app:layout_constraintEnd_toStartOf="@id/boost_3"
app:layout_constraintStart_toEndOf="@id/boost_1"
app:layout_constraintTop_toTopOf="parent"
app:strokeColor="@color/signal_selectable_button_stroke"
app:strokeWidth="1.5dp"
app:strokeWidth="2dp"
tools:text="$5" />
<com.google.android.material.button.MaterialButton
@ -51,12 +51,12 @@
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
app:cornerRadius="18dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/boost_2"
app:layout_constraintTop_toTopOf="parent"
app:strokeColor="@color/signal_selectable_button_stroke"
app:strokeWidth="1.5dp"
app:strokeWidth="2dp"
tools:text="$10" />
<com.google.android.material.button.MaterialButton
@ -64,16 +64,16 @@
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginTop="12dp"
android:layout_marginTop="19dp"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
app:cornerRadius="18dp"
app:layout_constraintEnd_toStartOf="@id/boost_5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/boost_1"
app:strokeColor="@color/signal_selectable_button_stroke"
app:strokeWidth="1.5dp"
app:strokeWidth="2dp"
tools:text="$20" />
<com.google.android.material.button.MaterialButton
@ -82,17 +82,17 @@
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginStart="10dp"
android:layout_marginTop="12dp"
android:layout_marginTop="19dp"
android:layout_marginEnd="10dp"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
app:cornerRadius="18dp"
app:layout_constraintEnd_toStartOf="@id/boost_6"
app:layout_constraintStart_toEndOf="@id/boost_4"
app:layout_constraintTop_toBottomOf="@id/boost_2"
app:strokeColor="@color/signal_selectable_button_stroke"
app:strokeWidth="1.5dp"
app:strokeWidth="2dp"
tools:text="$50" />
<com.google.android.material.button.MaterialButton
@ -100,25 +100,25 @@
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginTop="12dp"
android:layout_marginTop="19dp"
android:gravity="center"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
app:cornerRadius="18dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/boost_5"
app:layout_constraintTop_toBottomOf="@id/boost_3"
app:strokeColor="@color/signal_selectable_button_stroke"
app:strokeWidth="1.5dp"
app:strokeWidth="2dp"
tools:text="$100" />
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/boost_custom"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginTop="12dp"
android:background="@drawable/rounded_outline_focusable_38dp"
android:layout_marginTop="19dp"
android:background="@drawable/custom_donation_amount_background"
android:gravity="center"
android:hint="@string/Boost__enter_custom_amount"
android:imeOptions="actionDone"
@ -130,5 +130,4 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/boost_4" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.thoughtcrime.securesms.components.ProgressCard
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/boost1_animation"
android:layout_width="60dp"
android:layout_height="360dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:lottie_rawRes="@raw/boost_smile" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/boost2_animation"
android:layout_width="60dp"
android:layout_height="360dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:lottie_rawRes="@raw/boost_clap" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/boost3_animation"
android:layout_width="60dp"
android:layout_height="360dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:lottie_rawRes="@raw/boost_heart_eyes" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/boost4_animation"
android:layout_width="60dp"
android:layout_height="360dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:lottie_rawRes="@raw/boost_fire" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/boost5_animation"
android:layout_width="60dp"
android:layout_height="360dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:lottie_rawRes="@raw/boost_shock" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/boost6_animation"
android:layout_width="60dp"
android:layout_height="360dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:lottie_rawRes="@raw/boost_rockets" />
<include layout="@layout/dsl_settings_toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/dsl_settings_gutter">
<androidx.constraintlayout.widget.Barrier
android:id="@+id/start_barrier"
android:layout_width="0dp"
android:layout_height="match_parent"
app:barrierDirection="start"
app:constraint_referenced_ids="monthly_invis_start,one_time_invis_start"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/end_barrier"
android:layout_width="0dp"
android:layout_height="match_parent"
app:barrierDirection="end"
app:constraint_referenced_ids="monthly_invis_end,one_time_invis_end" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/center_guide"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />
<!-- Invisible Views -->
<com.google.android.material.button.MaterialButton
android:id="@+id/monthly_invis_start"
style="@style/Signal.Widget.Button.Medium.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/DonationPillToggle__monthly"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurface"
android:visibility="invisible"
app:backgroundTint="@color/donation_pill_toggle_background_tint"
app:icon="@drawable/ic_check_20"
app:iconGravity="textStart"
app:iconSize="20dp"
app:iconTint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/center_guide"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.DonationTogglePill.Start" />
<com.google.android.material.button.MaterialButton
android:id="@+id/one_time_invis_start"
style="@style/Signal.Widget.Button.Medium.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/DonationPillToggle__one_time"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurface"
android:visibility="invisible"
app:backgroundTint="@color/donation_pill_toggle_background_tint"
app:icon="@drawable/ic_check_20"
app:iconGravity="textStart"
app:iconSize="20dp"
app:iconTint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/center_guide"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.DonationTogglePill.Start" />
<com.google.android.material.button.MaterialButton
android:id="@+id/monthly_invis_end"
style="@style/Signal.Widget.Button.Medium.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/DonationPillToggle__monthly"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurface"
android:visibility="invisible"
app:backgroundTint="@color/donation_pill_toggle_background_tint"
app:icon="@drawable/ic_check_20"
app:iconGravity="textStart"
app:iconSize="20dp"
app:iconTint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/center_guide"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.DonationTogglePill.End" />
<com.google.android.material.button.MaterialButton
android:id="@+id/one_time_invis_end"
style="@style/Signal.Widget.Button.Medium.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/DonationPillToggle__one_time"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurface"
android:visibility="invisible"
app:backgroundTint="@color/donation_pill_toggle_background_tint"
app:icon="@drawable/ic_check_20"
app:iconGravity="textStart"
app:iconSize="20dp"
app:iconTint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/center_guide"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.DonationTogglePill.End" />
<!-- End invisible views -->
<com.google.android.material.button.MaterialButton
android:id="@+id/one_time"
style="@style/Signal.Widget.Button.Medium.Primary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/DonationPillToggle__one_time"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurface"
app:backgroundTint="@color/donation_pill_toggle_background_tint"
app:iconGravity="textStart"
app:iconSize="20dp"
app:iconTint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/center_guide"
app:layout_constraintStart_toEndOf="@id/start_barrier"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.DonationTogglePill.Start" />
<com.google.android.material.button.MaterialButton
android:id="@+id/monthly"
style="@style/Signal.Widget.Button.Medium.Primary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/DonationPillToggle__monthly"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurface"
app:backgroundTint="@color/donation_pill_toggle_background_tint"
app:iconGravity="textStart"
app:iconSize="20dp"
app:iconTint="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/end_barrier"
app:layout_constraintStart_toEndOf="@id/center_guide"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.DonationTogglePill.End"
tools:icon="@drawable/ic_check_20" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.button.MaterialButton
android:id="@+id/button"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
tools:text="Primary button" />
</FrameLayout>

Wyświetl plik

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<include layout="@layout/dsl_settings_toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -1,24 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
app:cardCornerRadius="10dp"
app:cardElevation="0dp">
app:cardElevation="0dp"
tools:viewBindingIgnore="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_outline">
android:background="@drawable/subscription_row_item_background">
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/my_support_badge"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="16dp"
app:badge_size="xlarge"
app:layout_constraintBottom_toBottomOf="@id/my_support_expiry"
@ -26,22 +26,25 @@
app:layout_constraintTop_toTopOf="@id/my_support_title"
tools:src="@drawable/test_gradient" />
<ProgressBar
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/my_support_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?circularProgressIndicatorStyle"
android:layout_width="40dp"
android:layout_height="40dp"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/my_support_badge"
app:layout_constraintEnd_toEndOf="@id/my_support_badge"
app:layout_constraintStart_toStartOf="@id/my_support_badge"
app:layout_constraintTop_toTopOf="@id/my_support_badge" />
app:layout_constraintTop_toTopOf="@id/my_support_badge"
tools:visibility="visible" />
<TextView
android:id="@+id/my_support_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="15dp"
android:layout_marginTop="11dp"
android:layout_marginEnd="12dp"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
app:layout_constraintEnd_toEndOf="parent"
@ -49,33 +52,19 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="Subscription Name" />
<TextView
android:id="@+id/my_support_price"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="12dp"
android:textAppearance="@style/TextAppearance.Signal.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/my_support_badge"
app:layout_constraintTop_toBottomOf="@id/my_support_title"
tools:text="Earn a badge!" />
<TextView
android:id="@+id/my_support_expiry"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="15dp"
android:layout_marginBottom="11dp"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/my_support_badge"
app:layout_constraintTop_toBottomOf="@id/my_support_price"
app:layout_constraintTop_toBottomOf="@id/my_support_title"
tools:text="$400.00" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minWidth="232dp"
android:minHeight="215dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minWidth="232dp"
android:minHeight="215dp"
android:gravity="center"
android:orientation="vertical">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress_card_child"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:indeterminate="true"
app:indicatorColor="@color/signal_colorPrimary" />
<TextView
android:id="@+id/progress_card_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:gravity="center"
android:text="@string/SubscribeFragment__processing_payment"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:textColor="@color/signal_colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

Wyświetl plik

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<include
android:id="@+id/donate_with_googlepay"
layout="@layout/donate_with_googlepay_button" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -1,54 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/dsl_settings_toolbar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="16dp"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="@id/fixed_button_container"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<LinearLayout
android:id="@+id/fixed_button_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent">
<FrameLayout
android:id="@+id/update_button_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include layout="@layout/dsl_button_primary" />
</FrameLayout>
<FrameLayout
android:id="@+id/cancel_button_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include layout="@layout/dsl_button_secondary" />
</FrameLayout>
<include
android:id="@+id/pay_button_wrapper"
layout="@layout/google_pay_button_pref" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -1,26 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter">
<TextView
android:id="@+id/subscription_currency_selection_donation_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="13dp"
android:gravity="center_vertical|end"
android:minHeight="48dp"
android:text="@string/SubscribeFragment__currency"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toStartOf="@id/subscription_currency_selection_spinner"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:paddingEnd="@dimen/dsl_settings_gutter"
tools:viewBindingIgnore="true">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/subscription_currency_selection_spinner"
@ -30,14 +16,16 @@
android:drawablePadding="8dp"
android:gravity="center"
android:minHeight="32dp"
android:padding="12dp"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:textAppearance="@style/Signal.Text.LabelMedium"
android:textColor="@color/currency_selector_text_color"
app:drawableEndCompat="@drawable/ic_chevron_down_20"
app:drawableTint="@color/conversation_mention_background_color"
app:layout_constraintBottom_toBottomOf="@id/subscription_currency_selection_donation_amount"
app:drawableEndCompat="@drawable/ic_chevron_16"
app:drawableTint="@color/signal_colorOutline"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/subscription_currency_selection_donation_amount"
app:layout_constraintTop_toTopOf="@id/subscription_currency_selection_donation_amount"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="USD" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -1,20 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="12dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@drawable/selectable_rounded_outline"
android:padding="16dp">
android:background="@drawable/subscription_row_item_background"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/badge"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_width="40dp"
android:layout_height="40dp"
app:badge_size="xlarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
@ -49,24 +49,10 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Signal.Body2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/badge"
app:layout_constraintTop_toBottomOf="@id/title"
tools:text="Earn a badge!" />
<TextView
android:id="@+id/price"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="4dp"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/badge"
app:layout_constraintTop_toBottomOf="@id/tagline"
tools:text="$400.00" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -8,25 +8,25 @@
<View
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_height="64dp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="12dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@drawable/subscription_loading_preference_background" />
<View
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_height="64dp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="12dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@drawable/subscription_loading_preference_background" />
<View
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_height="64dp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="12dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@drawable/subscription_loading_preference_background" />

Wyświetl plik

@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
<ImageView
@ -63,4 +62,9 @@
app:iconTint="@color/signal_colorOnSurfaceVariant"
tools:icon="@drawable/ic_open_20" />
<Space
android:id="@+id/single_page_space"
android:layout_width="match_parent"
android:layout_height="16dp" />
</LinearLayout>

Wyświetl plik

@ -1,17 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical">
android:orientation="vertical"
tools:viewBindingIgnore="true">
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/badge"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_width="112dp"
android:layout_height="112dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="36dp"
android:contentDescription="@string/BadgesOverviewFragment__featured_badge"
app:badge_size="xlarge"
tools:src="@drawable/test_gradient" />
@ -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" />
<TextView
@ -38,7 +39,8 @@
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:textAlignment="center"
android:textAppearance="@style/Signal.Text.Body"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:textColor="@color/signal_colorOnSurfaceVariant"
tools:text="Paige supports Signal by making a monthly donation. Get your own badge by donating below." />
</LinearLayout>

Wyświetl plik

@ -101,20 +101,6 @@
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_subscribeFragment"
app:destination="@id/subscribeFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_boostsFragment"
app:destination="@id/boosts"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_storyPrivacySettings"
app:destination="@+id/story_privacy_settings"
@ -173,8 +159,6 @@
<include app:graph="@navigation/app_settings_change_number" />
<include app:graph="@navigation/boosts" />
<fragment
android:id="@+id/advancedPinSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.wrapped.WrappedAdvancedPinPreferenceFragment"
@ -437,16 +421,6 @@
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_direct_to_subscriptions"
app:destination="@id/subscribeFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_manageDonations"
app:destination="@id/manageDonationsFragment"
@ -457,6 +431,23 @@
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_donateToSignal"
app:destination="@id/donate_to_signal"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true">
<argument
android:name="start_type"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType"
app:nullable="false" />
</action>
<action
android:id="@+id/action_direct_to_notificationProfiles"
app:destination="@id/notificationProfilesFragment"
@ -502,7 +493,7 @@
<fragment
android:id="@+id/internalSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalSettingsFragment"
android:label="internal_settings_fragment" >
android:label="internal_settings_fragment">
<action
android:id="@+id/action_internalSettingsFragment_to_donorErrorConfigurationFragment"
app:destination="@id/donorErrorConfigurationFragment" />
@ -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">
<action
android:id="@+id/action_manageDonationsFragment_to_subscribeFragment"
app:destination="@id/subscribeFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_manageDonationsFragment_to_manage_badges"
app:destination="@id/manage_badges"
@ -545,14 +527,6 @@
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_manageDonationsFragment_to_boosts"
app:destination="@id/boosts"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_manageDonationsFragment_to_donationReceiptListFragment"
app:destination="@id/donationReceiptListFragment"
@ -563,6 +537,19 @@
<action
android:id="@+id/action_manageDonationsFragment_to_subscribeLearnMoreBottomSheetDialog"
app:destination="@id/subscribeLearnMoreBottomSheetDialog" />
<action
android:id="@+id/action_manageDonationsFragment_to_donateToSignalFragment"
app:destination="@id/donate_to_signal"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit">
<argument
android:name="start_type"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType"
app:nullable="false" />
</action>
</fragment>
<fragment
@ -590,42 +577,7 @@
app:argType="long" />
</fragment>
<fragment
android:id="@+id/subscribeFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeFragment"
android:label="subscribe_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_subscribeFragment_to_setDonationCurrencyFragment"
app:destination="@id/setDonationCurrencyFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_subscribeFragment_to_subscribeLearnMoreBottomSheetDialog"
app:destination="@id/subscribeLearnMoreBottomSheetDialog" />
<action
android:id="@+id/action_subscribeFragment_to_subscribeThanksForYourSupportBottomSheetDialog"
app:destination="@id/subscribeThanksForYourSupportBottomSheetDialog" />
</fragment>
<dialog
android:id="@+id/setDonationCurrencyFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.currency.SetCurrencyFragment"
android:label="set_currency_fragment"
tools:layout="@layout/dsl_settings_fragment">
<argument
android:name="isBoost"
app:argType="boolean" />
<argument
android:name="supportedCurrencyCodes"
app:argType="string[]" />
</dialog>
<include app:graph="@navigation/donate_to_signal" />
<dialog
android:id="@+id/subscribeLearnMoreBottomSheetDialog"
@ -633,23 +585,6 @@
android:label="subscribe_learn_more_bottom_sheet_dialog"
tools:layout="@layout/subscribe_learn_more_bottom_sheet_dialog_fragment" />
<dialog
android:id="@+id/subscribeThanksForYourSupportBottomSheetDialog"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment"
android:label="subscribe_thanks_for_your_support_bottom_sheet_dialog"
tools:layout="@layout/thanks_for_your_support_bottom_sheet_dialog_fragment">
<argument
android:name="badge"
app:argType="org.thoughtcrime.securesms.badges.models.Badge"
app:nullable="false" />
<argument
android:name="isBoost"
android:defaultValue="false"
app:argType="boolean" />
</dialog>
<include app:graph="@navigation/manage_badges" />
<!-- endregion -->

Wyświetl plik

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/boosts"
app:startDestination="@id/boostFragment">
<dialog
android:id="@+id/boostFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostFragment"
android:label="boost_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_boostFragment_to_setDonationCurrencyFragment"
app:destination="@id/setDonationCurrencyFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_boostFragment_to_boostThanksForYourSupportBottomSheetDialog"
app:destination="@id/boostThanksForYourSupportBottomSheetDialog" />
</dialog>
<dialog
android:id="@+id/setDonationCurrencyFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.currency.SetCurrencyFragment"
android:label="set_currency_fragment"
tools:layout="@layout/dsl_settings_fragment">
<argument
android:name="isBoost"
app:argType="boolean" />
<argument
android:name="supportedCurrencyCodes"
app:argType="string[]" />
</dialog>
<dialog
android:id="@+id/boostThanksForYourSupportBottomSheetDialog"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment"
android:label="boost_thanks_for_your_support_bottom_sheet_dialog"
tools:layout="@layout/thanks_for_your_support_bottom_sheet_dialog_fragment">
<argument
android:name="badge"
app:argType="org.thoughtcrime.securesms.badges.models.Badge"
app:nullable="false" />
<argument
android:name="isBoost"
app:argType="boolean"
android:defaultValue="false" />
</dialog>
</navigation>

Wyświetl plik

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/donate_to_signal"
app:startDestination="@id/donateToSignalFragment">
<fragment
android:id="@+id/donateToSignalFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment"
android:label="donate_to_signal_fragment"
tools:layout="@layout/dsl_settings_fragment">
<argument
android:name="start_type"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType"
app:nullable="false" />
<action
android:id="@+id/action_donateToSignalFragment_to_setDonationCurrencyFragment"
app:destination="@id/setDonationCurrencyFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_donateToSignalFragment_to_gatewaySelectorBottomSheetDialog"
app:destination="@id/gatewaySelectorBottomSheet" />
<action
android:id="@+id/action_donateToSignalFragment_to_stripePaymentInProgressFragment"
app:destination="@id/stripePaymentInProgressFragment" />
<action
android:id="@+id/action_donateToSignalFragment_to_thanksForYourSupportBottomSheetDialog"
app:destination="@id/thanksForYourSupportBottomSheetDialog" />
<action
android:id="@+id/action_donateToSignalFragment_to_subscribeLearnMoreBottomSheetDialog"
app:destination="@id/subscribeLearnMoreBottomSheetDialog" />
</fragment>
<dialog
android:id="@+id/gatewaySelectorBottomSheet"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet"
android:label="gateway_selector_bottom_sheet"
tools:layout="@layout/dsl_settings_bottom_sheet">
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
</dialog>
<dialog
android:id="@+id/setDonationCurrencyFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.currency.SetCurrencyFragment"
android:label="set_currency_fragment"
tools:layout="@layout/dsl_settings_fragment">
<argument
android:name="isBoost"
app:argType="boolean" />
<argument
android:name="supportedCurrencyCodes"
app:argType="string[]" />
</dialog>
<dialog
android:id="@+id/stripePaymentInProgressFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment"
android:label="stripe_payment_in_progress_fragment"
tools:layout="@layout/stripe_payment_in_progress_fragment">
<argument
android:name="action"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripeAction"
app:nullable="false" />
<argument
android:name="request"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
</dialog>
<dialog
android:id="@+id/thanksForYourSupportBottomSheetDialog"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment"
android:label="thanks_for_your_support_bottom_sheet_dialog"
tools:layout="@layout/thanks_for_your_support_bottom_sheet_dialog_fragment">
<argument
android:name="badge"
app:argType="org.thoughtcrime.securesms.badges.models.Badge"
app:nullable="false" />
<argument
android:name="isBoost"
app:argType="boolean"
android:defaultValue="false" />
</dialog>
<dialog
android:id="@+id/subscribeLearnMoreBottomSheetDialog"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeLearnMoreBottomSheetDialogFragment"
android:label="subscribe_learn_more_bottom_sheet_dialog"
tools:layout="@layout/subscribe_learn_more_bottom_sheet_dialog_fragment" />
</navigation>

Wyświetl plik

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="ShapeAppearanceOverlay.Signal.DonationTogglePill.End" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSizeTopLeft">12dp</item>
<item name="cornerSizeBottomLeft">12dp</item>
<item name="cornerSizeTopRight">0dp</item>
<item name="cornerSizeBottomRight">0dp </item>
</style>
<style name="ShapeAppearanceOverlay.Signal.DonationTogglePill.Start" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSizeTopLeft">0dp</item>
<item name="cornerSizeBottomLeft">0dp</item>
<item name="cornerSizeTopRight">12dp</item>
<item name="cornerSizeBottomRight">12dp</item>
</style>
</resources>

Wyświetl plik

@ -210,6 +210,9 @@
<item name="android:windowAnimationStyle">@style/TextSecure.Animation.FullScreenDialog</item>
</style>
<style name="Signal.DayNight.Dialog.FullScreen.Donate">
</style>
<style name="Signal.Media.Dialog.AddMessageDialog" parent="TextSecure.MediaPreview">
<item name="android:windowAnimationStyle">@style/TextSecure.Animation.AddMessageDialog</item>
</style>

Wyświetl plik

@ -4397,10 +4397,8 @@
<string name="ImageView__badge">Badge</string>
<string name="SubscribeFragment__signal_is_powered_by_people_like_you">Signal is powered by people like you.</string>
<string name="SubscribeFragment__support_technology_that_is_built_for_you">Support technology that is built for you—not for your data—by joining the community of people that sustain it.</string>
<string name="SubscribeFragment__support_technology_that_is_built_for_you_not">Support technology that is built for you, not for your data, by joining the community that sustains Signal.</string>
<string name="SubscribeFragment__make_a_recurring_monthly_donation">Make a recurring monthly donation to Signal to support technology built for you, not your data.</string>
<string name="SubscribeFragment__currency">Currency</string>
<string name="SubscribeFragment__more_payment_options">More Payment Options</string>
<string name="SubscribeFragment__cancel_subscription">Cancel Subscription</string>
@ -4415,8 +4413,10 @@
<string name="SubscribeFragment__you_will_be_charged_the_full_amount_s_of">You will be charged the full amount (%1$s) of the new subscription price today. Your subscription will renew monthly.</string>
<string name="Subscription__s_per_month">%s/month</string>
<string name="Subscription__s_per_month_dot_renews_s">%1$s/month · Renews %2$s</string>
<string name="Subscription__s_per_month_dot_expires_s">%1$s/month · Expires %2$s</string>
<!-- Shown when a subscription is active and isn't going to expire at the end of the term -->
<string name="Subscription__renews_s">Renews %1$s</string>
<!-- Shown when a subscription is active and is going to expire at the end of the term -->
<string name="Subscription__expires_s">Expires %1$s</string>
<!-- First small text blurb on learn more sheet -->
<string name="SubscribeLearnMoreBottomSheetDialogFragment__signal_is_a_nonprofit_with_no">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.</string>
@ -4438,14 +4438,14 @@
<string name="BecomeASustainerFragment__get_badges">Get badges for your profile by supporting Signal.</string>
<string name="BecomeASustainerFragment__signal_is_a_non_profit">Signal is a nonprofit with no advertisers or investors, supported only by people like you.</string>
<!-- Button label for creating a monthly donation -->
<string name="ManageDonationsFragment__make_a_monthly_donation">Make a monthly donation</string>
<!-- Button label for creating a donation -->
<string name="ManageDonationsFragment__donate_to_signal">Donate to Signal</string>
<!-- Heading for more area of manage subscriptions page -->
<string name="ManageDonationsFragment__more">More</string>
<!-- Heading for receipts area of manage subscriptions page -->
<string name="ManageDonationsFragment__receipts">Receipts</string>
<!-- Heading for my subscription area of manage subscriptions page -->
<string name="ManageDonationsFragment__my_subscription">My subscription</string>
<string name="ManageDonationsFragment__my_support">My support</string>
<string name="ManageDonationsFragment__manage_subscription">Manage subscription</string>
<!-- Label for Donation Receipts button -->
<string name="ManageDonationsFragment__donation_receipts">Donation Receipts</string>
@ -4457,10 +4457,6 @@
<!-- Preference label to launch badge gifting -->
<string name="ManageDonationsFragment__gift_a_badge">Gift a badge</string>
<string name="BoostFragment__give_signal_a_boost">Give Signal a Boost</string>
<!-- Description text in boost sheet -->
<string name="BoostFragment__make_a_one_time">Make a one-time donation and earn a Boost badge for %1$d days.</string>
<string name="Boost__enter_custom_amount">Enter Custom Amount</string>
<string name="Boost__one_time_contribution">One-time contribution</string>
@ -4501,7 +4497,7 @@
<string name="Subscription__please_contact_support_for_more_information">Please contact support for more information.</string>
<string name="Subscription__contact_support">Contact Support</string>
<string name="Subscription__earn_a_s_badge">Earn a %1$s badge</string>
<string name="Subscription__get_a_s_badge">Get a %1$s badge</string>
<string name="SubscribeFragment__processing_payment">Processing payment…</string>
<!-- Displayed in notification when user payment fails to process on Stripe -->
@ -5485,6 +5481,31 @@
<string name="ExportSmsFullError__please_try_again_if_the_problem_persists">Please try again. If the problem persists, </string>
<!-- DonateToSignalFragment -->
<!-- Title below avatar -->
<string name="DonateToSignalFragment__powered_by">Powered by people like you.</string>
<!-- Continue button label -->
<string name="DonateToSignalFragment__continue">Continue</string>
<!-- Description below title -->
<string name="DonateToSignalFragment__support_technology">Support technology built for you, not your data, by joining the community that sustains Signal.</string>
<!-- Donation pill toggle monthly text -->
<string name="DonationPillToggle__monthly">Monthly</string>
<!-- Donation pill toggle one-time text -->
<string name="DonationPillToggle__one_time">One-time</string>
<!-- GatewaySelectorBottomSheet -->
<!-- Sheet title when subscribing -->
<string name="GatewaySelectorBottomSheet__donate_s_month_to_signal">Donate %1$s/month to Signal</string>
<!-- Sheet summary when subscribing -->
<string name="GatewaySelectorBottomSheet__get_a_s_badge">Get a %1$s badge</string>
<!-- Sheet title when giving a one-time donation -->
<string name="GatewaySelectorBottomSheet__donate_s_to_signal">Donate %1$s to Signal</string>
<!-- Sheet summary when giving a one-time donation -->
<string name="GatewaySelectorBottomSheet__get_a_s_badge_for_d_days">Get a %1$s badge for %2$d days</string>
<!-- StripePaymentInProgressFragment -->
<string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string>
<!-- EOF -->
</resources>

Wyświetl plik

@ -538,6 +538,22 @@
<item name="cornerSize">8dp</item>
</style>
<style name="ShapeAppearanceOverlay.Signal.DonationTogglePill.Start" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSizeTopLeft">12dp</item>
<item name="cornerSizeBottomLeft">12dp</item>
<item name="cornerSizeTopRight">0dp</item>
<item name="cornerSizeBottomRight">0dp</item>
</style>
<style name="ShapeAppearanceOverlay.Signal.DonationTogglePill.End" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSizeTopLeft">0dp</item>
<item name="cornerSizeBottomLeft">0dp</item>
<item name="cornerSizeTopRight">12dp</item>
<item name="cornerSizeBottomRight">12dp</item>
</style>
<style name="ShapeAppearanceOverlay.Signal.Circle" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">50%</item>