Implement the majority of the Donor UI.

This commit is contained in:
Alex Hart
2021-10-12 15:55:54 -03:00
committed by GitHub
parent 6cbc2f684d
commit 43e4cba3d7
96 changed files with 3601 additions and 266 deletions

View File

@@ -13,6 +13,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.models.Button
import org.thoughtcrime.securesms.components.settings.models.Space
import org.thoughtcrime.securesms.components.settings.models.Text
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
@@ -31,6 +34,9 @@ class DSLSettingsAdapter : MappingAdapter() {
registerFactory(SectionHeaderPreference::class.java, LayoutFactory(::SectionHeaderPreferenceViewHolder, R.layout.dsl_section_header))
registerFactory(SwitchPreference::class.java, LayoutFactory(::SwitchPreferenceViewHolder, R.layout.dsl_switch_preference_item))
registerFactory(RadioPreference::class.java, LayoutFactory(::RadioPreferenceViewHolder, R.layout.dsl_radio_preference_item))
Text.register(this)
Space.register(this)
Button.register(this)
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EdgeEffect
import androidx.annotation.LayoutRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
abstract class DSLSettingsBottomSheetFragment(
@LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) },
override val peekHeightPercentage: Float = 1f
) : FixedRoundedCornerBottomSheetDialogFragment() {
private lateinit var recyclerView: RecyclerView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(layoutId, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
recyclerView = view.findViewById(R.id.recycler)
recyclerView.edgeEffectFactory = EdgeEffectFactory()
val adapter = DSLSettingsAdapter()
recyclerView.layoutManager = layoutManagerProducer(requireContext())
recyclerView.adapter = adapter
bindAdapter(adapter)
}
abstract fun bindAdapter(adapter: DSLSettingsAdapter)
private class EdgeEffectFactory : RecyclerView.EdgeEffectFactory() {
override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
return super.createEdgeEffect(view, direction).apply {
if (Build.VERSION.SDK_INT > 21) {
color =
requireNotNull(ContextCompat.getColor(view.context, R.color.settings_ripple_color))
}
}
}
}
}

View File

@@ -3,35 +3,71 @@ package org.thoughtcrime.securesms.components.settings
import android.content.Context
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.SpanUtil
sealed class DSLSettingsText {
protected abstract val modifiers: List<Modifier>
private data class FromResource(
@StringRes private val stringId: Int,
@ColorInt private val textColor: Int?
override val modifiers: List<Modifier>
) : DSLSettingsText() {
override fun resolve(context: Context): CharSequence {
val text = context.getString(stringId)
return if (textColor == null) {
text
} else {
SpanUtil.color(textColor, text)
}
override fun getCharSequence(context: Context): CharSequence {
return context.getString(stringId)
}
}
private data class FromCharSequence(private val charSequence: CharSequence) : DSLSettingsText() {
override fun resolve(context: Context): CharSequence = charSequence
private data class FromCharSequence(
private val charSequence: CharSequence,
override val modifiers: List<Modifier>
) : DSLSettingsText() {
override fun getCharSequence(context: Context): CharSequence = charSequence
}
abstract fun resolve(context: Context): CharSequence
protected abstract fun getCharSequence(context: Context): CharSequence
fun resolve(context: Context): CharSequence {
val text: CharSequence = getCharSequence(context)
return modifiers.fold(text) { t, m -> m.modify(context, t) }
}
companion object {
fun from(@StringRes stringId: Int, @ColorInt textColor: Int? = null): DSLSettingsText =
FromResource(stringId, textColor)
fun from(@StringRes stringId: Int, @ColorInt textColor: Int): DSLSettingsText =
FromResource(stringId, listOf(ColorModifier(textColor)))
fun from(charSequence: CharSequence): DSLSettingsText = FromCharSequence(charSequence)
fun from(@StringRes stringId: Int, vararg modifiers: Modifier): DSLSettingsText =
FromResource(stringId, modifiers.toList())
fun from(charSequence: CharSequence, vararg modifiers: Modifier): DSLSettingsText =
FromCharSequence(charSequence, modifiers.toList())
}
interface Modifier {
fun modify(context: Context, charSequence: CharSequence): CharSequence
}
class ColorModifier(@ColorInt private val textColor: Int) : Modifier {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.color(textColor, charSequence)
}
}
object CenterModifier : Modifier {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.center(charSequence)
}
}
object Title2BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Title2_Bold)
object Body1Modifier : TextAppearanceModifier(R.style.Signal_Text_Body)
object Body1BoldModifier : TextAppearanceModifier(R.style.TextAppearance_Signal_Body1_Bold)
open class TextAppearanceModifier(@StyleRes private val textAppearance: Int) : Modifier {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.textAppearance(context, textAppearance, charSequence)
}
}
}

View File

@@ -3,16 +3,23 @@ package org.thoughtcrime.securesms.components.settings.app
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.navigation.NavDirections
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
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.boost.BoostRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeViewModel
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.FeatureFlags
private const val START_LOCATION = "app.settings.start.location"
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
@@ -22,7 +29,23 @@ class AppSettingsActivity : DSLSettingsActivity() {
private var wasConfigurationUpdated = false
private val donationRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
private val subscribeViewModel: SubscribeViewModel by viewModels(
factoryProducer = {
SubscribeViewModel.Factory(SubscriptionsRepository(), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
}
)
private val boostViewModel: BoostViewModel by viewModels(
factoryProducer = {
BoostViewModel.Factory(BoostRepository(), donationRepository, FETCH_BOOST_TOKEN_REQUEST_CODE)
}
)
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
warmDonationViewModels()
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
intent?.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
}
@@ -79,8 +102,17 @@ class AppSettingsActivity : DSLSettingsActivity() {
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
subscribeViewModel.onActivityResult(requestCode, resultCode, data)
boostViewModel.onActivityResult(requestCode, resultCode, data)
}
companion object {
private const val FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE = 1000
private const val FETCH_BOOST_TOKEN_REQUEST_CODE = 2000
@JvmStatic
fun home(context: Context): Intent = getIntentForStartLocation(context, StartLocation.HOME)
@@ -109,6 +141,13 @@ class AppSettingsActivity : DSLSettingsActivity() {
}
}
private fun warmDonationViewModels() {
if (FeatureFlags.donorBadges()) {
subscribeViewModel
boostViewModel
}
}
private enum class StartLocation(val code: Int) {
HOME(0),
BACKUPS(1),

View File

@@ -4,6 +4,7 @@ import android.view.View
import android.widget.TextView
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
@@ -130,11 +131,33 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
)
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)
if (FeatureFlags.donorBadges()) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
onClick = {
findNavController()
.navigate(
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
.setSkipToSubscribe(true /* TODO [alex] -- Check state to see if user has active subscription or not. */)
)
}
)
// TODO [alex] -- clap
clickPref(
title = DSLSettingsText.from(R.string.preferences__signal_boost),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
onClick = {
findNavController().navigate(R.id.action_appSettingsFragment_to_boostsFragment)
}
)
} else {
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)
}
if (FeatureFlags.internalUser()) {
dividerPref()

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import org.thoughtcrime.securesms.badges.models.Badge
/**
* Events that can arise from use of the donations apis.
*/
sealed class DonationEvent {
class GooglePayUnavailableError(val throwable: Throwable) : DonationEvent()
object RequestTokenSuccess : DonationEvent()
object RequestTokenError : DonationEvent()
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
object SubscriptionCancelled : DonationEvent()
}

View File

@@ -0,0 +1,55 @@
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 org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher {
private val configuration = StripeApi.Configuration(publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY)
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(configuration))
private val stripeApi = StripeApi(configuration, this, ApplicationDependencies.getOkHttpClient())
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
googlePayApi.requestPayment(price, label, requestCode)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?,
expectedRequestCode: Int,
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
) {
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
}
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
return stripeApi.createPaymentIntent(price)
.flatMapCompletable { result ->
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(Exception("Amount is too small"))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(Exception("Amount is too large"))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(Exception("Currency is not supported"))
is StripeApi.CreatePaymentIntentResult.Success -> stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), result.paymentIntent)
}
}
}
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
return ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
.map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.subscription.Subscription
import java.util.Currency
/**
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
* in the currency indicated.
*/
class SubscriptionsRepository {
fun getActiveSubscription(currency: Currency): Maybe<Subscription> = Maybe.empty()
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = Single.fromCallable {
listOf()
}
}

View File

