Implement badge gifting behind feature flag.

This commit is contained in:
Alex Hart
2022-05-02 14:29:42 -03:00
committed by Greyson Parrelli
parent 5d16d1cd23
commit a4a4665aaa
164 changed files with 4999 additions and 486 deletions

View File

@@ -8,9 +8,12 @@ import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.glide.GiftBadgeModel
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ScreenDensity
import org.thoughtcrime.securesms.util.ThemeUtil
class BadgeImageView @JvmOverloads constructor(
@@ -77,6 +80,22 @@ class BadgeImageView @JvmOverloads constructor(
}
}
fun setGiftBadge(badge: GiftBadge?, glideRequests: GlideRequests) {
if (badge != null) {
glideRequests
.load(GiftBadgeModel(badge))
.downsample(DownsampleStrategy.NONE)
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), ScreenDensity.getBestDensityBucketForDevice(), ThemeUtil.isDarkTheme(context)))
.into(this)
isClickable = true
} else {
glideRequests
.clear(this)
clearDrawable()
}
}
private fun clearDrawable() {
setImageDrawable(null)
isClickable = false

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.badges.gifts
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredBadge
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
/**
* Displays expired gift information and gives the user the option to start a recurring monthly donation.
*/
class ExpiredGiftSheet : DSLSettingsBottomSheetFragment() {
companion object {
private const val ARG_BADGE = "arg.badge"
fun show(fragmentManager: FragmentManager, badge: Badge) {
ExpiredGiftSheet().apply {
arguments = Bundle().apply {
putParcelable(ARG_BADGE, badge)
}
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
private val badge: Badge
get() = requireArguments().getParcelable(ARG_BADGE)!!
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredGiftSheetConfiguration.register(adapter)
adapter.submitList(
configure {
forExpiredBadge(
badge = badge,
onMakeAMonthlyDonation = {
requireListener<Callback>().onMakeAMonthlyDonation()
},
onNotNow = {
dismissAllowingStateLoss()
}
)
}.toMappingModelList()
)
}
interface Callback {
fun onMakeAMonthlyDonation()
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Long>()
private val messageIdsOpenedThisSession = mutableSetOf<Long>()
private val animationState = mutableMapOf<Long, GiftAnimationState>()
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
}
}

View File

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

View File

@@ -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<DonationPaymentComponent.GooglePayResult> = 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()
}
}
}
}

View File

@@ -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<TextInput.TextInputEvent>()
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<GooglePayButton>(R.id.google_pay_button)
googlePayButton.setOnGooglePayClickListener {
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time))
}
val textInput = requireView().findViewById<FrameLayout>(R.id.text_input)
val emojiToggle = textInput.findViewById<ImageView>(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))
}
}
}

View File

@@ -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<Toolbar>(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<ContactSearchKey.ParcelableRecipientSearchKey> = 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
}

View File

@@ -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<Pair<Long, Badge>> {
return ApplicationDependencies.getDonationsService()
.getGiftBadges(Locale.getDefault())
.flatMap(ServiceResponse<Map<Long, SignalServiceProfile.Badge>>::flattenResult)
.map { gifts -> gifts.map { it.key to Badges.fromServiceBadge(it.value) } }
.map { it.first() }
.subscribeOn(Schedulers.io())
}
fun getGiftPricing(): Single<Map<Currency, FiatMoney>> {
return ApplicationDependencies.getDonationsService()
.giftAmount
.subscribeOn(Schedulers.io())
.flatMap { it.flattenResult() }
.map { result ->
result
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
.mapKeys { (code, _) -> Currency.getInstance(code) }
.mapValues { (currency, price) -> FiatMoney(price, currency) }
}
}
}

View File

