diff --git a/app/build.gradle b/app/build.gradle
index 6ab76c37bb..d44f4daecc 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d04931542a..19d268d08e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -101,6 +101,10 @@
android:theme="@style/TextSecure.LightTheme"
android:largeHeap="true">
+
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt
index d0bfd90387..4efa6e1009 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt
@@ -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
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt
index da3b5c4d7e..c6e54b3ba8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt
@@ -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, selectedBadge: Badge? = null) {
badges
.map { Badge.Model(it, it == selectedBadge) }
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt
index 3c9b53369b..ce98dba774 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt
@@ -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(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(view) {
-
- private val animator: BadgeAnimator = BadgeAnimator()
-
- override fun onLoadFailed(errorDrawable: Drawable?) {
- view.setImageDrawable(errorDrawable)
- }
-
- override fun onResourceReady(resource: Drawable, transition: Transition?) {
- 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()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeAnimator.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeAnimator.kt
deleted file mode 100644
index 9279758610..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeAnimator.kt
+++ /dev/null
@@ -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
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/FeaturedBadgePreview.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt
similarity index 58%
rename from app/src/main/java/org/thoughtcrime/securesms/badges/models/FeaturedBadgePreview.kt
rename to app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt
index 1af79b2382..5c849c1ed5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/FeaturedBadgePreview.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt
@@ -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() {
+ abstract class BadgeModel> : PreferenceModel() {
+ abstract val badge: Badge?
+ }
+
+ data class Model(override val badge: Badge?) : BadgeModel() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.badge?.id == badge?.id
}
@@ -25,12 +30,22 @@ object FeaturedBadgePreview {
}
}
- class ViewHolder(itemView: View) : MappingViewHolder(itemView) {
+ data class SubscriptionModel(override val badge: Badge?) : BadgeModel() {
+ 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>(itemView: View) : MappingViewHolder(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)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/ExpiredBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/ExpiredBadge.kt
new file mode 100644
index 0000000000..947761800a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/ExpiredBadge.kt
@@ -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() {
+ 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(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))
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt
new file mode 100644
index 0000000000..6b727365d3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt
@@ -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()
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt
index ae4c9e7c5c..9aed2a745d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt
@@ -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(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())
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt
index 1fc68bc950..90f5223e9f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeViewModel.kt
@@ -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
)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt
index ae9e18afc9..0b3dcbc31e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt
@@ -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())
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt
index 0a61157ec6..ef8288ba56 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt
@@ -6,8 +6,11 @@ data class BadgesOverviewState(
val stage: Stage = Stage.INIT,
val allUnlockedBadges: List = 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,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt
index d2f0e1983c..d5ec8c9778 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt
@@ -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
)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt
index 5e00c86d4c..bf408cb044 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/view/ViewBadgeBottomSheetDialogFragment.kt
@@ -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()))
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt
index d60e137892..94541318e8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt
@@ -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)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsBottomSheetFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsBottomSheetFragment.kt
new file mode 100644
index 0000000000..996817c72c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsBottomSheetFragment.kt
@@ -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))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt
index 9457fed596..6f80910125 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsText.kt
@@ -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
+
private data class FromResource(
@StringRes private val stringId: Int,
- @ColorInt private val textColor: Int?
+ override val modifiers: List
) : 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
+ ) : 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)
+ }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt
index 37c6e733cf..192f19eebd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt
@@ -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),
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt
index 1cb834ee88..b1e4a0f30f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt
@@ -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()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt
new file mode 100644
index 0000000000..dec04beaae
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt
@@ -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()
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt
new file mode 100644
index 0000000000..e6c9353379
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt
@@ -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 {
+ return ApplicationDependencies
+ .getDonationsService()
+ .createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
+ .map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt
new file mode 100644
index 0000000000..d02723109f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt
@@ -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 = Maybe.empty()
+
+ fun getSubscriptions(currency: Currency): Single> = Single.fromCallable {
+ listOf()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt
new file mode 100644
index 0000000000..8ccb585398
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt
@@ -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() {
+ 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,
+ 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(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(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(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))
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt
new file mode 100644
index 0000000000..c8cdb43450
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt
new file mode 100644
index 0000000000..fccd5718af
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt
@@ -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, Boost?>> {
+ val boosts = testBoosts(currency)
+
+ return Single.just(
+ Pair(
+ boosts,
+ boosts[2]
+ )
+ )
+ }
+
+ fun getBoostBadge(): Single = 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))
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt
new file mode 100644
index 0000000000..02c957940e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt
@@ -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 = 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,
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt
new file mode 100644
index 0000000000..e420a4cb7d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt
@@ -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 = PublishSubject.create()
+ private val disposables = CompositeDisposable()
+
+ val state: LiveData = store.stateLiveData
+ val events: Observable = 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, val defaultBoost: Boost?, val boostBadge: Badge)
+
+ class Factory(
+ private val boostRepository: BoostRepository,
+ private val donationPaymentRepository: DonationPaymentRepository,
+ private val fetchTokenRequestCode: Int
+ ) : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ return modelClass.cast(BoostViewModel(boostRepository, donationPaymentRepository, fetchTokenRequestCode))!!
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt
new file mode 100644
index 0000000000..f80cb4975c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt
@@ -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)
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyState.kt
new file mode 100644
index 0000000000..db9b25e2fa
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyState.kt
@@ -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 = listOf()
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt
new file mode 100644
index 0000000000..64f03101c4
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt
@@ -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 = 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) : Comparator {
+
+ 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()))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt
new file mode 100644
index 0000000000..388c01735a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt
@@ -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() {
+ 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(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))
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsEvent.kt
new file mode 100644
index 0000000000..57e0f5ab60
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsEvent.kt
@@ -0,0 +1,6 @@
+package org.thoughtcrime.securesms.components.settings.app.subscription.manage
+
+enum class ManageDonationsEvent {
+ NOT_SUBSCRIBED,
+ ERROR_GETTING_SUBSCRIPTION
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt
new file mode 100644
index 0000000000..ce614c0e43
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt
new file mode 100644
index 0000000000..d95ffe644b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt
@@ -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
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt
new file mode 100644
index 0000000000..eb3d07ed63
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt
@@ -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()
+ private val disposables = CompositeDisposable()
+
+ val state: LiveData = store.stateLiveData
+ val events: Observable = 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 create(modelClass: Class): T {
+ return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt
new file mode 100644
index 0000000000..81dde6f888
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt
@@ -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(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(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() }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/GooglePayButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/GooglePayButton.kt
new file mode 100644
index 0000000000..a51ea34087
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/GooglePayButton.kt
@@ -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(isEnabled = isEnabled) {
+ override fun areItemsTheSame(newItem: Model): Boolean = true
+ }
+
+ class ViewHolder(itemView: View) : MappingViewHolder(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))
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt
new file mode 100644
index 0000000000..5cec8e030a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeLearnMoreBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeLearnMoreBottomSheetDialogFragment.kt
new file mode 100644
index 0000000000..e6f545cec3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeLearnMoreBottomSheetDialogFragment.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt
new file mode 100644
index 0000000000..34d3c0b1ef
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt
@@ -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 = 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
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt
new file mode 100644
index 0000000000..ff320c9573
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt
@@ -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 = PublishSubject.create()
+ private val disposables = CompositeDisposable()
+
+ val state: LiveData = store.stateLiveData
+ val events: Observable = 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 create(modelClass: Class): T {
+ return modelClass.cast(SubscribeViewModel(subscriptionsRepository, donationPaymentRepository, fetchTokenRequestCode))!!
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt
new file mode 100644
index 0000000000..5639da748a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt
index 273faac848..ce1d087d0d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt
@@ -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) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt
index 475e8ff381..89d581d3fa 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt
@@ -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
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt
new file mode 100644
index 0000000000..4bfb122aef
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt
@@ -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 }, R.layout.dsl_button_primary))
+ mappingAdapter.registerFactory(Model.SecondaryNoOutline::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) as MappingViewHolder }, R.layout.dsl_button_secondary))
+ }
+
+ sealed class Model>(
+ title: DSLSettingsText?,
+ icon: DSLSettingsIcon?,
+ isEnabled: Boolean,
+ val onClick: () -> Unit
+ ) : PreferenceModel(
+ title = title,
+ icon = icon,
+ isEnabled = isEnabled
+ ) {
+ class Primary(
+ title: DSLSettingsText?,
+ icon: DSLSettingsIcon?,
+ isEnabled: Boolean,
+ onClick: () -> Unit
+ ) : Model(title, icon, isEnabled, onClick)
+
+ class SecondaryNoOutline(
+ title: DSLSettingsText?,
+ icon: DSLSettingsIcon?,
+ isEnabled: Boolean,
+ onClick: () -> Unit
+ ) : Model(title, icon, isEnabled, onClick)
+ }
+
+ class ViewHolder(itemView: View) : MappingViewHolder>(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
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Space.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Space.kt
new file mode 100644
index 0000000000..e08374f6ed
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Space.kt
@@ -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() {
+ 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(itemView) {
+ override fun bind(model: Model) {
+ itemView.updateLayoutParams {
+ height = model.space.pixels
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Text.kt
new file mode 100644
index 0000000000..9d7fa721ed
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Text.kt
@@ -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() {
+ 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(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
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt
new file mode 100644
index 0000000000..20563e213a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt
@@ -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 = mutableListOf(KEY_CURRENCY_CODE)
+
+ private val currencyPublisher: Subject = BehaviorSubject.createDefault(getCurrency())
+ val observableCurrency: Observable = 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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java
index 1c197b866f..cd2d225671 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java
@@ -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;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt
new file mode 100644
index 0000000000..fe906fa264
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt
@@ -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(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(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
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java
index 09386a2427..75d5dcbfe6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java
@@ -10,7 +10,7 @@ import androidx.lifecycle.LifecycleOwner;
import java.util.LinkedList;
import java.util.List;
-public abstract class MappingViewHolder> extends LifecycleViewHolder implements LifecycleOwner {
+public abstract class MappingViewHolder extends LifecycleViewHolder implements LifecycleOwner {
protected final Context context;
protected final List