@@ -0,0 +1,187 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.text.Editable
import android.text.Spanned
import android.text.TextWatcher
import android.text.method.DigitsKeyListener
import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.widget.addTextChangedListener
import com.google.android.material.button.MaterialButton
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
import java.lang.Integer.min
import java.util.Currency
import java.util.Locale
import java.util.regex.Pattern
/**
* A Signal Boost is a one-time ephemeral show of support. Each boost level
* can unlock a corresponding badge for a time determined by the server.
*/
data class Boost(
val badge: Badge,
val price: FiatMoney
) {
/**
* A heading containing a 96dp rendering of the boost's badge.
*/
class HeadingModel(
val boostBadge: Badge
) : PreferenceModel<HeadingModel>() {
override fun areItemsTheSame(newItem: HeadingModel): Boolean = true
override fun areContentsTheSame(newItem: HeadingModel): Boolean {
return super.areContentsTheSame(newItem) && newItem.boostBadge == boostBadge
}
}
/**
* A widget that allows a user to select from six different amounts, or enter a custom amount.
*/
class SelectionModel(
val boosts: List<Boost>,
val selectedBoost: Boost?,
val currency: Currency,
override val isEnabled: Boolean,
val onBoostClick: (Boost) -> Unit,
val isCustomAmountFocused: Boolean,
val onCustomAmountChanged: (String) -> Unit,
val onCustomAmountFocusChanged: (Boolean) -> Unit,
) : PreferenceModel<SelectionModel>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: SelectionModel): Boolean = true
override fun areContentsTheSame(newItem: SelectionModel): Boolean {
return super.areContentsTheSame(newItem) &&
newItem.boosts == boosts &&
newItem.selectedBoost == selectedBoost &&
newItem.currency == currency &&
newItem.isCustomAmountFocused == isCustomAmountFocused
}
}
private class SelectionViewHolder(itemView: View) : MappingViewHolder<SelectionModel>(itemView) {
private val boost1: MaterialButton = itemView.findViewById(R.id.boost_1)
private val boost2: MaterialButton = itemView.findViewById(R.id.boost_2)
private val boost3: MaterialButton = itemView.findViewById(R.id.boost_3)
private val boost4: MaterialButton = itemView.findViewById(R.id.boost_4)
private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5)
private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6)
private val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom)
private var filter: MoneyFilter? = null
init {
custom.filters = emptyArray()
}
override fun bind(model: SelectionModel) {
itemView.isEnabled = model.isEnabled
model.boosts.zip(listOf(boost1, boost2, boost3, boost4, boost5, boost6)).forEach { (boost, button) ->
button.isSelected = boost == model.selectedBoost && !model.isCustomAmountFocused
button.text = FiatMoneyUtil.format(
context.resources,
boost.price,
FiatMoneyUtil.formatOptions()
)
button.setOnClickListener {
model.onBoostClick(boost)
custom.clearFocus()
}
}
if (filter == null || filter?.currency != model.currency) {
custom.removeTextChangedListener(filter)
filter = MoneyFilter(model.currency) {
model.onCustomAmountChanged(it)
}
custom.keyListener = filter
custom.addTextChangedListener(filter)
custom.setText("")
}
custom.setOnFocusChangeListener { _, hasFocus ->
model.onCustomAmountFocusChanged(hasFocus)
}
if (model.isCustomAmountFocused && !custom.hasFocus()) {
ViewUtil.focusAndShowKeyboard(custom)
}
}
}
private class HeadingViewHolder(itemView: View) : MappingViewHolder<HeadingModel>(itemView) {
private val badgeImageView: BadgeImageView = itemView as BadgeImageView
override fun bind(model: HeadingModel) {
badgeImageView.setBadge(model.boostBadge)
}
}
@VisibleForTesting
class MoneyFilter(val currency: Currency, private val onCustomAmountChanged: (String) -> Unit = {}) : DigitsKeyListener(), TextWatcher {
val separatorCount = min(1, currency.defaultFractionDigits)
val prefix: String = "${currency.getSymbol(Locale.getDefault())} "
val pattern: Pattern = "[0-9]*([.,]){0,$separatorCount}[0-9]{0,${currency.defaultFractionDigits}}".toPattern()
override fun filter(
source: CharSequence,
start: Int,
end: Int,
dest: Spanned,
dstart: Int,
dend: Int
): CharSequence? {
val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length)
val resultWithoutCurrencyPrefix = result.removePrefix(prefix)
val matcher = pattern.matcher(resultWithoutCurrencyPrefix)
if (!matcher.matches()) {
return dest.subSequence(dstart, dend)
}
return null
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
if (s.isNullOrEmpty()) return
val hasPrefix = s.startsWith(prefix)
if (hasPrefix && s.length == prefix.length) {
s.clear()
} else if (!hasPrefix) {
s.insert(0, prefix)
}
onCustomAmountChanged(s.removePrefix(prefix).toString())
}
}
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference))
adapter.registerFactory(HeadingModel::class.java, MappingAdapter.LayoutFactory({ HeadingViewHolder(it) }, R.layout.boost_preview_preference))
}
}
}

View File