@@ -0,0 +1,89 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.view.View
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
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.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Landing fragment for sending gifts.
*/
class GiftFlowStartFragment : DSLSettingsFragment(
layoutId = R.layout.gift_flow_start_fragment
) {
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() },
factoryProducer = { GiftFlowViewModel.Factory(GiftFlowRepository(), requireListener<DonationPaymentComponent>().donationPaymentRepository) }
)
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
CurrencySelection.register(adapter)
GiftRowItem.register(adapter)
NetworkFailure.register(adapter)
IndeterminateLoadingCircle.register(adapter)
val next = requireView().findViewById<View>(R.id.next)
next.setOnClickListener {
findNavController().safeNavigate(R.id.action_giftFlowStartFragment_to_giftFlowRecipientSelectionFragment)
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
next.isEnabled = state.stage == GiftFlowState.Stage.READY
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: GiftFlowState): DSLConfiguration {
return configure {
customPref(
CurrencySelection.Model(
selectedCurrency = state.currency,
isEnabled = state.stage == GiftFlowState.Stage.READY,
onClick = {
val action = GiftFlowStartFragmentDirections.actionGiftFlowStartFragmentToSetCurrencyFragment(true, viewModel.getSupportedCurrencyCodes().toTypedArray())
findNavController().safeNavigate(action)
}
)
)
@Suppress("CascadeIf")
if (state.stage == GiftFlowState.Stage.FAILURE) {
customPref(
NetworkFailure.Model(
onRetryClick = {
viewModel.retry()
}
)
)
} else if (state.stage == GiftFlowState.Stage.INIT) {
customPref(IndeterminateLoadingCircle)
} else if (state.giftBadge != null) {
state.giftPrices[state.currency]?.let {
customPref(
GiftRowItem.Model(
giftBadge = state.giftBadge,
price = it
)
)
}
}
}
}
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.Currency
/**
* State maintained by the GiftFlowViewModel
*/
data class GiftFlowState(
val currency: Currency,
val giftLevel: Long? = null,
val giftBadge: Badge? = null,
val giftPrices: Map<Currency, FiatMoney> = emptyMap(),
val stage: Stage = Stage.INIT,
val recipient: Recipient? = null,
val additionalMessage: CharSequence? = null
) {
enum class Stage {
INIT,
READY,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
FAILURE
}
}

View File

