Implement the majority of the Donor UI.

fork-5.53.8
Alex Hart 2021-10-12 15:55:54 -03:00 zatwierdzone przez GitHub
rodzic 6cbc2f684d
commit 43e4cba3d7
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
96 zmienionych plików z 3601 dodań i 266 usunięć

Wyświetl plik

@ -179,6 +179,7 @@ android {
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"unset\""
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
@ -456,6 +457,7 @@ dependencies {
implementation project(':video')
implementation project(':device-transfer')
implementation project(':image-editor')
implementation project(':donations')
implementation libs.signal.zkgroup.android
implementation libs.signal.client.android
@ -546,6 +548,8 @@ dependencies {
androidTestImplementation testLibs.androidx.test.ext.junit
androidTestImplementation testLibs.espresso.core
testImplementation testLibs.espresso.core
implementation libs.kotlin.stdlib.jdk8
implementation libs.kotlin.reflect
implementation libs.jackson.module.kotlin

Wyświetl plik

@ -101,6 +101,10 @@
android:theme="@style/TextSecure.LightTheme"
android:largeHeap="true">
<meta-data
android:name="com.google.android.gms.wallet.api.enabled"
android:value="true" />
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>

Wyświetl plik

@ -44,7 +44,6 @@ class BadgeImageView @JvmOverloads constructor(
val lifecycle = ViewUtil.getActivityLifecycle(this)
if (lifecycle?.currentState == Lifecycle.State.DESTROYED) {
Log.w(TAG, "Ignoring setBadge call for destroyed activity.")
return
}

Wyświetl plik

@ -1,49 +1,16 @@
package org.thoughtcrime.securesms.badges
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.core.graphics.withScale
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.lottie.SimpleColorFilter
import com.google.android.flexbox.AlignItems
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.flexbox.JustifyContent
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgeAnimator
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.util.customizeOnDraw
object Badges {
fun Drawable.selectable(
@Px outlineWidth: Float,
@ColorInt outlineColor: Int,
animator: BadgeAnimator
): Drawable {
val outline = mutate().constantState?.newDrawable()?.mutate()
outline?.colorFilter = SimpleColorFilter(outlineColor)
return customizeOnDraw { wrapped, canvas ->
outline?.bounds = wrapped.bounds
outline?.draw(canvas)
val scale = 1 - ((outlineWidth * 2) / wrapped.bounds.width())
val interpolatedScale = scale + (1f - scale) * animator.getFraction()
canvas.withScale(x = interpolatedScale, y = interpolatedScale, wrapped.bounds.width() / 2f, wrapped.bounds.height() / 2f) {
wrapped.draw(canvas)
}
if (animator.shouldInvalidate()) {
invalidateSelf()
}
}
}
fun DSLConfiguration.displayBadges(context: Context, badges: List<Badge>, selectedBadge: Badge? = null) {
badges
.map { Badge.Model(it, it == selectedBadge) }

Wyświetl plik

@ -1,21 +1,16 @@
package org.thoughtcrime.securesms.badges.models
import android.graphics.drawable.Drawable
import android.animation.ObjectAnimator
import android.net.Uri
import android.os.Parcelable
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.bumptech.glide.load.Key
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomViewTarget
import com.bumptech.glide.request.transition.Transition
import kotlinx.parcelize.Parcelize
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges.selectable
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.mms.GlideApp
@ -41,6 +36,8 @@ data class Badge(
val visible: Boolean,
) : Parcelable, Key {
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis()
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(id.toByteArray(Key.CHARSET))
messageDigest.update(imageUrl.toString().toByteArray(Key.CHARSET))
@ -94,35 +91,47 @@ data class Badge(
class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder<Model>(itemView) {
private val check: ImageView = itemView.findViewById(R.id.checkmark)
private val badge: ImageView = itemView.findViewById(R.id.badge)
private val name: TextView = itemView.findViewById(R.id.name)
private val target = Target(badge)
private var checkAnimator: ObjectAnimator? = null
init {
check.isSelected = true
}
override fun bind(model: Model) {
itemView.setOnClickListener {
onBadgeClicked(model.badge, model.isSelected)
}
checkAnimator?.cancel()
if (payload.isNotEmpty()) {
if (model.isSelected) {
target.animateToStart()
checkAnimator = if (model.isSelected) {
ObjectAnimator.ofFloat(check, "alpha", 1f)
} else {
target.animateToEnd()
ObjectAnimator.ofFloat(check, "alpha", 0f)
}
checkAnimator?.start()
return
}
badge.alpha = if (model.badge.isExpired()) 0.5f else 1f
GlideApp.with(badge)
.load(model.badge)
.downsample(DownsampleStrategy.NONE)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.XLARGE, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)))
.into(target)
.transform(
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.XLARGE, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
)
.into(badge)
if (model.isSelected) {
target.setAnimationToStart()
check.alpha = 1f
} else {
target.setAnimationToEnd()
check.alpha = 0f
}
name.text = model.badge.name
@ -145,49 +154,6 @@ data class Badge(
}
}
private class Target(view: ImageView) : CustomViewTarget<ImageView, Drawable>(view) {
private val animator: BadgeAnimator = BadgeAnimator()
override fun onLoadFailed(errorDrawable: Drawable?) {
view.setImageDrawable(errorDrawable)
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
val drawable = resource.selectable(
DimensionUnit.DP.toPixels(2.5f),
ContextCompat.getColor(view.context, R.color.signal_inverse_primary),
animator
)
view.setImageDrawable(drawable)
}
override fun onResourceCleared(placeholder: Drawable?) {
view.setImageDrawable(placeholder)
}
fun setAnimationToStart() {
animator.setState(BadgeAnimator.State.START)
view.drawable?.invalidateSelf()
}
fun setAnimationToEnd() {
animator.setState(BadgeAnimator.State.END)
view.drawable?.invalidateSelf()
}
fun animateToStart() {
animator.setState(BadgeAnimator.State.REVERSE)
view.drawable?.invalidateSelf()
}
fun animateToEnd() {
animator.setState(BadgeAnimator.State.FORWARD)
view.drawable?.invalidateSelf()
}
}
companion object {
private val SELECTION_CHANGED = Any()

Wyświetl plik

@ -1,97 +0,0 @@
package org.thoughtcrime.securesms.badges.models
import org.thoughtcrime.securesms.util.Util
class BadgeAnimator {
val duration = 250L
var state: State = State.START
private set
private var startTime: Long = 0L
fun getFraction(): Float {
return when (state) {
State.START -> 0f
State.END -> 1f
State.FORWARD -> Util.clamp((System.currentTimeMillis() - startTime) / duration.toFloat(), 0f, 1f)
State.REVERSE -> 1f - Util.clamp((System.currentTimeMillis() - startTime) / duration.toFloat(), 0f, 1f)
}
}
fun setState(newState: State) {
shouldInvalidate()
if (state == newState) {
return
}
if (newState == State.END || newState == State.START) {
state = newState
startTime = 0L
return
}
if (state == State.START && newState == State.REVERSE) {
return
}
if (state == State.END && newState == State.FORWARD) {
return
}
if (state == State.START && newState == State.FORWARD) {
state = State.FORWARD
startTime = System.currentTimeMillis()
return
}
if (state == State.END && newState == State.REVERSE) {
state = State.REVERSE
startTime = System.currentTimeMillis()
return
}
if (state == State.FORWARD && newState == State.REVERSE) {
val elapsed = System.currentTimeMillis() - startTime
val delta = duration - elapsed
startTime -= delta
state = State.REVERSE
return
}
if (state == State.REVERSE && newState == State.FORWARD) {
val elapsed = System.currentTimeMillis() - startTime
val delta = duration - elapsed
startTime -= delta
state = State.FORWARD
return
}
}
fun shouldInvalidate(): Boolean {
if (state == State.START || state == State.END) {
return false
}
if (state == State.FORWARD && getFraction() == 1f) {
state = State.END
return false
}
if (state == State.REVERSE && getFraction() == 0f) {
state = State.START
return false
}
return true
}
enum class State {
START,
FORWARD,
REVERSE,
END
}
}

Wyświetl plik

@ -9,13 +9,18 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object FeaturedBadgePreview {
object BadgePreview {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
mappingAdapter.registerFactory(SubscriptionModel::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
}
data class Model(val badge: Badge?) : PreferenceModel<Model>() {
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
abstract val badge: Badge?
}
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.badge?.id == badge?.id
}
@ -25,12 +30,22 @@ object FeaturedBadgePreview {
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
return newItem.badge?.id == badge?.id
}
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
}
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
override fun bind(model: Model) {
override fun bind(model: T) {
avatar.setRecipient(Recipient.self())
avatar.disableQuickContact()
badge.setBadge(model.badge)

Wyświetl plik

@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.badges.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object ExpiredBadge {
class Model(val badge: Badge) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.badge.id == badge.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && newItem.badge == badge
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badge: BadgeImageView = itemView.findViewById(R.id.expired_badge)
override fun bind(model: Model) {
badge.setBadge(model.badge)
}
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
}
}

Wyświetl plik

@ -0,0 +1,69 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
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
/**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
*/
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
peekHeightPercentage = 1f
) {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredBadge.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
val badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge
return configure {
customPref(ExpiredBadge.Model(badge))
sectionHeaderPref(R.string.ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired)
space(DimensionUnit.DP.toPixels(4f).toInt())
noPadTextPref(
DSLSettingsText.from(
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_s_badge_has_expired, badge.name),
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
DSLSettingsText.from(
R.string.ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting,
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(92f).toInt())
primaryButton(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_subscriber),
onClick = {
dismiss()
findNavController().navigate(R.id.action_directly_to_subscribe)
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
onClick = {
dismiss()
}
)
}
}
}

Wyświetl plik

@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.Badges.displayBadges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.FeaturedBadgePreview
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
@ -58,7 +58,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
}
val previewView: View = requireView().findViewById(R.id.preview)
val previewViewHolder = FeaturedBadgePreview.ViewHolder(previewView)
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.Model>(previewView)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
@ -71,7 +71,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
viewModel.state.observe(viewLifecycleOwner) { state ->
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
previewViewHolder.bind(FeaturedBadgePreview.Model(state.selectedBadge))
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}

Wyświetl plik

@ -29,10 +29,11 @@ class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : Vi
init {
store.update(Recipient.live(Recipient.self().id).liveDataResolved) { recipient, state ->
val unexpiredBadges = recipient.badges.filterNot { it.isExpired() }
state.copy(
stage = if (state.stage == SelectFeaturedBadgeState.Stage.INIT) SelectFeaturedBadgeState.Stage.READY else state.stage,
selectedBadge = recipient.badges.firstOrNull(),
allUnlockedBadges = recipient.badges
selectedBadge = unexpiredBadges.firstOrNull(),
allUnlockedBadges = unexpiredBadges
)
}
}

Wyświetl plik

@ -30,7 +30,11 @@ class BadgesOverviewFragment : DSLSettingsFragment(
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, _ ->
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
if (badge.isExpired()) {
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
} else {
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
}
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
@ -57,6 +61,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
switchPref(
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
isChecked = state.displayBadgesOnProfile,
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
onClick = {
viewModel.setDisplayBadgesOnProfile(!state.displayBadgesOnProfile)
}
@ -65,7 +70,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
clickPref(
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__featured_badge),
summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) },
isEnabled = state.stage == BadgesOverviewState.Stage.READY,
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges,
onClick = {
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
}

Wyświetl plik

@ -6,8 +6,11 @@ data class BadgesOverviewState(
val stage: Stage = Stage.INIT,
val allUnlockedBadges: List<Badge> = listOf(),
val featuredBadge: Badge? = null,
val displayBadgesOnProfile: Boolean = false
val displayBadgesOnProfile: Boolean = false,
) {
val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
enum class Stage {
INIT,
READY,

Wyświetl plik

@ -29,7 +29,8 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
state.copy(
stage = if (state.stage == BadgesOverviewState.Stage.INIT) BadgesOverviewState.Stage.READY else state.stage,
allUnlockedBadges = recipient.badges,
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true
displayBadgesOnProfile = recipient.badges.firstOrNull()?.visible == true,
featuredBadge = recipient.featuredBadge
)
}
}

Wyświetl plik

@ -68,6 +68,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
dismissAllowingStateLoss()
}
tabs.visible = state.allBadgesVisibleOnProfile.size > 1
adapter.submitList(
state.allBadgesVisibleOnProfile.map {
LargeBadge.Model(LargeBadge(it), state.recipient.getShortDisplayName(requireContext()))

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.keyvalue
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.donations.StripeApi
import java.util.Currency
import java.util.Locale
internal class DonationsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
companion object {
private const val KEY_CURRENCY_CODE = "donation.currency.code"
}
override fun onFirstEverAppLaunch() = Unit
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(KEY_CURRENCY_CODE)
private val currencyPublisher: Subject<Currency> = BehaviorSubject.createDefault(getCurrency())
val observableCurrency: Observable<Currency> = currencyPublisher
fun getCurrency(): Currency {
val currencyCode = getString(KEY_CURRENCY_CODE, null)
val currency = if (currencyCode == null) {
Currency.getInstance(Locale.getDefault())
} else {
Currency.getInstance(currencyCode)
}
return if (StripeApi.Validation.supportedCurrencyCodes.contains(currency.currencyCode)) {
currency
} else {
Currency.getInstance("USD")
}
}
fun setCurrency(currency: Currency) {
putString(KEY_CURRENCY_CODE, currency.currencyCode)
currencyPublisher.onNext(currency)
}
}

Wyświetl plik

@ -34,6 +34,7 @@ public final class SignalStore {
private final OnboardingValues onboardingValues;
private final WallpaperValues wallpaperValues;
private final PaymentsValues paymentsValues;
private final DonationsValues donationsValues;
private final ProxyValues proxyValues;
private final RateLimitValues rateLimitValues;
private final ChatColorsValues chatColorsValues;
@ -57,6 +58,7 @@ public final class SignalStore {
this.onboardingValues = new OnboardingValues(store);
this.wallpaperValues = new WallpaperValues(store);
this.paymentsValues = new PaymentsValues(store);
this.donationsValues = new DonationsValues(store);
this.proxyValues = new ProxyValues(store);
this.rateLimitValues = new RateLimitValues(store);
this.chatColorsValues = new ChatColorsValues(store);
@ -80,6 +82,7 @@ public final class SignalStore {
onboarding().onFirstEverAppLaunch();
wallpaper().onFirstEverAppLaunch();
paymentsValues().onFirstEverAppLaunch();
donationsValues().onFirstEverAppLaunch();
proxy().onFirstEverAppLaunch();
rateLimit().onFirstEverAppLaunch();
chatColorsValues().onFirstEverAppLaunch();
@ -104,6 +107,7 @@ public final class SignalStore {
keys.addAll(onboarding().getKeysToIncludeInBackup());
keys.addAll(wallpaper().getKeysToIncludeInBackup());
keys.addAll(paymentsValues().getKeysToIncludeInBackup());
keys.addAll(donationsValues().getKeysToIncludeInBackup());
keys.addAll(proxy().getKeysToIncludeInBackup());
keys.addAll(rateLimit().getKeysToIncludeInBackup());
keys.addAll(chatColorsValues().getKeysToIncludeInBackup());
@ -184,6 +188,10 @@ public final class SignalStore {
return INSTANCE.paymentsValues;
}
public static @NonNull DonationsValues donationsValues() {
return INSTANCE.donationsValues;
}
public static @NonNull ProxyValues proxy() {
return INSTANCE.proxyValues;
}

Wyświetl plik

@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.subscription
import android.view.View
import android.widget.ImageView
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.components.settings.PreferenceModel
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* Represents a Subscription that a user can start.
*/
data class Subscription(
val id: String,
val title: String,
val badge: Badge,
val price: FiatMoney,
) {
val renewalTimestamp = badge.expirationTimestamp
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_preference))
}
}
class Model(
val subscription: Subscription,
val isSelected: Boolean,
val isActive: Boolean,
override val isEnabled: Boolean,
val onClick: () -> Unit
) : PreferenceModel<Model>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: Model): Boolean {
return subscription.id == newItem.subscription.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
newItem.subscription == subscription &&
newItem.isSelected == isSelected &&
newItem.isActive == isActive
}
}
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)
override fun bind(model: Model) {
itemView.isEnabled = model.isEnabled
itemView.setOnClickListener { model.onClick() }
itemView.isSelected = model.isSelected
badge.setBadge(model.subscription.badge)
title.text = model.subscription.title
tagline.text = model.subscription.id
val formattedPrice = FiatMoneyUtil.format(
context.resources,
model.subscription.price,
FiatMoneyUtil.formatOptions()
)
if (model.isActive) {
price.text = context.getString(
R.string.Subscription__s_per_month_dot_renews_s,
formattedPrice,
DateUtils.formatDate(Locale.getDefault(), model.subscription.renewalTimestamp)
)
} else {
price.text = context.getString(
R.string.Subscription__s_per_month,
formattedPrice
)
}
check.visible = model.isActive
}
}
}

Wyświetl plik

@ -10,7 +10,7 @@ import androidx.lifecycle.LifecycleOwner;
import java.util.LinkedList;
import java.util.List;
public abstract class MappingViewHolder<Model extends MappingModel<Model>> extends LifecycleViewHolder implements LifecycleOwner {
public abstract class MappingViewHolder<Model> extends LifecycleViewHolder implements LifecycleOwner {
protected final Context context;
protected final List<Object> payload;
@ -36,7 +36,7 @@ public abstract class MappingViewHolder<Model extends MappingModel<Model>> exten
this.payload.addAll(payload);
}
public static final class SimpleViewHolder<Model extends MappingModel<Model>> extends MappingViewHolder<Model> {
public static final class SimpleViewHolder<Model> extends MappingViewHolder<Model> {
public SimpleViewHolder(@NonNull View itemView) {
super(itemView);
}

Wyświetl plik

@ -5,6 +5,7 @@ import android.content.res.Resources;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
@ -12,6 +13,7 @@ import android.text.Spanned;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.AlignmentSpan;
import android.text.style.BulletSpan;
import android.text.style.CharacterStyle;
import android.text.style.ClickableSpan;
@ -20,12 +22,14 @@ import android.text.style.ForegroundColorSpan;
import android.text.style.ImageSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import android.text.style.TextAppearanceSpan;
import android.text.style.TypefaceSpan;
import android.view.View;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.annotation.StyleRes;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
@ -40,6 +44,18 @@ public final class SpanUtil {
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL);
public static CharSequence center(@NonNull CharSequence sequence) {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
}
public static CharSequence textAppearance(@NonNull Context context, @StyleRes int textAppearance, @NonNull CharSequence sequence) {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new TextAppearanceSpan(context, textAppearance), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
}
public static CharSequence italic(CharSequence sequence) {
return italic(sequence, sequence.length());
}

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_accent_primary_transparent_15" android:state_selected="true" />
<item android:color="@color/transparent" />
</selector>

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_accent_primary" android:state_selected="true" />
<item android:color="@color/signal_button_secondary_stroke" />
</selector>

Wyświetl plik

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:endColor="@color/signal_background_dialog"
android:startColor="@color/transparent"
android:type="linear" />
</shape>

Wyświetl plik

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="139.59"
android:endColor="#4C3EAE"
android:startColor="#439DF1"
android:type="linear" />
</shape>

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">
<stroke android:color="@color/signal_divider_minor" android:width="1dp" />
<corners android:radius="38dp" />
</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_accent_primary_transparent_15" />
<stroke android:color="@color/signal_accent_primary" android:width="1dp" />
<corners android:radius="38dp" />
</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/rounded_outline_accent_38dp" android:state_focused="true" />
<item android:drawable="@drawable/rounded_outline_38dp" />
</selector>

Wyświetl plik

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<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="1dp" />
<corners android:radius="10dp" />
</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" />
</shape>
</item>
</selector>

Wyświetl plik

@ -19,6 +19,20 @@
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/circle_ultramarine" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/checkmark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0"
android:background="@drawable/circle_tintable"
android:elevation="4dp"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/v2_media_check"
tools:alpha="1" />
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"

Wyświetl plik

@ -0,0 +1,133 @@
<?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:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="20dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter">
<com.google.android.material.button.MaterialButton
android:id="@+id/boost_1"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="48sp"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
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"
tools:text="$3" />
<com.google.android.material.button.MaterialButton
android:id="@+id/boost_2"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginStart="10dp"
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: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"
tools:text="$5" />
<com.google.android.material.button.MaterialButton
android:id="@+id/boost_3"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="48sp"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
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"
tools:text="$10" />
<com.google.android.material.button.MaterialButton
android:id="@+id/boost_4"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginTop="12dp"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
app:backgroundTint="@color/signal_selectable_button_background_tint"
app:cornerRadius="38dp"
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"
tools:text="$20" />
<com.google.android.material.button.MaterialButton
android:id="@+id/boost_5"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginStart="10dp"
android:layout_marginTop="12dp"
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: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"
tools:text="$50" />
<com.google.android.material.button.MaterialButton
android:id="@+id/boost_6"
style="@style/Signal.Widget.Button.Large.Secondary"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginTop="12dp"
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: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"
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:gravity="center"
android:hint="@string/Boost__enter_custom_amount"
android:imeOptions="actionDone"
android:inputType="numberDecimal"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
android:textColorHint="@color/signal_text_primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/boost_4" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.badges.BadgeImageView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="24dp" />

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/button"
style="@style/Signal.Widget.Button.Large.Secondary.NoOutline"
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:iconGravity="textEnd"
tools:text="Secondary button" />

Wyświetl plik

@ -11,7 +11,7 @@
<TextView
android:id="@+id/section_header"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
android:textStyle="bold"

Wyświetl plik

@ -0,0 +1,28 @@
<?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="wrap_content">
<ImageView
android:id="@+id/handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:importantForAccessibility="no"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/bottom_sheet_handle" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/handle" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/space"
android:layout_width="match_parent"
android:layout_height="0dp" />

Wyświetl plik

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.Body"
tools:text="Message font size" />

Wyświetl plik

@ -0,0 +1,33 @@
<?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:layout_marginTop="28dp"
android:layout_marginBottom="24dp">
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/expired_badge"
android:layout_width="110dp"
android:layout_height="110dp"
app:layout_constraintHorizontal_bias="0.48"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/test_gradient" />
<ImageView
android:id="@+id/expired_badge_fade"
android:importantForAccessibility="no"
android:layout_width="110dp"
android:layout_height="110dp"
app:layout_constraintHorizontal_bias="0.52"
android:src="@drawable/expired_badge_fade"
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,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginEnd="@dimen/dsl_settings_gutter">
<include
android:id="@+id/googlepay_button"
layout="@layout/donate_with_googlepay_button" />
</FrameLayout>

Wyświetl plik

@ -0,0 +1,87 @@
<?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"
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">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_outline">
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/my_support_badge"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="16dp"
app:badge_size="xlarge"
app:layout_constraintBottom_toBottomOf="@id/my_support_expiry"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/my_support_title"
tools:src="@drawable/test_gradient" />
<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_marginEnd="12dp"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/my_support_badge"
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:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/my_support_badge"
app:layout_constraintTop_toBottomOf="@id/my_support_price"
tools:text="$400.00" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/my_support_heading_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:barrierMargin="16dp"
app:constraint_referenced_ids="my_support_badge,my_support_expiry,my_support_price,my_support_title" />
<com.google.android.material.button.MaterialButton
android:id="@+id/my_support_boost"
android:background="@drawable/my_boost_gradient"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/MySupportPreference__add_a_signal_boost"
app:cornerRadius="0dp"
app:layout_constraintTop_toBottomOf="@id/my_support_heading_barrier" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

Wyświetl plik

@ -0,0 +1,13 @@
<?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="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

@ -0,0 +1,95 @@
<?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">
<ImageView
android:id="@+id/subscribe_bottom_sheet_handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:importantForAccessibility="no"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/bottom_sheet_handle" />
<!-- TODO [alex] - need final asset -->
<ImageView
android:id="@+id/subscribe_bottom_sheet_heart"
android:layout_width="76dp"
android:layout_height="74dp"
android:layout_marginTop="36dp"
android:importantForAccessibility="no"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_handle" />
<TextView
android:id="@+id/subscribe_bottom_sheet_headline"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="28dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:text="@string/SubscribeFragment__support_technology_that_is_built_for_you"
android:textAppearance="@style/TextAppearance.Signal.Title2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_heart" />
<TextView
android:id="@+id/subscribe_bottom_sheet_subhead"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="16dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:text="@string/SubscribeLearnMoreBottomSheetDialogFragment__signal_is_a_non_profit_with_no"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_headline" />
<TextView
android:id="@+id/subscribe_bottom_why"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="36dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:text="@string/SubscribeLearnMoreBottomSheetDialogFragment__why_contribute"
android:textAppearance="@style/TextAppearance.Signal.Title2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_subhead" />
<TextView
android:id="@+id/subscribe_bottom_sheet_the_team"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="16dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:text="@string/SubscribeLearnMoreBottomSheetDialogFragment__the_team_at_signal_is_committed"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_why" />
<TextView
android:id="@+id/subscribe_bottom_sheet_your_contribution"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="24dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:paddingBottom="36dp"
android:text="@string/SubscribeLearnMoreBottomSheetDialogFragment__your_contribution_helps_pay"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/subscribe_bottom_sheet_the_team" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,39 @@
<?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: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__donation_amount"
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" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/subscription_currency_selection_spinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/circled_rectangle_outline"
android:drawablePadding="8dp"
android:padding="12dp"
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:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/subscription_currency_selection_donation_amount"
app:layout_constraintTop_toTopOf="@id/subscription_currency_selection_donation_amount"
tools:text="USD" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,33 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="0dp"
app:cardElevation="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="24dp"
android:layout_marginBottom="24dp">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/avatar"
android:layout_width="88dp"
android:layout_height="88dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/badge"
android:layout_width="33dp"
android:layout_height="33dp"
android:contentDescription="@string/BadgesOverviewFragment__featured_badge"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="@id/avatar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

Wyświetl plik

@ -0,0 +1,71 @@
<?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:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="16dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@drawable/selectable_rounded_outline"
android:padding="16dp">
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/badge"
android:layout_width="64dp"
android:layout_height="64dp"
app:badge_size="xlarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
app:layout_constraintEnd_toStartOf="@id/check"
app:layout_constraintStart_toEndOf="@id/badge"
app:layout_constraintTop_toTopOf="parent"
tools:text="Subscription Name" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/check"
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="@id/title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/title"
app:layout_constraintTop_toTopOf="@id/title"
app:srcCompat="@drawable/ic_check_24"
app:tint="@color/signal_inverse_primary" />
<TextView
android:id="@+id/tagline"
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

@ -0,0 +1,124 @@
<?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="match_parent">
<ImageView
android:id="@+id/thanks_bottom_sheet_handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:importantForAccessibility="no"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/bottom_sheet_handle" />
<TextView
android:id="@+id/thanks_bottom_sheet_heading"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="20dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:gravity="center"
android:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support"
android:textAppearance="@style/TextAppearance.Signal.Title2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_handle" />
<TextView
android:id="@+id/thanks_bottom_sheet_subhead"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="12dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:gravity="center"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_heading"
tools:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_s_badge" />
<org.thoughtcrime.securesms.badges.BadgeImageView
android:id="@+id/thanks_bottom_sheet_badge"
android:layout_width="112dp"
android:layout_height="112dp"
android:layout_marginTop="26dp"
app:badge_size="xlarge"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_subhead"
tools:src="@drawable/test_gradient" />
<TextView
android:id="@+id/thanks_bottom_sheet_badge_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="12dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Signal.Title2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_badge"
tools:text="Signal Meteor" />
<View
android:id="@+id/thanks_bottom_sheet_control_outline"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/rounded_outline"
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_display_on_profile"
app:layout_constraintEnd_toEndOf="@id/thanks_bottom_sheet_switch"
app:layout_constraintStart_toStartOf="@id/thanks_bottom_sheet_display_on_profile"
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_display_on_profile" />
<TextView
android:id="@+id/thanks_bottom_sheet_display_on_profile"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="32dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile"
android:textAppearance="@style/TextAppearance.Signal.Body1"
app:layout_constraintEnd_toStartOf="@id/thanks_bottom_sheet_switch"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_badge_name" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/thanks_bottom_sheet_switch"
android:layout_width="wrap_content"
android:layout_height="48sp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_display_on_profile"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/thanks_bottom_sheet_display_on_profile"
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_display_on_profile" />
<com.google.android.material.button.MaterialButton
android:id="@+id/thanks_bottom_sheet_done"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="36dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__done"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_display_on_profile" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -25,6 +25,7 @@
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:tabBackground="@drawable/tab_selector"
app:tabGravity="center"
app:tabIndicatorHeight="0dp" />

Wyświetl plik

@ -94,6 +94,27 @@
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_subscriptions"
app:destination="@id/subscriptions"
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="skipToSubscribe"
app:argType="boolean"
android:defaultValue="false" />
</action>
<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" />
</fragment>
<activity
@ -139,6 +160,10 @@
<include app:graph="@navigation/app_settings_change_number" />
<include app:graph="@navigation/subscriptions" />
<include app:graph="@navigation/boosts" />
<fragment
android:id="@+id/advancedPinSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.wrapped.WrappedAdvancedPinPreferenceFragment"

Wyświetl plik

@ -0,0 +1,48 @@
<?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" />
<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,40 @@
<?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"
android:id="@+id/manage_badges"
app:startDestination="@id/badgeManageFragment">
<fragment
android:id="@+id/badgeManageFragment"
android:name="org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment"
android:label="fragment_manage_badges">
<action
android:id="@+id/action_badgeManageFragment_to_featuredBadgeFragment"
app:destination="@id/featuredBadgeFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_badgeManageFragment_to_expiredBadgeDialog"
app:destination="@id/expiredBadgeDialog" />
</fragment>
<fragment
android:id="@+id/featuredBadgeFragment"
android:name="org.thoughtcrime.securesms.badges.self.featured.SelectFeaturedBadgeFragment"
android:label="fragment_featured_badge" />
<dialog
android:id="@+id/expiredBadgeDialog"
android:name="org.thoughtcrime.securesms.badges.self.expired.ExpiredBadgeBottomSheetDialogFragment"
android:label="dialog_expired_badge">
<argument
android:name="badge"
app:argType="org.thoughtcrime.securesms.badges.models.Badge"
app:nullable="false" />
</dialog>
</navigation>

Wyświetl plik

@ -57,7 +57,7 @@
<action
android:id="@+id/action_manageProfileFragment_to_badgeManageFragment"
app:destination="@id/badgeManageFragment"
app:destination="@id/manage_badges"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
@ -83,25 +83,7 @@
android:label="fragment_manage_about"
tools:layout="@layout/edit_about_fragment" />
<fragment
android:id="@+id/badgeManageFragment"
android:name="org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment"
android:label="fragment_manage_badges" >
<action
android:id="@+id/action_badgeManageFragment_to_featuredBadgeFragment"
app:destination="@id/featuredBadgeFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</fragment>
<fragment
android:id="@+id/featuredBadgeFragment"
android:name="org.thoughtcrime.securesms.badges.self.featured.SelectFeaturedBadgeFragment"
android:label="fragment_featured_badge" />
<include app:graph="@navigation/manage_badges" />
<include app:graph="@navigation/avatar_picker" />

Wyświetl plik

@ -0,0 +1,103 @@
<?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/subscriptions"
app:startDestination="@id/manageDonationsFragment">
<fragment
android:id="@+id/manageDonationsFragment"
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_subscriptionBadgeManageFragment"
app:destination="@id/manage_badges"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_manageDonationsFragment_to_manage_badges"
app:destination="@id/manage_badges" />
<action
android:id="@+id/action_manageDonationsFragment_to_boosts"
app:destination="@id/boosts" />
<argument
android:name="skipToSubscribe"
android:defaultValue="false"
app:argType="boolean" />
</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" />
<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" />
<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" />
<include app:graph="@navigation/boosts" />
<action
android:id="@+id/action_directly_to_subscribe"
app:destination="@id/subscribeFragment" />
</navigation>

Wyświetl plik

@ -164,4 +164,6 @@
<color name="voice_note_player_view_background">@color/core_grey_80</color>
<color name="voice_note_player_speed_background_tint">@color/core_grey_65</color>
<color name="signal_accent_primary_transparent_15">#266191f3</color>
</resources>

Wyświetl plik

@ -164,4 +164,6 @@
<color name="voice_note_player_view_background">@color/core_grey_02</color>
<color name="voice_note_player_speed_background_tint">@color/transparent_black_08</color>
<color name="signal_accent_primary_transparent_15">#262c6bed</color>
</resources>

Wyświetl plik

@ -2352,6 +2352,8 @@
<string name="preferences__help">Help</string>
<string name="preferences__advanced">Advanced</string>
<string name="preferences__donate_to_signal">Donate to Signal</string>
<string name="preferences__subscription">Subscription</string>
<string name="preferences__signal_boost">Signal Boost</string>
<string name="preferences__privacy">Privacy</string>
<string name="preferences__mms_user_agent">MMS User Agent</string>
<string name="preferences__advanced_mms_access_point_names">Manual MMS settings</string>
@ -3866,6 +3868,55 @@
<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__donation_amount">Donation amount</string>
<string name="SubscribeFragment__more_payment_options">More Payment Options</string>
<string name="SubscribeFragment__cancel_subscription">Cancel Subscription</string>
<string name="SubscribeFragment__confirm_cancellation">Confirm Cancellation?</string>
<string name="SubscribeFragment__you_wont_be_charged_again">You won\'t be charged again. Your badge will be removed from your profile at the end of your billing period.</string>
<string name="SubscribeFragment__not_now">Not now</string>
<string name="SubscribeFragment__confirm">Confirm</string>
<string name="SubscribeFragment__update_subscription">Update Subscription</string>
<string name="SubscribeFragment__your_subscription_has_been_cancelled">Your subscription has been cancelled.</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="SubscribeLearnMoreBottomSheetDialogFragment__signal_is_a_non_profit_with_no">Signal is a non-profit with no advertisers or investors, sustained only by the people who use and value it. Make a recurring monthly contribution and receive a profile badge to share your support.</string>
<string name="SubscribeLearnMoreBottomSheetDialogFragment__why_contribute">Why Contribute?</string>
<string name="SubscribeLearnMoreBottomSheetDialogFragment__the_team_at_signal_is_committed">The team at Signal is committed to the mission of developing open source privacy technology that protects free expression and enables secure global communication. Your contribution fuels this cause. No ads. No trackers. No kidding.</string>
<string name="SubscribeLearnMoreBottomSheetDialogFragment__your_contribution_helps_pay">Your contribution helps pay for the development, servers, and bandwidth of an app used by millions around the world for private and instantaneous communication.</string>
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support">Thanks for your Support!</string>
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_the_boost">Thanks for the Boost!</string>
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__youve_earned_a_s_badge">You\'ve earned a %s badge! Displaying your badge will show people you chat with that you support Signal.</string>
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile">Display on Profile</string>
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge">Make featured badge</string>
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__done">Done</string>
<string name="ManageDonationsFragment__my_support">My support</string>
<string name="ManageDonationsFragment__manage_subscription">Manage subscription</string>
<string name="ManageDonationsFragment__badges">Badges</string>
<string name="ManageDonationsFragment__subscription_faq">Subscription FAQ</string>
<string name="ManageDonationsFragment__error_getting_subscription">Error getting subscription.</string>
<string name="BoostFragment__give_signal_a_boost">Give Signal a Boost</string>
<string name="BoostFragment__say_thanks_and_earn">Say "Thanks!" and earn the 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>
<string name="MySupportPreference__add_a_signal_boost">Add a Signal Boost</string>
<string name="MySupportPreference__s_per_month">%1$s/month</string>
<string name="MySupportPreference__renews_s">Renews %1$s</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired">Your Badge has Expired</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__your_s_badge_has_expired">Your %1$s badge has expired, and is no longer visible to others on your profile.</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting">To continue supporting technology that is built for you—not for your data—please consider becoming a monthly subscriber.</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__become_a_subscriber">Become a subscriber</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__not_now">Not now</string>
<!-- EOF -->
</resources>

Wyświetl plik

@ -0,0 +1,135 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.app.Application
import android.text.SpannableStringBuilder
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertNotNull
import junit.framework.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Currency
import java.util.Locale
@Suppress("ClassName")
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class BoostTest__MoneyFilter {
private val usd = Currency.getInstance("USD")
private val yen = Currency.getInstance("JPY")
@Before
fun setUp() {
Locale.setDefault(Locale.US)
}
@Test
fun `Given USD, when I enter 5, then I expect $ 5`() {
val testSubject = Boost.MoneyFilter(usd)
val editable = SpannableStringBuilder("5")
testSubject.afterTextChanged(editable)
assertEquals("$ 5", editable.toString())
}
@Test
fun `Given USD, when I enter 5dot00, then I expect successful filter`() {
val testSubject = Boost.MoneyFilter(usd)
val editable = SpannableStringBuilder("5.00")
val dest = SpannableStringBuilder()
testSubject.afterTextChanged(editable)
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
assertNull(filterResult)
}
@Test
fun `Given USD, when I enter 5dot00, then I expect 5dot00 from text change`() {
var result = ""
val testSubject = Boost.MoneyFilter(usd) {
result = it
}
val editable = SpannableStringBuilder("5.00")
testSubject.afterTextChanged(editable)
assertEquals("5.00", result)
}
@Test
fun `Given USD, when I enter 5dot000, then I expect unsuccessful filter`() {
val testSubject = Boost.MoneyFilter(yen)
val editable = SpannableStringBuilder("5.000")
val dest = SpannableStringBuilder()
testSubject.afterTextChanged(editable)
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
assertNotNull(filterResult)
}
@Test
fun `Given USD, when I enter 5dot, then I expect successful filter`() {
val testSubject = Boost.MoneyFilter(usd)
val editable = SpannableStringBuilder("5.")
val dest = SpannableStringBuilder()
testSubject.afterTextChanged(editable)
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
assertNull(filterResult)
}
@Test
fun `Given JPY, when I enter 5, then I expect yen 5`() {
val testSubject = Boost.MoneyFilter(yen)
val editable = SpannableStringBuilder("5")
testSubject.afterTextChanged(editable)
assertEquals("¥ 5", editable.toString())
}
@Test
fun `Given JPY, when I enter 5, then I expect 5 from text change`() {
var result = ""
val testSubject = Boost.MoneyFilter(yen) {
result = it
}
val editable = SpannableStringBuilder("5")
testSubject.afterTextChanged(editable)
assertEquals("5", result)
}
@Test
fun `Given JPY, when I enter 5, then I expect successful filter`() {
val testSubject = Boost.MoneyFilter(yen)
val editable = SpannableStringBuilder("5")
val dest = SpannableStringBuilder()
testSubject.afterTextChanged(editable)
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
assertNull(filterResult)
}
@Test
fun `Given JPY, when I enter 5dot, then I expect unsuccessful filter`() {
val testSubject = Boost.MoneyFilter(yen)
val editable = SpannableStringBuilder("5.")
val dest = SpannableStringBuilder()
testSubject.afterTextChanged(editable)
val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0)
assertNotNull(filterResult)
}
}

Wyświetl plik

@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import junit.framework.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import java.util.Currency
@Suppress("ClassName")
@RunWith(JUnit4::class)
class SetCurrencyViewModel__CurrencyComparatorTest {
private val currencyComparator = SetCurrencyViewModel.CurrencyComparator(listOf("AUD", "EUR", "CAD"))
@Test
fun givenAListOfCurrencies_whenISort_thenIExpectTheProperOrder() {
// GIVEN
val currencies = listOf("EUR", "AUD", "JPY", "USD", "CAD", "BWP", "BIF").map { Currency.getInstance(it) }
val expected = listOf("USD", "AUD", "CAD", "EUR", "BWP", "BIF", "JPY").map { Currency.getInstance(it) }
// WHEN
val sorted: List<Currency> = currencies.sortedWith(currencyComparator)
// THEN
assertEquals(expected, sorted)
}
@Test
fun givenUSDAndADefaultCurrency_whenISort_thenIExpectUSDFirst() {
// GIVEN
val currencies = listOf("EUR", "USD").map { Currency.getInstance(it) }
val expected = listOf("USD", "EUR").map { Currency.getInstance(it) }
// WHEN
val sorted: List<Currency> = currencies.sortedWith(currencyComparator)
// THEN
assertEquals(expected, sorted)
}
@Test
fun givenADefaultCurrencyAndANonDefaultCurrency_whenISort_thenIExpectUSDFirst() {
// GIVEN
val currencies = listOf("JPY", "EUR").map { Currency.getInstance(it) }
val expected = listOf("EUR", "JPY").map { Currency.getInstance(it) }
// WHEN
val sorted: List<Currency> = currencies.sortedWith(currencyComparator)
// THEN
assertEquals(expected, sorted)
}
@Test
fun givenTwoDefaultCurrencies_whenISort_thenIExpectOrderedByDisplayName() {
// GIVEN
val currencies = listOf("EUR", "AUD").map { Currency.getInstance(it) }
val expected = listOf("AUD", "EUR").map { Currency.getInstance(it) }
// WHEN
val sorted = currencies.sortedWith(currencyComparator)
// THEN
assertEquals(expected, sorted)
}
@Test
fun givenTwoNonDefaultCurrencies_whenISort_thenIExpectOrderedByDisplayName() {
// GIVEN
val currencies = listOf("XPF", "BIF").map { Currency.getInstance(it) }
val expected = listOf("BIF", "XPF").map { Currency.getInstance(it) }
// WHEN
val sorted = currencies.sortedWith(currencyComparator)
// THEN
assertEquals(expected, sorted)
}
}

Wyświetl plik

@ -333,23 +333,29 @@ dependencyVerification {
['com.google.android.gms:play-services-auth:16.0.1',
'aec9e1c584d442cb9f59481a50b2c66dc191872607c04d97ecb82dd0eb5149ec'],
['com.google.android.gms:play-services-base:16.0.1',
'aca10c780c3219bc50f3db06734f4ab88badd3113c564c0a3156ff8ff674655b'],
['com.google.android.gms:play-services-base:17.5.0',
'198c9e2115f5ce5f91140cd9b481dc6d64dd634ac2d6c6525567dc5fe00065cb'],
['com.google.android.gms:play-services-basement:17.0.0',
'd324a1785bbc48bfe3639fc847cfd3cf43d49e967b5caf2794240a854557a39c'],
['com.google.android.gms:play-services-basement:17.5.0',
'362301c0da1c765cbbdcf8ea866b6cb62bc130c86d2aa7cc9e9c18a6e51ea79d'],
['com.google.android.gms:play-services-cloud-messaging:16.0.0',
'3a5000df3d6b91f9b8b681b29331b4680d30c140f693b1c5d2969755b6fc4cf9'],
['com.google.android.gms:play-services-maps:16.1.0',
'ff50cae9e4059416202375597d99cdc8ddefd9cea3f1dc2ff53779a3a12eb480'],
['com.google.android.gms:play-services-identity:17.0.0',
'8987c6c303eaaa9c10c403822cf5ae188ee1ce61c3056eb3be2ca4aaecc80b5f'],
['com.google.android.gms:play-services-maps:17.0.0',
'f9e479bc57ff423959c6dd9d08d463c677f440e29d90de795418ea27da6c67fb'],
['com.google.android.gms:play-services-stats:17.0.0',
'e8ae5b40512b71e2258bfacd8cd3da398733aa4cde3b32d056093f832b83a6fe'],
['com.google.android.gms:play-services-tasks:17.0.0',
'2e6d1738b73647f3fe7a038b9780b97717b3746eae258009197e36e7bf3112a5'],
['com.google.android.gms:play-services-tasks:17.2.0',
'a131d126145dfe87de04fa904f9ce91753b2d3273851b7d084666a71255792a8'],
['com.google.android.gms:play-services-wallet:18.1.3',
'e19d1f4650f51ce2202c092cbe174058860b6558cf26c8be37a732eff3ae1864'],
['com.google.android.material:material:1.3.0',
'cbf1e7d69fc236cdadcbd1ec5f6c0a1a41aca6ad1ef7f8481058956270ab1f0a'],

Wyświetl plik

@ -23,21 +23,20 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
data class Success(val paymentIntent: PaymentIntent) : CreatePaymentIntentResult()
}
fun createPaymentIntent(price: FiatMoney, description: String? = null): Single<CreatePaymentIntentResult> = Single.fromCallable {
if (Validation.isAmountTooSmall(price)) {
CreatePaymentIntentResult.AmountIsTooSmall(price)
fun createPaymentIntent(price: FiatMoney, description: String? = null): Single<CreatePaymentIntentResult> {
@Suppress("CascadeIf")
return if (Validation.isAmountTooSmall(price)) {
Single.just(CreatePaymentIntentResult.AmountIsTooSmall(price))
} else if (Validation.isAmountTooLarge(price)) {
CreatePaymentIntentResult.AmountIsTooLarge(price)
Single.just(CreatePaymentIntentResult.AmountIsTooLarge(price))
} else if (!Validation.supportedCurrencyCodes.contains(price.currency.currencyCode.toUpperCase(Locale.ROOT))) {
CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode)
Single.just<CreatePaymentIntentResult>(CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode))
} else {
CreatePaymentIntentResult.Success(
paymentIntentFetcher.fetchPaymentIntent(
price, description
)
)
}
}.subscribeOn(Schedulers.io())
paymentIntentFetcher
.fetchPaymentIntent(price, description)
.map<CreatePaymentIntentResult> { CreatePaymentIntentResult.Success(it) }
}.subscribeOn(Schedulers.io())
}
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Completable = Completable.fromAction {
val paymentMethodId = createPaymentMethod(paymentSource).use { response ->
@ -293,7 +292,7 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
fun fetchPaymentIntent(
price: FiatMoney,
description: String? = null
): PaymentIntent
): Single<PaymentIntent>
}
data class PaymentIntent(

Wyświetl plik

@ -6,8 +6,11 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
import org.whispersystems.signalservice.internal.push.DonationIntentResult;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import java.io.IOException;
import io.reactivex.rxjava3.core.Scheduler;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@ -45,4 +48,21 @@ public class DonationsService {
}
}).subscribeOn(Schedulers.io());
}
/**
* Submits price information to the server to generate a payment intent via the payment gateway.
*
* @param amount Price, in the minimum currency unit (e.g. cents or yen)
* @param currencyCode The currency code for the amount
* @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway.
*/
public Single<ServiceResponse<DonationIntentResult>> createDonationIntentWithAmount(String amount, String currencyCode) {
return Single.fromCallable(() -> {
try {
return ServiceResponse.forResult(this.pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200, null);
} catch (IOException e) {
return ServiceResponse.<DonationIntentResult>forUnknownError(e);
}
}).subscribeOn(Schedulers.io());
}
}