@@ -0,0 +1,146 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.text.SpannableStringBuilder
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.signal.core.util.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.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.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
/**
* UX to allow users to donate ephemerally.
*/
class BoostFragment : DSLSettingsBottomSheetFragment() {
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
private val lifecycleDisposable = LifecycleDisposable()
private val sayThanks: CharSequence by lazy {
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
.append(" ")
.append(
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
// TODO [alex] -- Where's this go?
}
)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
CurrencySelection.register(adapter)
BadgePreview.register(adapter)
Boost.register(adapter)
GooglePayButton.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
when (event) {
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", event.throwable)
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", event.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> Unit
}
}
}
private fun getConfiguration(state: BoostState): DSLConfiguration {
return configure {
customPref(BadgePreview.SubscriptionModel(state.boostBadge))
sectionHeaderPref(
title = DSLSettingsText.from(
R.string.BoostFragment__give_signal_a_boost,
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
noPadTextPref(
title = DSLSettingsText.from(
sayThanks,
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(28f).toInt())
customPref(
CurrencySelection.Model(
currencySelection = state.currencySelection,
isEnabled = state.stage == BoostState.Stage.READY,
onClick = {
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment())
}
)
)
customPref(
Boost.SelectionModel(
boosts = state.boosts,
selectedBoost = state.selectedBoost,
currency = state.customAmount.currency,
isCustomAmountFocused = state.isCustomAmountFocused,
isEnabled = state.stage == BoostState.Stage.READY,
onBoostClick = {
viewModel.setSelectedBoost(it)
},
onCustomAmountChanged = {
viewModel.setCustomAmount(it)
},
onCustomAmountFocusChanged = {
viewModel.setCustomAmountFocused(it)
}
)
)
if (state.isGooglePayAvailable) {
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 = {
// TODO
}
)
}
}
private fun onGooglePayButtonClicked() {
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__signal_boost))
}
private fun onPaymentConfirmed(boostBadge: Badge) {
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true))
}
companion object {
private val TAG = Log.tag(BoostFragment::class.java)
}
}

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.net.Uri
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import java.math.BigDecimal
import java.util.Currency
class BoostRepository {
fun getBoosts(currency: Currency): Single<Pair<List<Boost>, Boost?>> {
val boosts = testBoosts(currency)
return Single.just(
Pair(
boosts,
boosts[2]
)
)
}
fun getBoostBadge(): Single<Badge> = Single.fromCallable {
// Get boost badge from server
// throw NotImplementedError()
testBadge
}
companion object {
private val testBadge = Badge(
id = "TEST",
category = Badge.Category.Testing,
name = "Test Badge",
description = "Test Badge",
imageUrl = Uri.EMPTY,
imageDensity = "xxxhdpi",
expirationTimestamp = 0L,
visible = false,
)
private fun testBoosts(currency: Currency) = listOf(
3L, 5L, 10L, 20L, 50L, 100L
).map {
Boost(testBadge, FiatMoney(BigDecimal.valueOf(it), currency))
}
}
}

View File

@@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import java.math.BigDecimal
import java.util.Currency
data class BoostState(
val boostBadge: Badge? = null,
val currencySelection: CurrencySelection = CurrencySelection("USD"),
val isGooglePayAvailable: Boolean = false,
val boosts: List<Boost> = listOf(),
val selectedBoost: Boost? = null,
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, Currency.getInstance(currencySelection.selectedCurrencyCode)),
val isCustomAmountFocused: Boolean = false,
val stage: Stage = Stage.INIT
) {
enum class Stage {
INIT,
READY,
PAYMENT_PIPELINE,
}
}

View File