@@ -0,0 +1,237 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.badges.gifts.Gifts
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.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.Currency
/**
* Maintains state as a user works their way through the gift flow.
*/
class GiftFlowViewModel(
val repository: GiftFlowRepository,
val donationPaymentRepository: DonationPaymentRepository
) : ViewModel() {
private var giftToPurchase: Gift? = null
private val store = RxStore(
GiftFlowState(
currency = SignalStore.donationsValues().getOneTimeCurrency()
)
)
private val disposables = CompositeDisposable()
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
private val networkDisposable: Disposable
val state: Flowable<GiftFlowState> = store.stateFlowable
val events: Observable<DonationEvent> = eventPublisher
val snapshot: GiftFlowState get() = store.state
init {
refresh()
networkDisposable = InternetConnectionObserver
.observe()
.distinctUntilChanged()
.subscribe { isConnected ->
if (isConnected) {
retry()
}
}
}
fun retry() {
if (!disposables.isDisposed && store.state.stage == GiftFlowState.Stage.FAILURE) {
store.update { it.copy(stage = GiftFlowState.Stage.INIT) }
refresh()
}
}
fun refresh() {
disposables.clear()
disposables += SignalStore.donationsValues().observableOneTimeCurrency.subscribe { currency ->
store.update {
it.copy(
currency = currency
)
}
}
disposables += repository.getGiftPricing().subscribe { giftPrices ->
store.update {
it.copy(
giftPrices = giftPrices,
stage = getLoadState(it, giftPrices = giftPrices)
)
}
}
disposables += repository.getGiftBadge().subscribeBy(
onSuccess = { (giftLevel, giftBadge) ->
store.update {
it.copy(
giftLevel = giftLevel,
giftBadge = giftBadge,
stage = getLoadState(it, giftBadge = giftBadge)
)
}
},
onError = { throwable ->
Log.w(TAG, "Could not load gift badge", throwable)
store.update {
it.copy(
stage = GiftFlowState.Stage.FAILURE
)
}
}
)
}
override fun onCleared() {
disposables.clear()
}
fun setSelectedContact(selectedContact: ContactSearchKey.RecipientSearchKey) {
store.update {
it.copy(recipient = Recipient.resolved(selectedContact.recipientId))
}
}
fun getSupportedCurrencyCodes(): List<String> {
return store.state.giftPrices.keys.map { it.currencyCode }
}
fun requestTokenFromGooglePay(label: String) {
val giftLevel = store.state.giftLevel ?: return
val giftPrice = store.state.giftPrices[store.state.currency] ?: return
this.giftToPurchase = Gift(giftLevel, giftPrice)
donationPaymentRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
val gift = giftToPurchase
giftToPurchase = null
val recipient = store.state.recipient?.id
donationPaymentRepository.onActivityResult(
requestCode, resultCode, data, Gifts.GOOGLE_PAY_REQUEST_CODE,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
if (gift != null && recipient != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.continuePayment(gift.price, paymentData, recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy(
onError = { throwable ->
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
onComplete = {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.giftBadge!!))
}
)
} else {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
}
}
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.GIFT, googlePayException))
}
override fun onCancelled() {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
}
}
)
}
private fun getLoadState(
oldState: GiftFlowState,
giftPrices: Map<Currency, FiatMoney>? = null,
giftBadge: Badge? = null,
): GiftFlowState.Stage {
if (oldState.stage != GiftFlowState.Stage.INIT) {
return oldState.stage
}
if (giftPrices?.isNotEmpty() == true) {
return if (oldState.giftBadge != null) {
GiftFlowState.Stage.READY
} else {
GiftFlowState.Stage.INIT
}
}
if (giftBadge != null) {
return if (oldState.giftPrices.isNotEmpty()) {
GiftFlowState.Stage.READY
} else {
GiftFlowState.Stage.INIT
}
}
return GiftFlowState.Stage.INIT
}
fun setAdditionalMessage(additionalMessage: CharSequence) {
store.update { it.copy(additionalMessage = additionalMessage) }
}
companion object {
private val TAG = Log.tag(GiftFlowViewModel::class.java)
}
class Factory(
private val repository: GiftFlowRepository,
private val donationPaymentRepository: DonationPaymentRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(
GiftFlowViewModel(
repository,
donationPaymentRepository
)
) as T
}
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.view.View
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.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
/**
* A line item for gifts, displayed in the Gift flow's start and confirmation fragments.
*/
object GiftRowItem {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.subscription_preference))
}
class Model(val giftBadge: Badge, val price: FiatMoney) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = giftBadge.id == newItem.giftBadge.id
override fun areContentsTheSame(newItem: Model): Boolean = giftBadge == newItem.giftBadge && price == newItem.price
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badgeView = itemView.findViewById<BadgeImageView>(R.id.badge)
private val titleView = itemView.findViewById<TextView>(R.id.title)
private val checkView = itemView.findViewById<View>(R.id.check)
private val taglineView = itemView.findViewById<TextView>(R.id.tagline)
private val priceView = itemView.findViewById<TextView>(R.id.price)
override fun bind(model: Model) {
checkView.visible = false
badgeView.setBadge(model.giftBadge)
titleView.text = model.giftBadge.name
taglineView.setText(R.string.GiftRowItem__send_a_gift_badge)
priceView.text = FiatMoneyUtil.format(
context.resources,
model.price,
FiatMoneyUtil.formatOptions()
.trimZerosAfterDecimal()
.withDisplayTime(false)
)
}
}
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import org.thoughtcrime.securesms.R
/**
* Wraps the google pay button in a convenient frame layout.
*/
class GooglePayButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
init {
inflate(context, R.layout.donate_with_googlepay_button, this)
}
fun setOnGooglePayClickListener(action: () -> Unit) {
getChildAt(0).setOnClickListener { action() }
}
}

View File

