diff --git a/app/build.gradle b/app/build.gradle index 6ab76c37b..d44f4daec 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 d04931542..19d268d08 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 d0bfd9038..4efa6e100 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 da3b5c4d7..c6e54b3ba 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 3c9b53369..ce98dba77 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 927975861..000000000 --- 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 1af79b238..5c849c1ed 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 000000000..947761800 --- /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 000000000..6b727365d --- /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 ae4c9e7c5..9aed2a745 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 1fc68bc95..90f5223e9 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 ae9e18afc..0b3dcbc31 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 0a61157ec..ef8288ba5 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 d2f0e1983..d5ec8c977 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 5e00c86d4..bf408cb04 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 d60e13789..94541318e 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 000000000..996817c72 --- /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 9457fed59..6f8091012 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 37c6e733c..192f19eeb 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 1cb834ee8..b1e4a0f30 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 000000000..dec04beaa --- /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 000000000..e6c935337 --- /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 000000000..d02723109 --- /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 000000000..8ccb58539 --- /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 000000000..c8cdb4345 --- /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 000000000..fccd5718a --- /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 000000000..02c957940 --- /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 000000000..e420a4cb7 --- /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 000000000..f80cb4975 --- /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 000000000..db9b25e2f --- /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 000000000..64f03101c --- /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 000000000..388c01735 --- /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 000000000..57e0f5ab6 --- /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 000000000..ce614c0e4 --- /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 000000000..d95ffe644 --- /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 000000000..eb3d07ed6 --- /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 000000000..81dde6f88 --- /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 000000000..a51ea3408 --- /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 000000000..5cec8e030 --- /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 000000000..e6f545cec --- /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 000000000..34d3c0b1e --- /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 000000000..ff320c957 --- /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 000000000..5639da748 --- /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 273faac84..ce1d087d0 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 475e8ff38..89d581d3f 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 000000000..4bfb122ae --- /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 000000000..e08374f6e --- /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 000000000..9d7fa721e --- /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 000000000..20563e213 --- /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 1c197b866..cd2d22567 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 000000000..fe906fa26 --- /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 09386a242..75d5dcbfe 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 payload; @@ -36,7 +36,7 @@ public abstract class MappingViewHolder> exten this.payload.addAll(payload); } - public static final class SimpleViewHolder> extends MappingViewHolder { + public static final class SimpleViewHolder extends MappingViewHolder { public SimpleViewHolder(@NonNull View itemView) { super(itemView); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java index 5179a3ce8..0e5310459 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java @@ -5,6 +5,7 @@ import android.content.res.Resources; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Build; +import android.text.Layout; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; @@ -12,6 +13,7 @@ import android.text.Spanned; import android.text.TextPaint; import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; +import android.text.style.AlignmentSpan; import android.text.style.BulletSpan; import android.text.style.CharacterStyle; import android.text.style.ClickableSpan; @@ -20,12 +22,14 @@ import android.text.style.ForegroundColorSpan; import android.text.style.ImageSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; +import android.text.style.TextAppearanceSpan; import android.text.style.TypefaceSpan; import android.view.View; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.StringRes; +import androidx.annotation.StyleRes; import androidx.core.content.ContextCompat; import org.thoughtcrime.securesms.R; @@ -40,6 +44,18 @@ public final class SpanUtil { private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL); private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL); + public static CharSequence center(@NonNull CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + + public static CharSequence textAppearance(@NonNull Context context, @StyleRes int textAppearance, @NonNull CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new TextAppearanceSpan(context, textAppearance), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + public static CharSequence italic(CharSequence sequence) { return italic(sequence, sequence.length()); } diff --git a/app/src/main/res/color/signal_selectable_button_background_tint.xml b/app/src/main/res/color/signal_selectable_button_background_tint.xml new file mode 100644 index 000000000..780216754 --- /dev/null +++ b/app/src/main/res/color/signal_selectable_button_background_tint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/signal_selectable_button_stroke.xml b/app/src/main/res/color/signal_selectable_button_stroke.xml new file mode 100644 index 000000000..598467db6 --- /dev/null +++ b/app/src/main/res/color/signal_selectable_button_stroke.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/expired_badge_fade.xml b/app/src/main/res/drawable/expired_badge_fade.xml new file mode 100644 index 000000000..2edcc7cee --- /dev/null +++ b/app/src/main/res/drawable/expired_badge_fade.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/my_boost_gradient.xml b/app/src/main/res/drawable/my_boost_gradient.xml new file mode 100644 index 000000000..38e7a1933 --- /dev/null +++ b/app/src/main/res/drawable/my_boost_gradient.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/rounded_outline_38dp.xml b/app/src/main/res/drawable/rounded_outline_38dp.xml new file mode 100644 index 000000000..2f870d03e --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline_38dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_outline_accent_38dp.xml b/app/src/main/res/drawable/rounded_outline_accent_38dp.xml new file mode 100644 index 000000000..d23a1b75e --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline_accent_38dp.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_outline_focusable_38dp.xml b/app/src/main/res/drawable/rounded_outline_focusable_38dp.xml new file mode 100644 index 000000000..7351a72af --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline_focusable_38dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selectable_rounded_outline.xml b/app/src/main/res/drawable/selectable_rounded_outline.xml new file mode 100644 index 000000000..4eff28f1f --- /dev/null +++ b/app/src/main/res/drawable/selectable_rounded_outline.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/badge_preference_view.xml b/app/src/main/res/layout/badge_preference_view.xml index 03c83b461..ebc9ed1fa 100644 --- a/app/src/main/res/layout/badge_preference_view.xml +++ b/app/src/main/res/layout/badge_preference_view.xml @@ -19,6 +19,20 @@ app:layout_constraintTop_toTopOf="parent" tools:src="@drawable/circle_ultramarine" /> + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/boost_preview_preference.xml b/app/src/main/res/layout/boost_preview_preference.xml new file mode 100644 index 000000000..c07243eb0 --- /dev/null +++ b/app/src/main/res/layout/boost_preview_preference.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_button_primary.xml b/app/src/main/res/layout/dsl_button_primary.xml new file mode 100644 index 000000000..722993245 --- /dev/null +++ b/app/src/main/res/layout/dsl_button_primary.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_button_secondary.xml b/app/src/main/res/layout/dsl_button_secondary.xml new file mode 100644 index 000000000..728fbbe6d --- /dev/null +++ b/app/src/main/res/layout/dsl_button_secondary.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_section_header.xml b/app/src/main/res/layout/dsl_section_header.xml index 2a8bcb744..1a5c8442e 100644 --- a/app/src/main/res/layout/dsl_section_header.xml +++ b/app/src/main/res/layout/dsl_section_header.xml @@ -11,7 +11,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_space_preference.xml b/app/src/main/res/layout/dsl_space_preference.xml new file mode 100644 index 000000000..e4437df61 --- /dev/null +++ b/app/src/main/res/layout/dsl_space_preference.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/dsl_text_preference.xml b/app/src/main/res/layout/dsl_text_preference.xml new file mode 100644 index 000000000..1090f4a7a --- /dev/null +++ b/app/src/main/res/layout/dsl_text_preference.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/expired_badge_preference.xml b/app/src/main/res/layout/expired_badge_preference.xml new file mode 100644 index 000000000..19e864bda --- /dev/null +++ b/app/src/main/res/layout/expired_badge_preference.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/google_pay_button_pref.xml b/app/src/main/res/layout/google_pay_button_pref.xml new file mode 100644 index 000000000..893e3156d --- /dev/null +++ b/app/src/main/res/layout/google_pay_button_pref.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/my_support_preference.xml b/app/src/main/res/layout/my_support_preference.xml new file mode 100644 index 000000000..2d8cb3c07 --- /dev/null +++ b/app/src/main/res/layout/my_support_preference.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/subscribe_activity.xml b/app/src/main/res/layout/subscribe_activity.xml new file mode 100644 index 000000000..b07556fbe --- /dev/null +++ b/app/src/main/res/layout/subscribe_activity.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/subscribe_learn_more_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/subscribe_learn_more_bottom_sheet_dialog_fragment.xml new file mode 100644 index 000000000..ab4367fc7 --- /dev/null +++ b/app/src/main/res/layout/subscribe_learn_more_bottom_sheet_dialog_fragment.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/subscription_currency_selection.xml b/app/src/main/res/layout/subscription_currency_selection.xml new file mode 100644 index 000000000..425f785a1 --- /dev/null +++ b/app/src/main/res/layout/subscription_currency_selection.xml @@ -0,0 +1,39 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/subscription_flow_badge_preview_preference.xml b/app/src/main/res/layout/subscription_flow_badge_preview_preference.xml new file mode 100644 index 000000000..01da4b0c3 --- /dev/null +++ b/app/src/main/res/layout/subscription_flow_badge_preview_preference.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/subscription_preference.xml b/app/src/main/res/layout/subscription_preference.xml new file mode 100644 index 000000000..999829889 --- /dev/null +++ b/app/src/main/res/layout/subscription_preference.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/thanks_for_your_support_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/thanks_for_your_support_bottom_sheet_dialog_fragment.xml new file mode 100644 index 000000000..8b75da38c --- /dev/null +++ b/app/src/main/res/layout/thanks_for_your_support_bottom_sheet_dialog_fragment.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml index 28aaa4233..e041e01c0 100644 --- a/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml +++ b/app/src/main/res/layout/view_badge_bottom_sheet_dialog_fragment.xml @@ -25,6 +25,7 @@ android:id="@+id/tab_layout" android:layout_width="match_parent" android:layout_height="wrap_content" + android:visibility="gone" app:tabBackground="@drawable/tab_selector" app:tabGravity="center" app:tabIndicatorHeight="0dp" /> diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index f37da7cc5..7933d2770 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -94,6 +94,27 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/manage_badges.xml b/app/src/main/res/navigation/manage_badges.xml new file mode 100644 index 000000000..94868e6c8 --- /dev/null +++ b/app/src/main/res/navigation/manage_badges.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/manage_profile.xml b/app/src/main/res/navigation/manage_profile.xml index c95e911e6..294da435e 100644 --- a/app/src/main/res/navigation/manage_profile.xml +++ b/app/src/main/res/navigation/manage_profile.xml @@ -57,7 +57,7 @@ - - - - - - - + diff --git a/app/src/main/res/navigation/subscriptions.xml b/app/src/main/res/navigation/subscriptions.xml new file mode 100644 index 000000000..f32bed819 --- /dev/null +++ b/app/src/main/res/navigation/subscriptions.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 44daa5450..260e6681e 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -164,4 +164,6 @@ @color/core_grey_80 @color/core_grey_65 + + #266191f3 diff --git a/app/src/main/res/values/light_colors.xml b/app/src/main/res/values/light_colors.xml index 458fc7ab3..c2f0ec05d 100644 --- a/app/src/main/res/values/light_colors.xml +++ b/app/src/main/res/values/light_colors.xml @@ -164,4 +164,6 @@ @color/core_grey_02 @color/transparent_black_08 + + #262c6bed diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ec8678a7..83cbce9fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2352,6 +2352,8 @@ Help Advanced Donate to Signal + Subscription + Signal Boost Privacy MMS User Agent Manual MMS settings @@ -3866,6 +3868,55 @@ Badge + Signal is powered by people like you. + Support technology that is built for you—not for your data—by joining the community of people that sustain it. + Donation amount + More Payment Options + Cancel Subscription + Confirm Cancellation? + You won\'t be charged again. Your badge will be removed from your profile at the end of your billing period. + Not now + Confirm + Update Subscription + Your subscription has been cancelled. + + %s/month + %1$s/month · Renews %2$s + + Signal is a non-profit with no advertisers or investors, sustained only by the people who use and value it. Make a recurring monthly contribution and receive a profile badge to share your support. + Why Contribute? + The team at Signal is committed to the mission of developing open source privacy technology that protects free expression and enables secure global communication. Your contribution fuels this cause. No ads. No trackers. No kidding. + Your contribution helps pay for the development, servers, and bandwidth of an app used by millions around the world for private and instantaneous communication. + + Thanks for your Support! + Thanks for the Boost! + You\'ve earned a %s badge! Displaying your badge will show people you chat with that you support Signal. + Display on Profile + Make featured badge + Done + + My support + Manage subscription + Badges + Subscription FAQ + Error getting subscription. + + Give Signal a Boost + Say "Thanks!" and earn the Boost badge for %1$d days. + + Enter Custom Amount + One-time contribution + + Add a Signal Boost + %1$s/month + Renews %1$s + + Your Badge has Expired + Your %1$s badge has expired, and is no longer visible to others on your profile. + To continue supporting technology that is built for you—not for your data—please consider becoming a monthly subscriber. + Become a subscriber + Not now + diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt new file mode 100644 index 000000000..d3258543a --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt @@ -0,0 +1,135 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.boost + +import android.app.Application +import android.text.SpannableStringBuilder +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertNotNull +import junit.framework.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Currency +import java.util.Locale + +@Suppress("ClassName") +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class BoostTest__MoneyFilter { + + private val usd = Currency.getInstance("USD") + private val yen = Currency.getInstance("JPY") + + @Before + fun setUp() { + Locale.setDefault(Locale.US) + } + + @Test + fun `Given USD, when I enter 5, then I expect $ 5`() { + val testSubject = Boost.MoneyFilter(usd) + val editable = SpannableStringBuilder("5") + + testSubject.afterTextChanged(editable) + + assertEquals("$ 5", editable.toString()) + } + + @Test + fun `Given USD, when I enter 5dot00, then I expect successful filter`() { + val testSubject = Boost.MoneyFilter(usd) + val editable = SpannableStringBuilder("5.00") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } + + @Test + fun `Given USD, when I enter 5dot00, then I expect 5dot00 from text change`() { + var result = "" + val testSubject = Boost.MoneyFilter(usd) { + result = it + } + + val editable = SpannableStringBuilder("5.00") + testSubject.afterTextChanged(editable) + + assertEquals("5.00", result) + } + + @Test + fun `Given USD, when I enter 5dot000, then I expect unsuccessful filter`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("5.000") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNotNull(filterResult) + } + + @Test + fun `Given USD, when I enter 5dot, then I expect successful filter`() { + val testSubject = Boost.MoneyFilter(usd) + val editable = SpannableStringBuilder("5.") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } + + @Test + fun `Given JPY, when I enter 5, then I expect yen 5`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("5") + + testSubject.afterTextChanged(editable) + + assertEquals("¥ 5", editable.toString()) + } + + @Test + fun `Given JPY, when I enter 5, then I expect 5 from text change`() { + var result = "" + val testSubject = Boost.MoneyFilter(yen) { + result = it + } + + val editable = SpannableStringBuilder("5") + + testSubject.afterTextChanged(editable) + + assertEquals("5", result) + } + + @Test + fun `Given JPY, when I enter 5, then I expect successful filter`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("5") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNull(filterResult) + } + + @Test + fun `Given JPY, when I enter 5dot, then I expect unsuccessful filter`() { + val testSubject = Boost.MoneyFilter(yen) + val editable = SpannableStringBuilder("5.") + val dest = SpannableStringBuilder() + + testSubject.afterTextChanged(editable) + val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) + + assertNotNull(filterResult) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel__CurrencyComparatorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel__CurrencyComparatorTest.kt new file mode 100644 index 000000000..0b0de640c --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel__CurrencyComparatorTest.kt @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.currency + +import junit.framework.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.util.Currency + +@Suppress("ClassName") +@RunWith(JUnit4::class) +class SetCurrencyViewModel__CurrencyComparatorTest { + + private val currencyComparator = SetCurrencyViewModel.CurrencyComparator(listOf("AUD", "EUR", "CAD")) + + @Test + fun givenAListOfCurrencies_whenISort_thenIExpectTheProperOrder() { + // GIVEN + val currencies = listOf("EUR", "AUD", "JPY", "USD", "CAD", "BWP", "BIF").map { Currency.getInstance(it) } + val expected = listOf("USD", "AUD", "CAD", "EUR", "BWP", "BIF", "JPY").map { Currency.getInstance(it) } + + // WHEN + val sorted: List = currencies.sortedWith(currencyComparator) + + // THEN + assertEquals(expected, sorted) + } + + @Test + fun givenUSDAndADefaultCurrency_whenISort_thenIExpectUSDFirst() { + // GIVEN + val currencies = listOf("EUR", "USD").map { Currency.getInstance(it) } + val expected = listOf("USD", "EUR").map { Currency.getInstance(it) } + + // WHEN + val sorted: List = currencies.sortedWith(currencyComparator) + + // THEN + assertEquals(expected, sorted) + } + + @Test + fun givenADefaultCurrencyAndANonDefaultCurrency_whenISort_thenIExpectUSDFirst() { + // GIVEN + val currencies = listOf("JPY", "EUR").map { Currency.getInstance(it) } + val expected = listOf("EUR", "JPY").map { Currency.getInstance(it) } + + // WHEN + val sorted: List = currencies.sortedWith(currencyComparator) + + // THEN + assertEquals(expected, sorted) + } + + @Test + fun givenTwoDefaultCurrencies_whenISort_thenIExpectOrderedByDisplayName() { + // GIVEN + val currencies = listOf("EUR", "AUD").map { Currency.getInstance(it) } + val expected = listOf("AUD", "EUR").map { Currency.getInstance(it) } + + // WHEN + val sorted = currencies.sortedWith(currencyComparator) + + // THEN + assertEquals(expected, sorted) + } + + @Test + fun givenTwoNonDefaultCurrencies_whenISort_thenIExpectOrderedByDisplayName() { + // GIVEN + val currencies = listOf("XPF", "BIF").map { Currency.getInstance(it) } + val expected = listOf("BIF", "XPF").map { Currency.getInstance(it) } + + // WHEN + val sorted = currencies.sortedWith(currencyComparator) + + // THEN + assertEquals(expected, sorted) + } +} diff --git a/app/witness-verifications.gradle b/app/witness-verifications.gradle index 0c32700ae..285afca4f 100644 --- a/app/witness-verifications.gradle +++ b/app/witness-verifications.gradle @@ -333,23 +333,29 @@ dependencyVerification { ['com.google.android.gms:play-services-auth:16.0.1', 'aec9e1c584d442cb9f59481a50b2c66dc191872607c04d97ecb82dd0eb5149ec'], - ['com.google.android.gms:play-services-base:16.0.1', - 'aca10c780c3219bc50f3db06734f4ab88badd3113c564c0a3156ff8ff674655b'], + ['com.google.android.gms:play-services-base:17.5.0', + '198c9e2115f5ce5f91140cd9b481dc6d64dd634ac2d6c6525567dc5fe00065cb'], - ['com.google.android.gms:play-services-basement:17.0.0', - 'd324a1785bbc48bfe3639fc847cfd3cf43d49e967b5caf2794240a854557a39c'], + ['com.google.android.gms:play-services-basement:17.5.0', + '362301c0da1c765cbbdcf8ea866b6cb62bc130c86d2aa7cc9e9c18a6e51ea79d'], ['com.google.android.gms:play-services-cloud-messaging:16.0.0', '3a5000df3d6b91f9b8b681b29331b4680d30c140f693b1c5d2969755b6fc4cf9'], - ['com.google.android.gms:play-services-maps:16.1.0', - 'ff50cae9e4059416202375597d99cdc8ddefd9cea3f1dc2ff53779a3a12eb480'], + ['com.google.android.gms:play-services-identity:17.0.0', + '8987c6c303eaaa9c10c403822cf5ae188ee1ce61c3056eb3be2ca4aaecc80b5f'], + + ['com.google.android.gms:play-services-maps:17.0.0', + 'f9e479bc57ff423959c6dd9d08d463c677f440e29d90de795418ea27da6c67fb'], ['com.google.android.gms:play-services-stats:17.0.0', 'e8ae5b40512b71e2258bfacd8cd3da398733aa4cde3b32d056093f832b83a6fe'], - ['com.google.android.gms:play-services-tasks:17.0.0', - '2e6d1738b73647f3fe7a038b9780b97717b3746eae258009197e36e7bf3112a5'], + ['com.google.android.gms:play-services-tasks:17.2.0', + 'a131d126145dfe87de04fa904f9ce91753b2d3273851b7d084666a71255792a8'], + + ['com.google.android.gms:play-services-wallet:18.1.3', + 'e19d1f4650f51ce2202c092cbe174058860b6558cf26c8be37a732eff3ae1864'], ['com.google.android.material:material:1.3.0', 'cbf1e7d69fc236cdadcbd1ec5f6c0a1a41aca6ad1ef7f8481058956270ab1f0a'], diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 4d0b787ff..7411f25e1 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -23,21 +23,20 @@ class StripeApi(private val configuration: Configuration, private val paymentInt data class Success(val paymentIntent: PaymentIntent) : CreatePaymentIntentResult() } - fun createPaymentIntent(price: FiatMoney, description: String? = null): Single = Single.fromCallable { - if (Validation.isAmountTooSmall(price)) { - CreatePaymentIntentResult.AmountIsTooSmall(price) + fun createPaymentIntent(price: FiatMoney, description: String? = null): Single { + @Suppress("CascadeIf") + return if (Validation.isAmountTooSmall(price)) { + Single.just(CreatePaymentIntentResult.AmountIsTooSmall(price)) } else if (Validation.isAmountTooLarge(price)) { - CreatePaymentIntentResult.AmountIsTooLarge(price) + Single.just(CreatePaymentIntentResult.AmountIsTooLarge(price)) } else if (!Validation.supportedCurrencyCodes.contains(price.currency.currencyCode.toUpperCase(Locale.ROOT))) { - CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode) + Single.just(CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode)) } else { - CreatePaymentIntentResult.Success( - paymentIntentFetcher.fetchPaymentIntent( - price, description - ) - ) - } - }.subscribeOn(Schedulers.io()) + paymentIntentFetcher + .fetchPaymentIntent(price, description) + .map { CreatePaymentIntentResult.Success(it) } + }.subscribeOn(Schedulers.io()) + } fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Completable = Completable.fromAction { val paymentMethodId = createPaymentMethod(paymentSource).use { response -> @@ -293,7 +292,7 @@ class StripeApi(private val configuration: Configuration, private val paymentInt fun fetchPaymentIntent( price: FiatMoney, description: String? = null - ): PaymentIntent + ): Single } data class PaymentIntent( diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index ef7396e41..80b951b61 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -6,8 +6,11 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.internal.EmptyResponse; import org.whispersystems.signalservice.internal.ServiceResponse; import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; +import org.whispersystems.signalservice.internal.push.DonationIntentResult; import org.whispersystems.signalservice.internal.push.PushServiceSocket; +import java.io.IOException; + import io.reactivex.rxjava3.core.Scheduler; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -45,4 +48,21 @@ public class DonationsService { } }).subscribeOn(Schedulers.io()); } + + /** + * Submits price information to the server to generate a payment intent via the payment gateway. + * + * @param amount Price, in the minimum currency unit (e.g. cents or yen) + * @param currencyCode The currency code for the amount + * @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway. + */ + public Single> createDonationIntentWithAmount(String amount, String currencyCode) { + return Single.fromCallable(() -> { + try { + return ServiceResponse.forResult(this.pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200, null); + } catch (IOException e) { + return ServiceResponse.forUnknownError(e); + } + }).subscribeOn(Schedulers.io()); + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationIntentPayload.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationIntentPayload.java new file mode 100644 index 000000000..fe9da02b1 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationIntentPayload.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +class DonationIntentPayload { + @JsonProperty + private long amount; + + @JsonProperty + private String currency; + + public DonationIntentPayload(long amount, String currency) { + this.amount = amount; + this.currency = currency; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationIntentResult.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationIntentResult.java new file mode 100644 index 000000000..d9b47a950 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationIntentResult.java @@ -0,0 +1,24 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class DonationIntentResult { + @JsonProperty("id") + private String id; + + @JsonProperty("client_secret") + private String clientSecret; + + public DonationIntentResult(@JsonProperty("id") String id, @JsonProperty("client_secret") String clientSecret) { + this.id = id; + this.clientSecret = clientSecret; + } + + public String getId() { + return id; + } + + public String getClientSecret() { + return clientSecret; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 1463d2685..51fb9008e 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -230,9 +230,11 @@ public class PushServiceSocket { private static final String PAYMENTS_CONVERSIONS = "/v1/payments/conversions"; + private static final String SUBMIT_RATE_LIMIT_CHALLENGE = "/v1/challenge"; private static final String REQUEST_RATE_LIMIT_PUSH_CHALLENGE = "/v1/challenge/push"; + private static final String DONATION_INTENT = "/v1/donation/authorize-apple-pay"; private static final String DONATION_REDEEM_RECEIPT = "/v1/donation/redeem-receipt"; private static final String REPORT_SPAM = "/v1/messages/report/%s/%s"; @@ -869,6 +871,15 @@ public class PushServiceSocket { makeServiceRequest(DONATION_REDEEM_RECEIPT, "PUT", payload); } + /** + * @return The PaymentIntent id + */ + public DonationIntentResult createDonationIntentWithAmount(String amount, String currencyCode) throws IOException { + String payload = JsonUtil.toJson(new DonationIntentPayload(Long.parseLong(amount), currencyCode.toLowerCase(Locale.ROOT))); + String result = makeServiceRequest(DONATION_INTENT, "POST", payload); + return JsonUtil.fromJsonResponse(result, DonationIntentResult.class); + } + public List retrieveDirectory(Set contactTokens) throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException {