mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 09:49:30 +01:00
Implement badge gifting behind feature flag.
This commit is contained in:
committed by
Greyson Parrelli
parent
5d16d1cd23
commit
a4a4665aaa
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user