@@ -0,0 +1,78 @@
package org.thoughtcrime.securesms.badges.gifts.thanks
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import org.signal.core.util.DimensionUnit
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.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Displays a "Thank you" message in a conversation when redirected
* there after purchasing and sending a gift badge.
*/
class GiftThanksSheet : DSLSettingsBottomSheetFragment() {
companion object {
private const val ARGS_RECIPIENT_ID = "args.recipient.id"
private const val ARGS_BADGE = "args.badge"
@JvmStatic
fun show(fragmentManager: FragmentManager, recipientId: RecipientId, badge: Badge) {
GiftThanksSheet().apply {
arguments = Bundle().apply {
putParcelable(ARGS_RECIPIENT_ID, recipientId)
putParcelable(ARGS_BADGE, badge)
}
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
private val lifecycleDisposable = LifecycleDisposable()
private val recipientId: RecipientId
get() = requireArguments().getParcelable(ARGS_RECIPIENT_ID)!!
private val badge: Badge
get() = requireArguments().getParcelable(ARGS_BADGE)!!
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgePreview.register(adapter)
lifecycleDisposable += Recipient.observable(recipientId).subscribe {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
}
private fun getConfiguration(recipient: Recipient): DSLConfiguration {
return configure {
textPref(
title = DSLSettingsText.from(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support, DSLSettingsText.Title2BoldModifier, DSLSettingsText.CenterModifier)
)
noPadTextPref(
title = DSLSettingsText.from(getString(R.string.GiftThanksSheet__youve_gifted_a_badge_to_s, recipient.getDisplayName(requireContext())))
)
space(DimensionUnit.DP.toPixels(37f).toInt())
customPref(
BadgePreview.BadgeModel.GiftedBadgeModel(
badge = badge,
recipient = recipient
)
)
space(DimensionUnit.DP.toPixels(60f).toInt())
}
}
}

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.util.Locale
/**
* Shared repository for getting information about a particular gift.
*/
class ViewGiftRepository {
fun getBadge(giftBadge: GiftBadge): Single<Badge> {
val presentation = ReceiptCredentialPresentation(giftBadge.redemptionToken.toByteArray())
return ApplicationDependencies
.getDonationsService()
.getGiftBadge(Locale.getDefault(), presentation.receiptLevel)
.flatMap { it.flattenResult() }
.map { Badges.fromServiceBadge(it) }
.subscribeOn(Schedulers.io())
}
fun getGiftBadge(messageId: Long): Observable<GiftBadge> {
return Observable.create { emitter ->
fun refresh() {
val record = SignalDatabase.mms.getMessageRecord(messageId)
val giftBadge: GiftBadge = (record as MmsMessageRecord).giftBadge!!
emitter.onNext(giftBadge)
}
val messageObserver = DatabaseObserver.MessageObserver {
if (it.mms && messageId == it.id) {
refresh()
}
}
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver)
}
refresh()
}
}
}

View File

@@ -0,0 +1,282 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
import android.content.DialogInterface
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveDataReactiveStreams
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredGiftBadge
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.badges.models.BadgeDisplay160
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.app.AppSettingsActivity
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.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.components.settings.models.OutlinedSwitch
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
import java.util.concurrent.TimeUnit
/**
* Handles all interactions for received gift badges.
*/
class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
companion object {
private val TAG = Log.tag(ViewReceivedGiftBottomSheet::class.java)
private const val ARG_GIFT_BADGE = "arg.gift.badge"
private const val ARG_SENT_FROM = "arg.sent.from"
private const val ARG_MESSAGE_ID = "arg.message.id"
@JvmField
val REQUEST_KEY: String = TAG
const val RESULT_NOT_NOW = "result.not.now"
@JvmStatic
fun show(fragmentManager: FragmentManager, messageRecord: MmsMessageRecord) {
ViewReceivedGiftBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(ARG_SENT_FROM, messageRecord.recipient.id)
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
putLong(ARG_MESSAGE_ID, messageRecord.id)
}
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
private val lifecycleDisposable = LifecycleDisposable()
private val sentFrom: RecipientId
get() = requireArguments().getParcelable(ARG_SENT_FROM)!!
private val messageId: Long
get() = requireArguments().getLong(ARG_MESSAGE_ID)
private val viewModel: ViewReceivedGiftViewModel by viewModels(
factoryProducer = { ViewReceivedGiftViewModel.Factory(sentFrom, messageId, ViewGiftRepository(), BadgeRepository(requireContext())) }
)
private var errorDialog: DialogInterface? = null
private lateinit var progressDialog: AlertDialog
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
OutlinedSwitch.register(adapter)
BadgeDisplay160.register(adapter)
IndeterminateLoadingCircle.register(adapter)
progressDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.redeeming_gift_dialog)
.setCancelable(false)
.create()
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += DonationError
.getErrorsForSource(DonationErrorSource.GIFT_REDEMPTION)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { donationError ->
onRedemptionError(donationError)
}
LiveDataReactiveStreams.fromPublisher(viewModel.state).observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
override fun onDestroy() {
super.onDestroy()
progressDialog.hide()
}
private fun onRedemptionError(throwable: Throwable?) {
Log.w(TAG, "onRedemptionError", 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() {
findNavController().popBackStack()
}
}
)
}
private fun getConfiguration(state: ViewReceivedGiftState): DSLConfiguration {
return configure {
if (state.giftBadge == null) {
customPref(IndeterminateLoadingCircle)
} else if (isGiftBadgeExpired(state.giftBadge)) {
forExpiredGiftBadge(
giftBadge = state.giftBadge,
onMakeAMonthlyDonation = {
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
requireActivity().finish()
},
onNotNow = {
dismissAllowingStateLoss()
}
)
} else {
if (state.giftBadge.redemptionState == GiftBadge.RedemptionState.STARTED) {
progressDialog.show()
} else {
progressDialog.hide()
}
if (state.recipient != null && !isGiftBadgeRedeemed(state.giftBadge)) {
noPadTextPref(
title = DSLSettingsText.from(
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__s_sent_you_a_gift, state.recipient.getShortDisplayName(requireContext())),
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
space(DimensionUnit.DP.toPixels(12f).toInt())
presentSubheading(state.recipient)
space(DimensionUnit.DP.toPixels(37f).toInt())
}
if (state.badge != null && state.controlState != null) {
presentForUnexpiredGiftBadge(state, state.giftBadge, state.controlState, state.badge)
space(DimensionUnit.DP.toPixels(16f).toInt())
}
}
}
}
private fun DSLConfiguration.presentSubheading(recipient: Recipient) {
noPadTextPref(
title = DSLSettingsText.from(
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__youve_received_a_gift_badge, recipient.getDisplayName(requireContext())),
DSLSettingsText.CenterModifier
)
)
}
private fun DSLConfiguration.presentForUnexpiredGiftBadge(
state: ViewReceivedGiftState,
giftBadge: GiftBadge,
controlState: ViewReceivedGiftState.ControlState,
badge: Badge
) {
when (giftBadge.redemptionState) {
GiftBadge.RedemptionState.REDEEMED -> {
customPref(
BadgeDisplay160.Model(
badge = badge
)
)
state.recipient?.run {
presentSubheading(this)
}
}
else -> {
customPref(
BadgeDisplay112.Model(
badge = badge
)
)
customPref(
OutlinedSwitch.Model(
text = DSLSettingsText.from(
when (controlState) {
ViewReceivedGiftState.ControlState.DISPLAY -> R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile
ViewReceivedGiftState.ControlState.FEATURE -> R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge
}
),
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
isChecked = state.getControlChecked(),
onClick = {
viewModel.setChecked(!it.isChecked)
}
)
)
if (state.hasOtherBadges && state.displayingOtherBadges) {
noPadTextPref(DSLSettingsText.from(R.string.ThanksForYourSupportBottomSheetFragment__when_you_have_more))
}
space(DimensionUnit.DP.toPixels(36f).toInt())
primaryButton(
text = DSLSettingsText.from(R.string.ViewReceivedGiftSheet__redeem),
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
onClick = {
lifecycleDisposable += viewModel.redeem().subscribeBy(
onComplete = {
dismissAllowingStateLoss()
},
onError = {
onRedemptionError(it)
}
)
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ViewReceivedGiftSheet__not_now),
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
onClick = {
setFragmentResult(
REQUEST_KEY,
Bundle().apply {
putBoolean(RESULT_NOT_NOW, true)
}
)
dismissAllowingStateLoss()
}
)
}
}
}
private fun isGiftBadgeRedeemed(giftBadge: GiftBadge): Boolean {
return giftBadge.redemptionState == GiftBadge.RedemptionState.REDEEMED
}
private fun isGiftBadgeExpired(giftBadge: GiftBadge): Boolean {
return try {
val receiptCredentialPresentation = ReceiptCredentialPresentation(giftBadge.redemptionToken.toByteArray())
receiptCredentialPresentation.receiptExpirationTime <= TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
} catch (e: InvalidInputException) {
Log.w(TAG, "Failed to check expiration of given badge.", e)
true
}
}
}