@@ -0,0 +1,168 @@
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.core.Observable
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.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.models.CurrencySelection
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.livedata.Store
import java.math.BigDecimal
class BoostViewModel(
private val boostRepository: BoostRepository,
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModel() {
private val store = Store(BoostState())
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
private val disposables = CompositeDisposable()
val state: LiveData<BoostState> = store.stateLiveData
val events: Observable<DonationEvent> = eventPublisher
private var boostToPurchase: Boost? = null
override fun onCleared() {
disposables.clear()
}
init {
val currencyObservable = SignalStore.donationsValues().observableCurrency
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
val boostBadge = boostRepository.getBoostBadge()
disposables += Observable.combineLatest(boosts, boostBadge.toObservable()) { (boosts, defaultBoost), badge -> BoostInfo(boosts, defaultBoost, badge) }.subscribe { 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) BoostState.Stage.READY else it.stage
)
}
}
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
)
disposables += currencyObservable.subscribeBy { currency ->
store.update {
it.copy(
currencySelection = CurrencySelection(currency.currencyCode),
isCustomAmountFocused = false,
customAmount = FiatMoney(
BigDecimal.ZERO, currency
)
)
}
}
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
donationPaymentRepository.onActivityResult(
requestCode,
resultCode,
data,
this.fetchTokenRequestCode,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
val boost = boostToPurchase
boostToPurchase = null
if (boost != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
onComplete = {
// Now we need to do the whole query for a token, submit token rigamarole
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
}
)
}
}
override fun onError() {
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
eventPublisher.onNext(DonationEvent.RequestTokenError)
}
override fun onCancelled() {
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
}
}
)
}
fun requestTokenFromGooglePay(label: String) {
val snapshot = store.state
if (snapshot.selectedBoost == null) {
return
}
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
// TODO [alex] -- Do we want prevalidation? Stripe will catch us anyway.
// TODO [alex] -- Custom boost badge details... how do we determine this?
boostToPurchase = if (snapshot.isCustomAmountFocused) {
Boost(snapshot.selectedBoost.badge, snapshot.customAmount)
} else {
snapshot.selectedBoost
}
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedBoost.price, label, fetchTokenRequestCode)
}
fun setSelectedBoost(boost: Boost) {
store.update {
it.copy(
isCustomAmountFocused = false,
selectedBoost = boost
)
}
}
fun setCustomAmount(amount: String) {
val bigDecimalAmount = if (amount.isEmpty()) {
BigDecimal.ZERO
} else {
BigDecimal(amount)
}
store.update { it.copy(customAmount = FiatMoney(bigDecimalAmount, it.customAmount.currency)) }
}
fun setCustomAmountFocused(isFocused: Boolean) {
store.update { it.copy(isCustomAmountFocused = isFocused) }
}
private data class BoostInfo(val boosts: List<Boost>, val defaultBoost: Boost?, val boostBadge: Badge)
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))!!
}
}
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import androidx.fragment.app.viewModels
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.configure
import java.util.Locale
/**
* Simple fragment for selecting a currency for Donations
*/
class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
private val viewModel: SetCurrencyViewModel by viewModels()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: SetCurrencyState): DSLConfiguration {
return configure {
state.currencies.forEach { currency ->
radioPref(
title = DSLSettingsText.from(currency.getDisplayName(Locale.getDefault())),
summary = DSLSettingsText.from(currency.currencyCode),
isChecked = currency.currencyCode == state.selectedCurrencyCode,
onClick = {
viewModel.setSelectedCurrency(currency.currencyCode)
}
)
}
}
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import java.util.Currency
data class SetCurrencyState(
val selectedCurrencyCode: String = "",
val currencies: List<Currency> = listOf()
)

View File

@@ -0,0 +1,68 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Currency
import java.util.Locale
class SetCurrencyViewModel : ViewModel() {
private val store = Store(SetCurrencyState())
val state: LiveData<SetCurrencyState> = store.stateLiveData
init {
val defaultCurrency = SignalStore.donationsValues().getCurrency()
store.update { state ->
val platformCurrencies = Currency.getAvailableCurrencies()
val stripeCurrencies = platformCurrencies
.filter { StripeApi.Validation.supportedCurrencyCodes.contains(it.currencyCode) }
.sortedWith(CurrencyComparator(BuildConfig.DEFAULT_CURRENCIES.split(",")))
state.copy(
selectedCurrencyCode = defaultCurrency.currencyCode,
currencies = stripeCurrencies
)
}
}
fun setSelectedCurrency(selectedCurrencyCode: String) {
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
SignalStore.donationsValues().setCurrency(Currency.getInstance(selectedCurrencyCode))
}
@VisibleForTesting
class CurrencyComparator(private val defaults: List<String>) : Comparator<Currency> {
companion object {
private const val USD = "USD"
}
override fun compare(o1: Currency, o2: Currency): Int {
val isO1Default = o1.currencyCode in defaults
val isO2Default = o2.currencyCode in defaults
return if (o1.currencyCode == o2.currencyCode) {
0
} else if (o1.currencyCode == USD) {
-1
} else if (o2.currencyCode == USD) {
1
} else if (isO1Default && isO2Default) {
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
} else if (isO1Default) {
-1
} else if (isO2Default) {
1
} else {
o1.getDisplayName(Locale.getDefault()).compareTo(o2.getDisplayName(Locale.getDefault()))
}
}
}
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import android.view.View
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import java.util.Locale
/**
* DSL renderable item that displays active subscription information on the user's
* manage donations page.
*/
object ActiveSubscriptionPreference {
class Model(
val subscription: Subscription,
val onAddBoostClick: () -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return subscription.id == newItem.subscription.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && subscription == newItem.subscription
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
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 boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
override fun bind(model: Model) {
badge.setBadge(model.subscription.badge)
title.text = model.subscription.title
price.text = context.getString(
R.string.MySupportPreference__s_per_month,
FiatMoneyUtil.format(
context.resources,
model.subscription.price,
FiatMoneyUtil.formatOptions()
)
)
expiry.text = context.getString(
R.string.MySupportPreference__renews_s,
DateUtils.formatDate(
Locale.getDefault(),
model.subscription.renewalTimestamp
)
)
boost.setOnClickListener {
model.onAddBoostClick()
}
}
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.my_support_preference))
}
}

