diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5a60e54188..54fbf94a9d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -417,6 +417,11 @@
android:windowSoftInputMode="stateAlwaysHidden">
+
+
().onMakeAMonthlyDonation()
+ },
+ onNotNow = {
+ dismissAllowingStateLoss()
+ }
+ )
+ }.toMappingModelList()
+ )
+ }
+
+ interface Callback {
+ fun onMakeAMonthlyDonation()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/ExpiredGiftSheetConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/ExpiredGiftSheetConfiguration.kt
new file mode 100644
index 0000000000..cfa5e23651
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/ExpiredGiftSheetConfiguration.kt
@@ -0,0 +1,81 @@
+package org.thoughtcrime.securesms.badges.gifts
+
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
+import org.thoughtcrime.securesms.components.settings.DSLConfiguration
+import org.thoughtcrime.securesms.components.settings.DSLSettingsText
+import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
+
+/**
+ * Contains shared DSL layout for expired gifts, creatable using a GiftBadge or a Badge.
+ */
+object ExpiredGiftSheetConfiguration {
+ fun register(mappingAdapter: MappingAdapter) {
+ BadgeDisplay112.register(mappingAdapter)
+ }
+
+ fun DSLConfiguration.forExpiredBadge(badge: Badge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
+ customPref(BadgeDisplay112.Model(badge, withDisplayText = false))
+ expiredSheet(onMakeAMonthlyDonation, onNotNow)
+ }
+
+ fun DSLConfiguration.forExpiredGiftBadge(giftBadge: GiftBadge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
+ customPref(BadgeDisplay112.GiftModel(giftBadge))
+ expiredSheet(onMakeAMonthlyDonation, onNotNow)
+ }
+
+ private fun DSLConfiguration.expiredSheet(onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
+ textPref(
+ title = DSLSettingsText.from(
+ stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired,
+ DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
+ )
+ )
+
+ textPref(
+ title = DSLSettingsText.from(
+ stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired_and_is,
+ DSLSettingsText.CenterModifier
+ )
+ )
+
+ if (SignalStore.donationsValues().isLikelyASustainer()) {
+ primaryButton(
+ text = DSLSettingsText.from(
+ stringId = android.R.string.ok
+ ),
+ onClick = {
+ onNotNow()
+ }
+ )
+ } else {
+ textPref(
+ title = DSLSettingsText.from(
+ stringId = R.string.ExpiredGiftSheetConfiguration__to_continue,
+ DSLSettingsText.CenterModifier
+ )
+ )
+
+ primaryButton(
+ text = DSLSettingsText.from(
+ stringId = R.string.ExpiredGiftSheetConfiguration__make_a_monthly_donation
+ ),
+ onClick = {
+ onMakeAMonthlyDonation()
+ }
+ )
+
+ secondaryButtonNoOutline(
+ text = DSLSettingsText.from(
+ stringId = R.string.ExpiredGiftSheetConfiguration__not_now
+ ),
+ onClick = {
+ onNotNow()
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/GiftMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/GiftMessageView.kt
new file mode 100644
index 0000000000..4177e264af
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/GiftMessageView.kt
@@ -0,0 +1,110 @@
+package org.thoughtcrime.securesms.badges.gifts
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.util.AttributeSet
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.use
+import androidx.swiperefreshlayout.widget.CircularProgressDrawable
+import com.google.android.material.button.MaterialButton
+import org.signal.core.util.DimensionUnit
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.badges.BadgeImageView
+import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
+import org.thoughtcrime.securesms.mms.GlideRequests
+
+/**
+ * Displays a gift badge sent to or received from a user, and allows the user to
+ * perform an action based off the badge's redemption state.
+ */
+class GiftMessageView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : FrameLayout(context, attrs) {
+ init {
+ inflate(context, R.layout.gift_message_view, this)
+ }
+
+ private val badgeView: BadgeImageView = findViewById(R.id.gift_message_view_badge)
+ private val titleView: TextView = findViewById(R.id.gift_message_view_title)
+ private val descriptionView: TextView = findViewById(R.id.gift_message_view_description)
+ private val actionView: MaterialButton = findViewById(R.id.gift_message_view_action)
+
+ init {
+ context.obtainStyledAttributes(attrs, R.styleable.GiftMessageView).use {
+ val textColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__textColor, Color.RED)
+ titleView.setTextColor(textColor)
+ descriptionView.setTextColor(textColor)
+
+ val buttonTextColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonTextColor, Color.RED)
+ actionView.setTextColor(buttonTextColor)
+ actionView.iconTint = ColorStateList.valueOf(buttonTextColor)
+
+ val buttonBackgroundTint = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonBackgroundTint, Color.RED)
+ actionView.backgroundTintList = ColorStateList.valueOf(buttonBackgroundTint)
+ }
+ }
+
+ fun setGiftBadge(glideRequests: GlideRequests, giftBadge: GiftBadge, isOutgoing: Boolean, callback: Callback) {
+ titleView.setText(R.string.GiftMessageView__gift_badge)
+ descriptionView.text = resources.getQuantityString(R.plurals.GiftMessageView__lasts_for_d_months, 1, 1)
+ actionView.icon = null
+ actionView.setOnClickListener { callback.onViewGiftBadgeClicked() }
+ actionView.isEnabled = true
+
+ if (isOutgoing) {
+ actionView.setText(R.string.GiftMessageView__view)
+ } else {
+ when (giftBadge.redemptionState) {
+ GiftBadge.RedemptionState.REDEEMED -> {
+ stopAnimationIfNeeded()
+ actionView.setIconResource(R.drawable.ic_check_circle_24)
+ }
+ GiftBadge.RedemptionState.STARTED -> actionView.icon = CircularProgressDrawable(context).apply {
+ actionView.isEnabled = false
+ setColorSchemeColors(ContextCompat.getColor(context, R.color.core_ultramarine))
+ strokeWidth = DimensionUnit.DP.toPixels(2f)
+ start()
+ }
+ else -> {
+ stopAnimationIfNeeded()
+ actionView.icon = null
+ }
+ }
+
+ actionView.setText(
+ when (giftBadge.redemptionState ?: GiftBadge.RedemptionState.UNRECOGNIZED) {
+ GiftBadge.RedemptionState.PENDING -> R.string.GiftMessageView__redeem
+ GiftBadge.RedemptionState.STARTED -> R.string.GiftMessageView__redeeming
+ GiftBadge.RedemptionState.REDEEMED -> R.string.GiftMessageView__redeemed
+ GiftBadge.RedemptionState.FAILED -> R.string.GiftMessageView__redeem
+ GiftBadge.RedemptionState.UNRECOGNIZED -> R.string.GiftMessageView__redeem
+ }
+ )
+ }
+
+ badgeView.setGiftBadge(giftBadge, glideRequests)
+ }
+
+ fun onGiftNotOpened() {
+ actionView.isClickable = false
+ }
+
+ fun onGiftOpened() {
+ actionView.isClickable = true
+ }
+
+ private fun stopAnimationIfNeeded() {
+ val icon = actionView.icon
+ if (icon is CircularProgressDrawable) {
+ icon.stop()
+ }
+ }
+
+ interface Callback {
+ fun onViewGiftBadgeClicked()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/Gifts.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/Gifts.kt
new file mode 100644
index 0000000000..938032d698
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/Gifts.kt
@@ -0,0 +1,48 @@
+package org.thoughtcrime.securesms.badges.gifts
+
+import org.thoughtcrime.securesms.database.ThreadDatabase
+import org.thoughtcrime.securesms.database.model.StoryType
+import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
+import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
+import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.util.Base64
+
+/**
+ * Helper object for Gift badges
+ */
+object Gifts {
+
+ /**
+ * Request Code for getting token from Google Pay
+ */
+ const val GOOGLE_PAY_REQUEST_CODE = 3000
+
+ /**
+ * Creates an OutgoingSecureMediaMessage which contains the given gift badge.
+ */
+ fun createOutgoingGiftMessage(
+ recipient: Recipient,
+ giftBadge: GiftBadge,
+ sentTimestamp: Long,
+ expiresIn: Long
+ ): OutgoingMediaMessage {
+ return OutgoingSecureMediaMessage(
+ recipient,
+ Base64.encodeBytes(giftBadge.toByteArray()),
+ listOf(),
+ sentTimestamp,
+ ThreadDatabase.DistributionTypes.CONVERSATION,
+ expiresIn,
+ false,
+ StoryType.NONE,
+ null,
+ false,
+ null,
+ listOf(),
+ listOf(),
+ listOf(),
+ giftBadge
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt
new file mode 100644
index 0000000000..4ba6e129cf
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt
@@ -0,0 +1,28 @@
+package org.thoughtcrime.securesms.badges.gifts
+
+import org.thoughtcrime.securesms.util.Projection
+
+/**
+ * Notes that a given item can have a gift box drawn over it.
+ */
+interface OpenableGift {
+ /**
+ * Returns a projection to draw a top, or null to not do so.
+ */
+ fun getOpenableGiftProjection(): Projection?
+
+ /**
+ * Returns a unique id assosicated with this gift.
+ */
+ fun getGiftId(): Long
+
+ /**
+ * Registers a callback to start the open animation
+ */
+ fun setOpenGiftCallback(openGift: (OpenableGift) -> Unit)
+
+ /**
+ * Clears any callback created to start the open animation
+ */
+ fun clearOpenGiftCallback()
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt
new file mode 100644
index 0000000000..ef4a408dc9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt
@@ -0,0 +1,237 @@
+package org.thoughtcrime.securesms.badges.gifts
+
+import android.animation.FloatEvaluator
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.provider.Settings
+import android.view.animation.AccelerateDecelerateInterpolator
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.withSave
+import androidx.core.graphics.withTranslation
+import androidx.core.view.children
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.RecyclerView
+import org.signal.core.util.DimensionUnit
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.util.Projection
+import kotlin.math.PI
+import kotlin.math.max
+import kotlin.math.sin
+
+/**
+ * Controls the gift box top and related animations for Gift bubbles.
+ */
+class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver {
+
+ private val animatorDurationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f)
+ private val messageIdsShakenThisSession = mutableSetOf()
+ private val messageIdsOpenedThisSession = mutableSetOf()
+ private val animationState = mutableMapOf()
+
+ private val rect = RectF()
+ private val lineWidth = DimensionUnit.DP.toPixels(24f).toInt()
+
+ private val boxPaint = Paint().apply {
+ isAntiAlias = true
+ color = ContextCompat.getColor(context, R.color.core_ultramarine)
+ }
+
+ private val ribbonPaint = Paint().apply {
+ isAntiAlias = true
+ color = Color.WHITE
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ super.onDestroy(owner)
+ animationState.clear()
+ }
+
+ override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
+ var needsInvalidation = false
+ val openableChildren = parent.children.filterIsInstance(OpenableGift::class.java)
+
+ val deadKeys = animationState.keys.filterNot { giftId -> openableChildren.any { it.getGiftId() == giftId } }
+ deadKeys.forEach {
+ animationState.remove(it)
+ }
+
+ val notAnimated = openableChildren.filterNot { animationState.containsKey(it.getGiftId()) }
+
+ notAnimated.filterNot { messageIdsOpenedThisSession.contains(it.getGiftId()) }.forEach { child ->
+ val projection = child.getOpenableGiftProjection()
+ if (projection != null) {
+ if (messageIdsShakenThisSession.contains(child.getGiftId())) {
+ child.setOpenGiftCallback {
+ child.clearOpenGiftCallback()
+ val proj = it.getOpenableGiftProjection()
+ if (proj != null) {
+ messageIdsOpenedThisSession.add(it.getGiftId())
+ startOpenAnimation(it)
+ parent.invalidate()
+ }
+ }
+
+ drawGiftBox(c, projection)
+ drawGiftBow(c, projection)
+ } else {
+ messageIdsShakenThisSession.add(child.getGiftId())
+ startShakeAnimation(child)
+
+ drawGiftBox(c, projection)
+ drawGiftBow(c, projection)
+
+ needsInvalidation = true
+ }
+
+ projection.release()
+ }
+ }
+
+ openableChildren.filter { animationState.containsKey(it.getGiftId()) }.forEach { child ->
+ val runningAnimation = animationState[child.getGiftId()]!!
+ c.withSave {
+ val isThisAnimationRunning = runningAnimation.update(
+ animatorDurationScale = animatorDurationScale,
+ canvas = c,
+ drawBox = this@OpenableGiftItemDecoration::drawGiftBox,
+ drawBow = this@OpenableGiftItemDecoration::drawGiftBow
+ )
+
+ if (!isThisAnimationRunning) {
+ animationState.remove(child.getGiftId())
+ }
+
+ needsInvalidation = true
+ }
+ }
+
+ if (needsInvalidation) {
+ parent.invalidate()
+ }
+ }
+
+ private fun drawGiftBox(canvas: Canvas, projection: Projection) {
+ canvas.drawPath(projection.path, boxPaint)
+
+ rect.set(
+ projection.x + (projection.width / 2) - lineWidth / 2,
+ projection.y,
+ projection.x + (projection.width / 2) + lineWidth / 2,
+ projection.y + projection.height
+ )
+
+ canvas.drawRect(rect, ribbonPaint)
+
+ rect.set(
+ projection.x,
+ projection.y + (projection.height / 2) - lineWidth / 2,
+ projection.x + projection.width,
+ projection.y + (projection.height / 2) + lineWidth / 2
+ )
+
+ canvas.drawRect(rect, ribbonPaint)
+ }
+
+ private fun drawGiftBow(canvas: Canvas, projection: Projection) {
+ rect.set(
+ projection.x + (projection.width / 2) - lineWidth,
+ projection.y + (projection.height / 2) - lineWidth,
+ projection.x + (projection.width / 2) + lineWidth,
+ projection.y + (projection.height / 2) + lineWidth
+ )
+
+ canvas.drawRect(rect, ribbonPaint)
+ }
+
+ private fun startShakeAnimation(child: OpenableGift) {
+ animationState[child.getGiftId()] = GiftAnimationState.ShakeAnimationState(child, System.currentTimeMillis())
+ }
+
+ private fun startOpenAnimation(child: OpenableGift) {
+ animationState[child.getGiftId()] = GiftAnimationState.OpenAnimationState(child, System.currentTimeMillis())
+ }
+
+ sealed class GiftAnimationState(val openableGift: OpenableGift, val startTime: Long) {
+
+ /**
+ * Shakes the gift box to the left and right, slightly revealing the contents underneath.
+ * Uses a lag value to keep the bow one "frame" behind the box, to give it the effect of
+ * following behind.
+ */
+ class ShakeAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime) {
+ override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
+ canvas.withTranslation(x = getTranslation(progress).toFloat()) {
+ drawBox(canvas, projection)
+ }
+
+ canvas.withTranslation(x = getTranslation(lastFrameProgress).toFloat()) {
+ drawBow(canvas, projection)
+ }
+ }
+
+ private fun getTranslation(progress: Float): Double {
+ val interpolated = INTERPOLATOR.getInterpolation(progress)
+ val evaluated = EVALUATOR.evaluate(interpolated, 0f, 360f)
+
+ return 0.25f * sin(4 * evaluated * PI / 180f) * 180f / PI
+ }
+ }
+
+ class OpenAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime) {
+ override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
+ val interpolatedProgress = INTERPOLATOR.getInterpolation(progress)
+ val evaluatedValue = EVALUATOR.evaluate(interpolatedProgress, 0f, DimensionUnit.DP.toPixels(300f))
+
+ canvas.translate(evaluatedValue, -evaluatedValue)
+
+ drawBox(canvas, projection)
+ drawBow(canvas, projection)
+ }
+ }
+
+ fun update(animatorDurationScale: Float, canvas: Canvas, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit): Boolean {
+ val projection = openableGift.getOpenableGiftProjection() ?: return false
+
+ if (animatorDurationScale <= 0f) {
+ update(canvas, projection, 0f, 0f, drawBox, drawBow)
+ projection.release()
+ return false
+ }
+
+ val currentFrameTime = System.currentTimeMillis()
+ val lastFrameProgress = max(0f, (currentFrameTime - startTime - ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS) / (DURATION_MILLIS.toFloat() * animatorDurationScale))
+ val progress = (currentFrameTime - startTime) / (DURATION_MILLIS.toFloat() * animatorDurationScale)
+
+ if (progress > 1f) {
+ update(canvas, projection, 1f, 1f, drawBox, drawBow)
+ projection.release()
+ return false
+ }
+
+ update(canvas, projection, progress, lastFrameProgress, drawBox, drawBow)
+ projection.release()
+ return true
+ }
+
+ protected abstract fun update(
+ canvas: Canvas,
+ projection: Projection,
+ progress: Float,
+ lastFrameProgress: Float,
+ drawBox: (Canvas, Projection) -> Unit,
+ drawBow: (Canvas, Projection) -> Unit
+ )
+ }
+
+ companion object {
+ private val INTERPOLATOR = AccelerateDecelerateInterpolator()
+ private val EVALUATOR = FloatEvaluator()
+
+ private const val DURATION_MILLIS = 1000
+ private const val ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS = 33
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/Gift.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/Gift.kt
new file mode 100644
index 0000000000..ec5a13e75d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/Gift.kt
@@ -0,0 +1,8 @@
+package org.thoughtcrime.securesms.badges.gifts.flow
+
+import org.signal.core.util.money.FiatMoney
+
+/**
+ * Convenience wrapper for a gift at a particular price point.
+ */
+data class Gift(val level: Long, val price: FiatMoney)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowActivity.kt
new file mode 100644
index 0000000000..163f413740
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowActivity.kt
@@ -0,0 +1,46 @@
+package org.thoughtcrime.securesms.badges.gifts.flow
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.OnBackPressedCallback
+import androidx.fragment.app.Fragment
+import androidx.navigation.findNavController
+import androidx.navigation.fragment.NavHostFragment
+import io.reactivex.rxjava3.subjects.PublishSubject
+import io.reactivex.rxjava3.subjects.Subject
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.FragmentWrapperActivity
+import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
+import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
+
+/**
+ * Activity which houses the gift flow.
+ */
+class GiftFlowActivity : FragmentWrapperActivity(), DonationPaymentComponent {
+
+ override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
+
+ override val googlePayResultPublisher: Subject = PublishSubject.create()
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ super.onCreate(savedInstanceState, ready)
+ onBackPressedDispatcher.addCallback(this, OnBackPressed())
+ }
+
+ override fun getFragment(): Fragment {
+ return NavHostFragment.create(R.navigation.gift_flow)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ googlePayResultPublisher.onNext(DonationPaymentComponent.GooglePayResult(requestCode, resultCode, data))
+ }
+
+ private inner class OnBackPressed : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (!findNavController(R.id.fragment_container).popBackStack()) {
+ finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt
new file mode 100644
index 0000000000..e7de159e3b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt
@@ -0,0 +1,254 @@
+package org.thoughtcrime.securesms.badges.gifts.flow
+
+import android.content.DialogInterface
+import android.view.KeyEvent
+import android.widget.FrameLayout
+import android.widget.ImageView
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.subjects.PublishSubject
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.MainActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.InputAwareLayout
+import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
+import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
+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.DSLSettingsText
+import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
+import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
+import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
+import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
+import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
+import org.thoughtcrime.securesms.components.settings.configure
+import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
+import org.thoughtcrime.securesms.components.settings.models.TextInput
+import org.thoughtcrime.securesms.conversation.ConversationIntents
+import org.thoughtcrime.securesms.keyboard.KeyboardPage
+import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
+import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
+import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
+import org.thoughtcrime.securesms.util.LifecycleDisposable
+import org.thoughtcrime.securesms.util.fragments.requireListener
+
+/**
+ * Allows the user to confirm details about a gift, add a message, and finally make a payment.
+ */
+class GiftFlowConfirmationFragment :
+ DSLSettingsFragment(
+ titleId = R.string.GiftFlowConfirmationFragment__confirm_gift,
+ layoutId = R.layout.gift_flow_confirmation_fragment
+ ),
+ EmojiKeyboardPageFragment.Callback,
+ EmojiEventListener,
+ EmojiSearchFragment.Callback {
+
+ companion object {
+ private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
+ }
+
+ private val viewModel: GiftFlowViewModel by viewModels(
+ ownerProducer = { requireActivity() }
+ )
+
+ private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels(
+ ownerProducer = { requireActivity() }
+ )
+
+ private lateinit var inputAwareLayout: InputAwareLayout
+ private lateinit var emojiKeyboard: MediaKeyboard
+
+ private val lifecycleDisposable = LifecycleDisposable()
+ private var errorDialog: DialogInterface? = null
+ private lateinit var processingDonationPaymentDialog: AlertDialog
+ private lateinit var donationPaymentComponent: DonationPaymentComponent
+ private lateinit var textInputViewHolder: TextInput.MultilineViewHolder
+
+ private val eventPublisher = PublishSubject.create()
+
+ override fun bindAdapter(adapter: DSLSettingsAdapter) {
+ RecipientPreference.register(adapter)
+ GiftRowItem.register(adapter)
+
+ keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
+
+ donationPaymentComponent = requireListener()
+
+ processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
+ .setView(R.layout.processing_payment_dialog)
+ .setCancelable(false)
+ .create()
+
+ inputAwareLayout = requireView().findViewById(R.id.input_aware_layout)
+ emojiKeyboard = requireView().findViewById(R.id.emoji_drawer)
+
+ emojiKeyboard.setFragmentManager(childFragmentManager)
+
+ val googlePayButton = requireView().findViewById(R.id.google_pay_button)
+ googlePayButton.setOnGooglePayClickListener {
+ viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time))
+ }
+
+ val textInput = requireView().findViewById(R.id.text_input)
+ val emojiToggle = textInput.findViewById(R.id.emoji_toggle)
+ textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher)
+ textInputViewHolder.onAttachedToWindow()
+
+ inputAwareLayout.addOnKeyboardShownListener {
+ inputAwareLayout.hideAttachedInput(true)
+ emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
+ }
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (inputAwareLayout.isInputOpen) {
+ inputAwareLayout.hideAttachedInput(true)
+ emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
+ } else {
+ findNavController().popBackStack()
+ }
+ }
+ }
+ )
+
+ lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
+ adapter.submitList(getConfiguration(state).toMappingModelList())
+
+ if (state.stage == GiftFlowState.Stage.PAYMENT_PIPELINE) {
+ processingDonationPaymentDialog.show()
+ } else {
+ processingDonationPaymentDialog.hide()
+ }
+
+ textInputViewHolder.bind(
+ TextInput.MultilineModel(
+ text = state.additionalMessage,
+ hint = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__add_a_message),
+ onTextChanged = {
+ viewModel.setAdditionalMessage(it)
+ },
+ onEmojiToggleClicked = {
+ if (inputAwareLayout.isKeyboardOpen || (!inputAwareLayout.isKeyboardOpen && !inputAwareLayout.isInputOpen)) {
+ inputAwareLayout.show(it, emojiKeyboard)
+ emojiToggle.setImageResource(R.drawable.ic_keyboard_24)
+ } else {
+ inputAwareLayout.showSoftkey(it)
+ emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
+ }
+ }
+ )
+ )
+ }
+
+ lifecycleDisposable.bindTo(viewLifecycleOwner)
+
+ lifecycleDisposable += DonationError
+ .getErrorsForSource(DonationErrorSource.GIFT)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { donationError ->
+ onPaymentError(donationError)
+ }
+
+ lifecycleDisposable += viewModel.events.observeOn(AndroidSchedulers.mainThread()).subscribe { donationEvent ->
+ when (donationEvent) {
+ is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed()
+ DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
+ DonationEvent.SubscriptionCancelled -> Unit
+ is DonationEvent.SubscriptionCancellationFailed -> Unit
+ }
+ }
+
+ lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
+ viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ textInputViewHolder.onDetachedFromWindow()
+ processingDonationPaymentDialog.dismiss()
+ }
+
+ private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration {
+ return configure {
+ if (giftFlowState.giftBadge != null) {
+ giftFlowState.giftPrices[giftFlowState.currency]?.let {
+ customPref(
+ GiftRowItem.Model(
+ giftBadge = giftFlowState.giftBadge,
+ price = it
+ )
+ )
+ }
+ }
+
+ sectionHeaderPref(R.string.GiftFlowConfirmationFragment__send_to)
+
+ customPref(
+ RecipientPreference.Model(
+ recipient = giftFlowState.recipient!!
+ )
+ )
+
+ textPref(
+ summary = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__your_gift_will_be_sent_in)
+ )
+ }
+ }
+
+ private fun onPaymentConfirmed() {
+ val mainActivityIntent = MainActivity.clearTop(requireContext())
+ val conversationIntent = ConversationIntents
+ .createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L)
+ .withGiftBadge(viewModel.snapshot.giftBadge!!)
+ .build()
+
+ requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent))
+ }
+
+ private fun onPaymentError(throwable: Throwable?) {
+ Log.w(TAG, "onPaymentError", throwable, true)
+
+ if (errorDialog != null) {
+ Log.i(TAG, "Already displaying an error dialog. Skipping.")
+ return
+ }
+
+ errorDialog = DonationErrorDialogs.show(
+ requireContext(), throwable,
+ object : DonationErrorDialogs.DialogCallback() {
+ override fun onDialogDismissed() {
+ requireActivity().finish()
+ }
+ }
+ )
+ }
+
+ override fun openEmojiSearch() {
+ emojiKeyboard.onOpenEmojiSearch()
+ }
+
+ override fun closeEmojiSearch() {
+ emojiKeyboard.onCloseEmojiSearch()
+ }
+
+ override fun onEmojiSelected(emoji: String?) {
+ if (emoji?.isNotEmpty() == true) {
+ eventPublisher.onNext(TextInput.TextInputEvent.OnEmojiEvent(emoji))
+ }
+ }
+
+ override fun onKeyEvent(keyEvent: KeyEvent?) {
+ if (keyEvent != null) {
+ eventPublisher.onNext(TextInput.TextInputEvent.OnKeyEvent(keyEvent))
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRecipientSelectionFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRecipientSelectionFragment.kt
new file mode 100644
index 0000000000..0c8dfb1145
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRecipientSelectionFragment.kt
@@ -0,0 +1,93 @@
+package org.thoughtcrime.securesms.badges.gifts.flow
+
+import android.graphics.Color
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.widget.Toolbar
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
+import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
+import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
+import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
+import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
+import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * Allows the user to select a recipient to send a gift to.
+ */
+class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
+
+ private val viewModel: GiftFlowViewModel by viewModels(
+ ownerProducer = { requireActivity() }
+ )
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val toolbar = view.findViewById(R.id.toolbar)
+ toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
+
+ if (savedInstanceState == null) {
+ childFragmentManager.beginTransaction()
+ .replace(
+ R.id.multiselect_container,
+ MultiselectForwardFragment.create(
+ MultiselectForwardFragmentArgs(
+ canSendToNonPush = false,
+ multiShareArgs = emptyList(),
+ forceDisableAddMessage = true,
+ selectSingleRecipient = true
+ )
+ )
+ )
+ .commit()
+ }
+ }
+
+ override fun getSearchConfiguration(fragmentManager: FragmentManager, contactSearchState: ContactSearchState): ContactSearchConfiguration {
+ return ContactSearchConfiguration.build {
+ query = contactSearchState.query
+
+ if (query.isNullOrEmpty()) {
+ addSection(
+ ContactSearchConfiguration.Section.Recents(
+ includeHeader = true,
+ mode = ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS
+ )
+ )
+ }
+
+ addSection(
+ ContactSearchConfiguration.Section.Individuals(
+ includeSelf = false,
+ transportType = ContactSearchConfiguration.TransportType.PUSH,
+ includeHeader = true
+ )
+ )
+ }
+ }
+
+ override fun onFinishForwardAction() = Unit
+
+ override fun exitFlow() = Unit
+
+ override fun onSearchInputFocused() = Unit
+
+ override fun setResult(bundle: Bundle) {
+ val parcelableContacts: List = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
+ val contacts = parcelableContacts.map { it.asRecipientSearchKey() }
+
+ if (contacts.isNotEmpty()) {
+ viewModel.setSelectedContact(contacts.first())
+ findNavController().safeNavigate(R.id.action_giftFlowRecipientSelectionFragment_to_giftFlowConfirmationFragment)
+ }
+ }
+
+ override fun getContainer(): ViewGroup = requireView() as ViewGroup
+
+ override fun getDialogBackgroundColor(): Int = Color.TRANSPARENT
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt
new file mode 100644
index 0000000000..e5a69dc109
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt
@@ -0,0 +1,41 @@
+package org.thoughtcrime.securesms.badges.gifts.flow
+
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.signal.core.util.money.FiatMoney
+import org.thoughtcrime.securesms.badges.Badges
+import org.thoughtcrime.securesms.badges.models.Badge
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
+import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
+import org.whispersystems.signalservice.internal.ServiceResponse
+import java.util.Currency
+import java.util.Locale
+
+/**
+ * Repository for grabbing gift badges and supported currency information.
+ */
+class GiftFlowRepository {
+
+ fun getGiftBadge(): Single> {
+ return ApplicationDependencies.getDonationsService()
+ .getGiftBadges(Locale.getDefault())
+ .flatMap(ServiceResponse