View File

@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.Recipient
data class ViewReceivedGiftState(
val recipient: Recipient? = null,
val giftBadge: GiftBadge? = null,
val badge: Badge? = null,
val controlState: ControlState? = null,
val hasOtherBadges: Boolean = false,
val displayingOtherBadges: Boolean = false,
val userCheckSelection: Boolean? = false,
val redemptionState: RedemptionState = RedemptionState.NONE
) {
fun getControlChecked(): Boolean {
return when {
userCheckSelection != null -> userCheckSelection
controlState == ControlState.FEATURE -> false
!displayingOtherBadges -> false
else -> true
}
}
enum class ControlState {
DISPLAY,
FEATURE
}
enum class RedemptionState {
NONE,
IN_PROGRESS
}
}

View File

@@ -0,0 +1,150 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class ViewReceivedGiftViewModel(
sentFrom: RecipientId,
private val messageId: Long,
repository: ViewGiftRepository,
val badgeRepository: BadgeRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(ViewReceivedGiftViewModel::class.java)
}
private val store = RxStore(ViewReceivedGiftState())
private val disposables = CompositeDisposable()
val state: Flowable<ViewReceivedGiftState> = store.stateFlowable
init {
disposables += Recipient.observable(sentFrom).subscribe { recipient ->
store.update { it.copy(recipient = recipient) }
}
disposables += repository.getGiftBadge(messageId).subscribe { giftBadge ->
store.update {
it.copy(giftBadge = giftBadge)
}
}
disposables += repository
.getGiftBadge(messageId)
.firstOrError()
.flatMap { repository.getBadge(it) }
.subscribe { badge ->
val otherBadges = Recipient.self().badges.filterNot { it.id == badge.id }
val hasOtherBadges = otherBadges.isNotEmpty()
val displayingBadges = SignalStore.donationsValues().getDisplayBadgesOnProfile()
val displayingOtherBadges = hasOtherBadges && displayingBadges
store.update {
it.copy(
badge = badge,
hasOtherBadges = hasOtherBadges,
displayingOtherBadges = displayingOtherBadges,
controlState = if (displayingBadges) ViewReceivedGiftState.ControlState.FEATURE else ViewReceivedGiftState.ControlState.DISPLAY
)
}
}
}
override fun onCleared() {
disposables.dispose()
}
fun setChecked(isChecked: Boolean) {
store.update { state ->
state.copy(
userCheckSelection = isChecked
)
}
}
fun redeem(): Completable {
val snapshot = store.state
return if (snapshot.controlState != null && snapshot.badge != null) {
if (snapshot.controlState == ViewReceivedGiftState.ControlState.DISPLAY) {
badgeRepository.setVisibilityForAllBadges(snapshot.getControlChecked()).andThen(awaitRedemptionCompletion(false))
} else if (snapshot.getControlChecked()) {
awaitRedemptionCompletion(true)
} else {
awaitRedemptionCompletion(false)
}
} else {
Completable.error(Exception("Cannot enqueue a redemption without a control state or badge."))
}
}
private fun awaitRedemptionCompletion(setAsPrimary: Boolean): Completable {
return Completable.create {
Log.i(TAG, "Enqueuing gift redemption and awaiting result...", true)
var finalJobState: JobTracker.JobState? = null
val countDownLatch = CountDownLatch(1)
DonationReceiptRedemptionJob.createJobChainForGift(messageId, setAsPrimary).enqueue { _, state ->
if (state.isComplete) {
finalJobState = state
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Gift redemption job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Gift redemption job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT_REDEMPTION))
}
else -> {
Log.w(TAG, "Gift redemption job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
}
}
} else {
Log.w(TAG, "Timeout awaiting for gift token redemption and profile refresh", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
}
} catch (e: InterruptedException) {
Log.w(TAG, "Interrupted awaiting for gift token redemption and profile refresh", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
}
}
}
class Factory(
private val sentFrom: RecipientId,
private val messageId: Long,
private val repository: ViewGiftRepository,
private val badgeRepository: BadgeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(ViewReceivedGiftViewModel(sentFrom, messageId, repository, badgeRepository)) as T
}
}
}