View File

@@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
enum class ManageDonationsEvent {
NOT_SUBSCRIBED,
ERROR_GETTING_SUBSCRIPTION
}

View File

@@ -0,0 +1,130 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* 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() {
private val viewModel: ManageDonationsViewModel by viewModels(
factoryProducer = {
ManageDonationsViewModel.Factory(SubscriptionsRepository())
}
)
private val lifecycleDisposable = LifecycleDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
if (args.skipToSubscribe) {
findNavController().navigate(
ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment(),
NavOptions.Builder().setPopUpTo(R.id.manageDonationsFragment, true).build()
)
}
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
val args = ManageDonationsFragmentArgs.fromBundle(requireArguments())
if (args.skipToSubscribe) {
return
}
ActiveSubscriptionPreference.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: ManageDonationsEvent ->
when (event) {
ManageDonationsEvent.NOT_SUBSCRIBED -> handleUserIsNotSubscribed()
ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION -> handleErrorGettingSubscription()
}
}
}
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
return configure {
sectionHeaderPref(
title = DSLSettingsText.from(
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
noPadTextPref(
title = DSLSettingsText.from(
R.string.ManageDonationsFragment__my_support,
DSLSettingsText.Title2BoldModifier
)
)
if (state.activeSubscription != null) {
customPref(
ActiveSubscriptionPreference.Model(
subscription = state.activeSubscription,
onAddBoostClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
}
)
)
dividerPref()
}
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
onClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}
)
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
onClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscriptionBadgeManageFragment())
}
)
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
linkId = R.string.donate_url
)
}
}
private fun handleUserIsNotSubscribed() {
findNavController().popBackStack()
}
private fun handleErrorGettingSubscription() {
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.subscription.Subscription
data class ManageDonationsState(
val featuredBadge: Badge? = null,
val activeSubscription: Subscription? = null
)

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Observable
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.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
class ManageDonationsViewModel(
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModel() {
private val store = Store(ManageDonationsState())
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
private val disposables = CompositeDisposable()
val state: LiveData<ManageDonationsState> = store.stateLiveData
val events: Observable<ManageDonationsEvent> = eventPublisher
init {
store.update(Recipient.self().live().liveDataResolved) { self, state ->
state.copy(featuredBadge = self.featuredBadge)
}
}
override fun onCleared() {
disposables.clear()
}
fun refresh() {
disposables.clear()
disposables += subscriptionsRepository.getActiveSubscription(SignalStore.donationsValues().getCurrency()).subscribeBy(
onSuccess = { subscription -> store.update { it.copy(activeSubscription = subscription) } },
onComplete = {
store.update { it.copy(activeSubscription = null) }
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
},
onError = {
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
}
)
}
class Factory(
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
}
}
}

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
data class CurrencySelection(
val selectedCurrencyCode: String,
) {
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_currency_selection))
}
}
class Model(
val currencySelection: CurrencySelection,
override val isEnabled: Boolean,
val onClick: () -> Unit
) : PreferenceModel<Model>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
newItem.currencySelection.selectedCurrencyCode == currencySelection.selectedCurrencyCode
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val spinner: TextView = itemView.findViewById(R.id.subscription_currency_selection_spinner)
override fun bind(model: Model) {
spinner.text = model.currencySelection.selectedCurrencyCode
itemView.setOnClickListener { model.onClick() }
}
}
}

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object GooglePayButton {
class Model(val onClick: () -> Unit, override val isEnabled: Boolean) : PreferenceModel<Model>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val googlePayButton: View = findViewById(R.id.googlepay_button)
override fun bind(model: Model) {
googlePayButton.isEnabled = model.isEnabled
googlePayButton.setOnClickListener {
googlePayButton.isEnabled = false
model.onClick()
}
}
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.google_pay_button_pref))
}
}

View File