Wyświetl plik

@ -0,0 +1,16 @@
package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
class DonationIntentPayload {
@JsonProperty
private long amount;
@JsonProperty
private String currency;
public DonationIntentPayload(long amount, String currency) {
this.amount = amount;
this.currency = currency;
}
}

Wyświetl plik

@ -0,0 +1,24 @@
package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
public class DonationIntentResult {
@JsonProperty("id")
private String id;
@JsonProperty("client_secret")
private String clientSecret;
public DonationIntentResult(@JsonProperty("id") String id, @JsonProperty("client_secret") String clientSecret) {
this.id = id;
this.clientSecret = clientSecret;
}
public String getId() {
return id;
}
public String getClientSecret() {
return clientSecret;
}
}

Wyświetl plik

@ -230,9 +230,11 @@ public class PushServiceSocket {
private static final String PAYMENTS_CONVERSIONS = "/v1/payments/conversions";
private static final String SUBMIT_RATE_LIMIT_CHALLENGE = "/v1/challenge";
private static final String REQUEST_RATE_LIMIT_PUSH_CHALLENGE = "/v1/challenge/push";
private static final String DONATION_INTENT = "/v1/donation/authorize-apple-pay";
private static final String DONATION_REDEEM_RECEIPT = "/v1/donation/redeem-receipt";
private static final String REPORT_SPAM = "/v1/messages/report/%s/%s";
@ -869,6 +871,15 @@ public class PushServiceSocket {
makeServiceRequest(DONATION_REDEEM_RECEIPT, "PUT", payload);
}
/**
* @return The PaymentIntent id
*/
public DonationIntentResult createDonationIntentWithAmount(String amount, String currencyCode) throws IOException {
String payload = JsonUtil.toJson(new DonationIntentPayload(Long.parseLong(amount), currencyCode.toLowerCase(Locale.ROOT)));
String result = makeServiceRequest(DONATION_INTENT, "POST", payload);
return JsonUtil.fromJsonResponse(result, DonationIntentResult.class);
}
public List<ContactTokenDetails> retrieveDirectory(Set<String> contactTokens)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{