View File

@@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveDataReactiveStreams
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
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 org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* Handles all interactions for received gift badges.
*/
class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
companion object {
private const val ARG_GIFT_BADGE = "arg.gift.badge"
private const val ARG_SENT_TO = "arg.sent.to"
@JvmStatic
fun show(fragmentManager: FragmentManager, messageRecord: MmsMessageRecord) {
ViewSentGiftBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(ARG_SENT_TO, messageRecord.recipient.id)
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
}
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
private val sentTo: RecipientId
get() = requireArguments().getParcelable(ARG_SENT_TO)!!
private val giftBadge: GiftBadge
get() = GiftBadge.parseFrom(requireArguments().getByteArray(ARG_GIFT_BADGE))
private val viewModel: ViewSentGiftViewModel by viewModels(
factoryProducer = { ViewSentGiftViewModel.Factory(sentTo, giftBadge, ViewGiftRepository()) }
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
LiveDataReactiveStreams.fromPublisher(viewModel.state).observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: ViewSentGiftState): DSLConfiguration {
return configure {
noPadTextPref(
title = DSLSettingsText.from(
stringId = R.string.ViewSentGiftBottomSheet__thanks_for_your_support,
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
space(DimensionUnit.DP.toPixels(8f).toInt())
if (state.recipient != null) {
noPadTextPref(
title = DSLSettingsText.from(
charSequence = getString(R.string.ViewSentGiftBottomSheet__youve_gifted_a_badge, state.recipient.getDisplayName(requireContext())),
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(30f).toInt())
}
if (state.badge != null) {
customPref(
BadgeDisplay112.Model(
badge = state.badge
)
)
}
}
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient
data class ViewSentGiftState(
val recipient: Recipient? = null,
val badge: Badge? = null
)

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
class ViewSentGiftViewModel(
sentFrom: RecipientId,
giftBadge: GiftBadge,
repository: ViewGiftRepository
) : ViewModel() {
private val store = RxStore(ViewSentGiftState())
private val disposables = CompositeDisposable()
val state: Flowable<ViewSentGiftState> = store.stateFlowable
init {
disposables += Recipient.observable(sentFrom).subscribe { recipient ->
store.update { it.copy(recipient = recipient) }
}
disposables += repository.getBadge(giftBadge).subscribe { badge ->
store.update {
it.copy(
badge = badge
)
}
}
}
override fun onCleared() {
disposables.dispose()
}
class Factory(
private val sentFrom: RecipientId,
private val giftBadge: GiftBadge,
private val repository: ViewGiftRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(ViewSentGiftViewModel(sentFrom, giftBadge, repository)) as T
}
}
}

View File

@@ -162,6 +162,7 @@ data class Badge(
companion object {
const val BOOST_BADGE_ID = "BOOST"
const val GIFT_BADGE_ID = "GIFT"
private val SELECTION_CHANGED = Any()

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.badges.models
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
/**
* Displays a 112dp badge.
*/
object BadgeDisplay112 {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.badge_display_112))
mappingAdapter.registerFactory(GiftModel::class.java, LayoutFactory(::GiftViewHolder, R.layout.badge_display_112))
}
class Model(val badge: Badge, val withDisplayText: Boolean = true) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = badge.id == newItem.badge.id
override fun areContentsTheSame(newItem: Model): Boolean = badge == newItem.badge && withDisplayText == newItem.withDisplayText
}
class GiftModel(val giftBadge: GiftBadge) : MappingModel<GiftModel> {
override fun areItemsTheSame(newItem: GiftModel): Boolean = giftBadge.redemptionToken == newItem.giftBadge.redemptionToken
override fun areContentsTheSame(newItem: GiftModel): Boolean = giftBadge == newItem.giftBadge
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
private val titleView: TextView = itemView.findViewById(R.id.name)
override fun bind(model: Model) {
titleView.text = model.badge.name
titleView.visible = model.withDisplayText
badgeImageView.setBadge(model.badge)
}
}
class GiftViewHolder(itemView: View) : MappingViewHolder<GiftModel>(itemView) {
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
private val titleView: TextView = itemView.findViewById(R.id.name)
override fun bind(model: GiftModel) {
titleView.visible = false
badgeImageView.setGiftBadge(model.giftBadge, GlideApp.with(badgeImageView))
}
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.badges.models
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* Displays a 160dp badge.
*/
object BadgeDisplay160 {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.badge_display_160))
}
class Model(val badge: Badge) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = badge.id == newItem.badge.id
override fun areContentsTheSame(newItem: Model): Boolean = badge == newItem.badge
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
private val titleView: TextView = itemView.findViewById(R.id.name)
override fun bind(model: Model) {
titleView.text = model.badge.name
badgeImageView.setBadge(model.badge)
}
}
}