@@ -0,0 +1,175 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import android.text.SpannableStringBuilder
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 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.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
/**
* UX for creating and changing a subscription
*/
class SubscribeFragment : DSLSettingsFragment() {
private val viewModel: SubscribeViewModel by viewModels(ownerProducer = { requireActivity() })
private val lifecycleDisposable = LifecycleDisposable()
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__support_technology_that_is_built_for_you))
.append(" ")
.append(
SpanUtil.learnMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeLearnMoreBottomSheetDialog())
}
)
}
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgePreview.register(adapter)
CurrencySelection.register(adapter)
Subscription.register(adapter)
GooglePayButton.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe {
when (it) {
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", it.throwable)
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", it.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
}
}
}
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
return configure {
customPref(BadgePreview.SubscriptionModel(state.previewBadge))
sectionHeaderPref(
title = DSLSettingsText.from(
R.string.SubscribeFragment__signal_is_powered_by_people_like_you,
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
noPadTextPref(
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
customPref(
CurrencySelection.Model(
currencySelection = state.currencySelection,
isEnabled = state.stage == SubscribeState.Stage.READY,
onClick = {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment())
}
)
)
state.subscriptions.forEach {
customPref(
Subscription.Model(
subscription = it,
isSelected = state.selectedSubscription == it,
isEnabled = state.stage == SubscribeState.Stage.READY,
isActive = state.activeSubscription == it,
onClick = { viewModel.setSelectedSubscription(it) }
)
)
}
if (state.activeSubscription != null) {
primaryButton(
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
onClick = {
// TODO [alex] -- Dunno what the update process requires.
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
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()
}
)
} else {
if (state.isGooglePayAvailable) {
space(DimensionUnit.DP.toPixels(16f).toInt())
customPref(
GooglePayButton.Model(
onClick = this@SubscribeFragment::onGooglePayButtonClicked,
isEnabled = state.stage == SubscribeState.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 = {
// TODO
}
)
}
}
}
private fun onGooglePayButtonClicked() {
viewModel.requestTokenFromGooglePay()
}
private fun onPaymentConfirmed(badge: Badge) {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false))
}
private fun onSubscriptionCancelled() {
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
}
companion object {
private val TAG = Log.tag(SubscribeFragment::class.java)
}
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
class SubscribeLearnMoreBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.subscribe_learn_more_bottom_sheet_dialog_fragment, container, false)
}
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.subscription.Subscription
data class SubscribeState(
val previewBadge: Badge? = null,
val currencySelection: CurrencySelection = CurrencySelection("USD"),
val subscriptions: List<Subscription> = listOf(),
val selectedSubscription: Subscription? = null,
val activeSubscription: Subscription? = null,
val isGooglePayAvailable: Boolean = false,
val stage: Stage = Stage.INIT
) {
enum class Stage {
INIT,
READY,
PAYMENT_PIPELINE,
CANCELLING
}
}

View File

@@ -0,0 +1,136 @@
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.core.Observable
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.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.models.CurrencySelection
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
class SubscribeViewModel(
private val subscriptionsRepository: SubscriptionsRepository,
private val donationPaymentRepository: DonationPaymentRepository,
private val fetchTokenRequestCode: Int
) : ViewModel() {
private val store = Store(SubscribeState())
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
private val disposables = CompositeDisposable()
val state: LiveData<SubscribeState> = store.stateLiveData
val events: Observable<DonationEvent> = eventPublisher
private var subscriptionToPurchase: Subscription? = null
override fun onCleared() {
disposables.clear()
}
fun refresh() {
disposables.clear()
val currency = SignalStore.donationsValues().getCurrency()
val allSubscriptions = subscriptionsRepository.getSubscriptions(currency)
val activeSubscription = subscriptionsRepository.getActiveSubscription(currency)
.map { Optional.of(it) }
.defaultIfEmpty(Optional.absent())
disposables += allSubscriptions.zipWith(activeSubscription, ::Pair).subscribe { (subs, active) ->
store.update {
it.copy(
subscriptions = subs,
selectedSubscription = it.selectedSubscription ?: active.orNull() ?: subs.firstOrNull(),
activeSubscription = active.orNull(),
stage = SubscribeState.Stage.READY
)
}
}
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
)
store.update { it.copy(currencySelection = CurrencySelection(SignalStore.donationsValues().getCurrency().currencyCode)) }
}
fun cancel() {
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
// TODO [alex] -- cancel api call
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
donationPaymentRepository.onActivityResult(
requestCode, resultCode, data, this.fetchTokenRequestCode,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
val subscription = subscriptionToPurchase
subscriptionToPurchase = null
if (subscription != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
donationPaymentRepository.continuePayment(subscription.price, paymentData).subscribeBy(
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
onComplete = {
// Now we need to do the whole query for a token, submit token rigamarole
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
}
)
}
}
override fun onError() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
eventPublisher.onNext(DonationEvent.RequestTokenError)
}
override fun onCancelled() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
}
}
)
}
fun requestTokenFromGooglePay() {
val snapshot = store.state
if (snapshot.selectedSubscription == null) {
return
}
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
subscriptionToPurchase = snapshot.selectedSubscription
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.price, snapshot.selectedSubscription.title, 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))!!
}
}
}