View File

@@ -4,48 +4,40 @@ import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object BadgePreview {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
mappingAdapter.registerFactory(SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
mappingAdapter.registerFactory(BadgeModel.FeaturedModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
mappingAdapter.registerFactory(BadgeModel.SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
mappingAdapter.registerFactory(BadgeModel.GiftedBadgeModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.gift_badge_preview_preference))
}
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
sealed class BadgeModel<T : BadgeModel<T>> : MappingModel<T> {
abstract val badge: Badge?
}
abstract val recipient: Recipient
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
data class FeaturedModel(override val badge: Badge?) : BadgeModel<FeaturedModel>() {
override val recipient: Recipient = Recipient.self()
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
override val recipient: Recipient = Recipient.self()
}
override fun getChangePayload(newItem: Model): Any? {
return Unit
}
}
data class GiftedBadgeModel(override val badge: Badge?, override val recipient: Recipient) : BadgeModel<GiftedBadgeModel>()
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
return true
override fun areItemsTheSame(newItem: T): Boolean {
return badge?.id == newItem.badge?.id && recipient.id == newItem.recipient.id
}
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
override fun getChangePayload(newItem: SubscriptionModel): Any? {
return Unit
override fun areContentsTheSame(newItem: T): Boolean {
return badge == newItem.badge && recipient.hasSameContent(newItem.recipient)
}
}
@@ -56,7 +48,7 @@ object BadgePreview {
override fun bind(model: T) {
if (payload.isEmpty()) {
avatar.setRecipient(Recipient.self())
avatar.setRecipient(model.recipient)
avatar.disableQuickContact()
}

View File

@@ -58,7 +58,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
}
val previewView: View = requireView().findViewById(R.id.preview)
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.Model>(previewView)
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.BadgeModel.FeaturedModel>(previewView)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
@@ -79,7 +79,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
hasBoundPreview = true
}
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
previewViewHolder.bind(BadgePreview.BadgeModel.FeaturedModel(state.selectedBadge))
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}

View File

@@ -34,7 +34,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
private fun getConfiguration(state: BecomeASustainerState): DSLConfiguration {
return configure {
customPref(BadgePreview.Model(badge = state.badge))
customPref(BadgePreview.BadgeModel.FeaturedModel(badge = state.badge))
sectionHeaderPref(
title = DSLSettingsText.from(