View File

@@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
private lateinit var displayOnProfileSwitch: SwitchMaterial
private lateinit var heading: TextView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.thanks_for_your_support_bottom_sheet_dialog_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
displayOnProfileSwitch = view.findViewById(R.id.thanks_bottom_sheet_display_on_profile)
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
badgeView.setBadge(args.badge)
badgeName.text = args.badge.name
displayOnProfileSwitch.isChecked = true
if (args.isBoost) {
presentBoostCopy()
} else {
presentSubscriptionCopy()
}
done.setOnClickListener { dismissAllowingStateLoss() }
}
override fun onDismiss(dialog: DialogInterface) {
val isDisplayOnProfile = displayOnProfileSwitch.isChecked
// TODO [alex] -- Not sure what state we're in with regards to submitting the token.
}
private fun presentBoostCopy() {
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_the_boost)
}
private fun presentSubscriptionCopy() {
heading.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support)
}
}

View File

@@ -660,7 +660,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
clickPref(
title = DSLSettingsText.from(title, titleTint),
title = if (titleTint != null) DSLSettingsText.from(title, titleTint) else DSLSettingsText.from(title),
icon = DSLSettingsIcon.from(blockUnblockIcon),
onClick = {
if (state.recipient.isBlocked) {

View File

@@ -1,7 +1,11 @@
package org.thoughtcrime.securesms.components.settings
import androidx.annotation.CallSuper
import androidx.annotation.Px
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.components.settings.models.Button
import org.thoughtcrime.securesms.components.settings.models.Space
import org.thoughtcrime.securesms.components.settings.models.Text
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingModelList
@@ -121,6 +125,35 @@ class DSLConfiguration {
children.add(preference)
}
fun noPadTextPref(title: DSLSettingsText) {
val preference = Text(title)
children.add(Text.Model(preference))
}
fun space(@Px pixels: Int) {
val preference = Space(pixels)
children.add(Space.Model(preference))
}
fun primaryButton(
text: DSLSettingsText,
isEnabled: Boolean = true,
onClick: () -> Unit
) {
val preference = Button.Model.Primary(text, null, isEnabled, onClick)
children.add(preference)
}
fun secondaryButtonNoOutline(
text: DSLSettingsText,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
onClick: () -> Unit
) {
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, onClick)
children.add(preference)
}
fun textPref(
title: DSLSettingsText? = null,
summary: DSLSettingsText? = null

View File

@@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object Button {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model.Primary::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.Primary> }, R.layout.dsl_button_primary))
mappingAdapter.registerFactory(Model.SecondaryNoOutline::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder<Model.SecondaryNoOutline> }, R.layout.dsl_button_secondary))
}
sealed class Model<T : Model<T>>(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
val onClick: () -> Unit
) : PreferenceModel<T>(
title = title,
icon = icon,
isEnabled = isEnabled
) {
class Primary(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
onClick: () -> Unit
) : Model<Primary>(title, icon, isEnabled, onClick)
class SecondaryNoOutline(
title: DSLSettingsText?,
icon: DSLSettingsIcon?,
isEnabled: Boolean,
onClick: () -> Unit
) : Model<SecondaryNoOutline>(title, icon, isEnabled, onClick)
}
class ViewHolder(itemView: View) : MappingViewHolder<Model<*>>(itemView) {
private val button: MaterialButton = itemView as MaterialButton
override fun bind(model: Model<*>) {
button.text = model.title?.resolve(context)
button.setOnClickListener {
model.onClick()
}
button.icon = model.icon?.resolve(context)
button.isEnabled = model.isEnabled
}
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import androidx.annotation.Px
import androidx.core.view.updateLayoutParams
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* Adds extra space between elements in a DSL fragment
*/
data class Space(
@Px val pixels: Int
) {
companion object {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_space_preference))
}
}
class Model(val space: Space) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && newItem.space == space
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
override fun bind(model: Model) {
itemView.updateLayoutParams {
height = model.space.pixels
}
}
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings.models
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* A Text without any padding, allowing for exact padding to be handed in at runtime.
*/
data class Text(
val text: DSLSettingsText,
) {
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.dsl_text_preference))
}
}
class Model(val paddableText: Text) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && newItem.paddableText == paddableText
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val text: TextView = itemView.findViewById(R.id.title)
override fun bind(model: Model) {
text.text = model.paddableText.text.resolve(context)
val clickableSpans = (text.text as? Spanned)?.getSpans(0, text.text.length, ClickableSpan::class.java)
if (clickableSpans?.isEmpty() == false) {
text.movementMethod = LinkMovementMethod.getInstance()
} else {
text.movementMethod = null
}
}
}
}