Implement badge gifting behind feature flag.
This commit is contained in:
parent
5d16d1cd23
commit
a4a4665aaa
164 changed files with 4999 additions and 486 deletions
|
@ -417,6 +417,11 @@
|
|||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
</activity>
|
||||
|
||||
<activity android:name=".badges.gifts.flow.GiftFlowActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
</activity>
|
||||
|
||||
<activity android:name=".wallpaper.ChatWallpaperActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
|
|
|
@ -20,6 +20,6 @@ public final class AppCapabilities {
|
|||
* asking if the user has set a Signal PIN or not.
|
||||
*/
|
||||
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories());
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories(), FeatureFlags.giftBadges());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,5 +103,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
|||
|
||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||
boolean onUrlClicked(@NonNull String url);
|
||||
|
||||
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -45,6 +45,7 @@ class DSLSettingsAdapter : MappingAdapter() {
|
|||
|
||||
abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
protected val iconView: ImageView = itemView.findViewById(R.id.icon)
|
||||
private val iconEndView: ImageView? = itemView.findViewById(R.id.icon_end)
|
||||
protected val titleView: TextView = itemView.findViewById(R.id.title)
|
||||
protected val summaryView: TextView = itemView.findViewById(R.id.summary)
|
||||
|
||||
|
@ -58,6 +59,10 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
|
|||
iconView.setImageDrawable(icon)
|
||||
iconView.visible = icon != null
|
||||
|
||||
val iconEnd = model.iconEnd?.resolve(context)
|
||||
iconEndView?.setImageDrawable(iconEnd)
|
||||
iconEndView?.visible = iconEnd != null
|
||||
|
||||
val title = model.title?.resolve(context)
|
||||
if (title != null) {
|
||||
titleView.text = model.title?.resolve(context)
|
||||
|
|
|
@ -15,9 +15,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
|||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
@ -30,11 +28,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
|||
|
||||
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
|
||||
|
||||
private val viewModel: AppSettingsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
AppSettingsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
private val viewModel: AppSettingsViewModel by viewModels()
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
|
||||
|
@ -48,7 +42,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
|||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refreshActiveSubscription()
|
||||
viewModel.refreshExpiredGiftBadge()
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
|
||||
|
@ -76,13 +70,22 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
|||
}
|
||||
)
|
||||
|
||||
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
|
||||
customPref(
|
||||
PaymentsPreference(
|
||||
unreadCount = state.unreadPaymentsCount
|
||||
) {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
|
||||
}
|
||||
if (FeatureFlags.donorBadges() && PlayServicesUtil.getPlayServicesStatus(requireContext()) == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
iconEnd = if (state.hasExpiredGiftBadge) DSLSettingsIcon.from(R.drawable.ic_info_solid_24, R.color.signal_accent_primary) else null,
|
||||
onClick = {
|
||||
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
|
||||
},
|
||||
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
|
||||
)
|
||||
} else {
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -130,6 +133,18 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
|||
|
||||
dividerPref()
|
||||
|
||||
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
|
||||
customPref(
|
||||
PaymentsPreference(
|
||||
unreadCount = state.unreadPaymentsCount
|
||||
) {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__help),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
||||
|
@ -146,44 +161,6 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
|||
}
|
||||
)
|
||||
|
||||
if (FeatureFlags.donorBadges() && PlayServicesUtil.getPlayServicesStatus(requireContext()) == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
|
||||
customPref(
|
||||
SubscriptionPreference(
|
||||
title = DSLSettingsText.from(
|
||||
if (state.hasActiveSubscription) {
|
||||
R.string.preferences__subscription
|
||||
} else {
|
||||
R.string.preferences__monthly_donation
|
||||
}
|
||||
),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
isActive = state.hasActiveSubscription,
|
||||
onClick = { isActive ->
|
||||
if (isActive) {
|
||||
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
|
||||
} else {
|
||||
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscribeFragment())
|
||||
}
|
||||
},
|
||||
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
|
||||
)
|
||||
)
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__one_time_donation),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_boost_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment())
|
||||
},
|
||||
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
|
||||
)
|
||||
} else {
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
|
||||
if (FeatureFlags.internalUser()) {
|
||||
dividerPref()
|
||||
|
||||
|
|
|
@ -5,5 +5,5 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
|||
data class AppSettingsState(
|
||||
val self: Recipient,
|
||||
val unreadPaymentsCount: Int,
|
||||
val hasActiveSubscription: Boolean
|
||||
val hasExpiredGiftBadge: Boolean
|
||||
)
|
||||
|
|
|
@ -2,22 +2,14 @@ package org.thoughtcrime.securesms.components.settings.app
|
|||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
|
||||
class AppSettingsViewModel : ViewModel() {
|
||||
|
||||
private val store = Store(AppSettingsState(Recipient.self(), 0, false))
|
||||
private val store = Store(AppSettingsState(Recipient.self(), 0, SignalStore.donationsValues().getExpiredGiftBadge() != null))
|
||||
|
||||
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
|
||||
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
|
||||
|
@ -29,38 +21,7 @@ class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRep
|
|||
store.update(selfLiveData) { self, state -> state.copy(self = self) }
|
||||
}
|
||||
|
||||
fun refreshActiveSubscription() {
|
||||
if (!FeatureFlags.donorBadges()) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update {
|
||||
it.copy(hasActiveSubscription = TimeUnit.SECONDS.toMillis(SignalStore.donationsValues().getLastEndOfPeriod()) > System.currentTimeMillis())
|
||||
}
|
||||
|
||||
subscriptionsRepository.getActiveSubscription().subscribeBy(
|
||||
onSuccess = { subscription -> store.update { it.copy(hasActiveSubscription = subscription.activeSubscription != null) } },
|
||||
onError = { throwable ->
|
||||
if (throwable.isNotFoundException()) {
|
||||
Log.w(TAG, "Could not load active subscription due to unset SubscriberId (404).")
|
||||
}
|
||||
|
||||
Log.w(TAG, "Could not load active subscription", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(AppSettingsViewModel::class.java)
|
||||
}
|
||||
|
||||
private fun Throwable.isNotFoundException(): Boolean {
|
||||
return this is PushNetworkException && this.cause is NotFoundException || this is NotFoundException
|
||||
fun refreshExpiredGiftBadge() {
|
||||
store.update { it.copy(hasExpiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge() != null) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,9 +12,9 @@ import org.signal.core.util.money.FiatMoney
|
|||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
@ -23,16 +23,21 @@ import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
|
|||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
@ -47,7 +52,7 @@ import java.util.concurrent.TimeUnit
|
|||
* 1. Confirm the SetupIntent via the Stripe API
|
||||
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
|
||||
*
|
||||
* For Boosts:
|
||||
* For Boosts and Gifts:
|
||||
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
|
||||
* 1. Create a PaymentIntent via the Stripe API
|
||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
||||
|
@ -86,19 +91,65 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
|||
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||
}
|
||||
|
||||
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
|
||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||
return stripeApi.createPaymentIntent(price, application.getString(R.string.Boost__thank_you_for_your_donation))
|
||||
.onErrorResumeNext { Single.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it)) }
|
||||
.flatMapCompletable { result ->
|
||||
Log.d(TAG, "Created payment intent for $price.", true)
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall())
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge())
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost())
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent)
|
||||
/**
|
||||
* @param price The amount to charce the local user
|
||||
* @param paymentData PaymentData from Google Pay that describes the payment method
|
||||
* @param badgeRecipient Who will be getting the badge
|
||||
* @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self)
|
||||
*/
|
||||
fun continuePayment(price: FiatMoney, paymentData: PaymentData, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
|
||||
val verifyRecipient = Completable.fromAction {
|
||||
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
|
||||
if (recipient.isSelf) {
|
||||
Log.d(TAG, "Badge recipient is self, so this is a boost. Skipping verification.", true)
|
||||
return@fromAction
|
||||
}
|
||||
|
||||
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
|
||||
}
|
||||
|
||||
try {
|
||||
val profile = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)
|
||||
if (!profile.profile.capabilities.isGiftBadges) {
|
||||
Log.w(TAG, "Badge recipient does not support gifting. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
|
||||
} else {
|
||||
Log.d(TAG, "Badge recipient supports gifting. Verification successful.", true)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
|
||||
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
|
||||
}
|
||||
}
|
||||
|
||||
return verifyRecipient.doOnComplete {
|
||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||
}.andThen(stripeApi.createPaymentIntent(price, badgeLevel))
|
||||
.onErrorResumeNext {
|
||||
if (it is DonationError) {
|
||||
Single.error(it)
|
||||
} else {
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
Single.error(DonationError.getPaymentSetupError(errorSource, it))
|
||||
}
|
||||
}
|
||||
.flatMapCompletable { result ->
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Created payment intent for $price.", true)
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun continueSubscriptionSetup(paymentData: PaymentData): Completable {
|
||||
|
@ -140,20 +191,36 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
|||
}
|
||||
}
|
||||
|
||||
private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
|
||||
private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Confirming payment intent...", true)
|
||||
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext {
|
||||
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it))
|
||||
Completable.error(DonationError.getPaymentSetupError(donationErrorSource, it))
|
||||
}
|
||||
|
||||
val waitOnRedemption = Completable.create {
|
||||
Log.d(TAG, "Confirmed payment intent. Recording boost receipt and submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.donationReceipts.addReceipt(DonationReceiptRecord.createForBoost(price))
|
||||
val donationReceiptRecord = if (isBoost) {
|
||||
DonationReceiptRecord.createForBoost(price)
|
||||
} else {
|
||||
DonationReceiptRecord.createForGift(price)
|
||||
}
|
||||
|
||||
val donationTypeLabel = donationReceiptRecord.type.code.capitalize(Locale.US)
|
||||
|
||||
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val chain = if (isBoost) {
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntent)
|
||||
} else {
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
|
||||
}
|
||||
|
||||
BoostReceiptRequestResponseJob.createJobChain(paymentIntent).enqueue { _, jobState ->
|
||||
chain.enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
|
@ -164,25 +231,25 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
|||
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
when (finalJobState) {
|
||||
JobTracker.JobState.SUCCESS -> {
|
||||
Log.d(TAG, "Boost request response job chain succeeded.", true)
|
||||
Log.d(TAG, "$donationTypeLabel request response job chain succeeded.", true)
|
||||
it.onComplete()
|
||||
}
|
||||
JobTracker.JobState.FAILURE -> {
|
||||
Log.d(TAG, "Boost request response job chain failed permanently.", true)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST))
|
||||
Log.d(TAG, "$donationTypeLabel request response job chain failed permanently.", true)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(donationErrorSource))
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Boost request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
||||
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Boost redemption timed out waiting for job completion.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
||||
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.d(TAG, "Boost redemption job interrupted", e, true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
||||
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -282,11 +349,11 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
|||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
|
||||
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeApi.PaymentIntent> {
|
||||
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, description)
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
|
||||
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
|
||||
.map {
|
||||
StripeApi.PaymentIntent(it.id, it.clientSecret)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
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.keyvalue.SignalStore
|
||||
|
@ -23,6 +24,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
|
|||
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
||||
return if (localSubscription != null) {
|
||||
donationsService.getSubscription(localSubscription.subscriberId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
|
||||
} else {
|
||||
Single.just(ActiveSubscription.EMPTY)
|
||||
|
@ -30,6 +32,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
|
|||
}
|
||||
|
||||
fun getSubscriptions(): Single<List<Subscription>> = donationsService.getSubscriptionLevels(Locale.getDefault())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
|
||||
.map { subscriptionLevels ->
|
||||
subscriptionLevels.levels.map { (code, level) ->
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
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
|
||||
|
@ -16,6 +17,7 @@ class BoostRepository(private val donationsService: DonationsService) {
|
|||
|
||||
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
|
||||
return donationsService.boostAmounts
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
|
||||
.map { result ->
|
||||
result
|
||||
|
@ -27,6 +29,7 @@ class BoostRepository(private val donationsService: DonationsService) {
|
|||
|
||||
fun getBoostBadge(): Single<Badge> {
|
||||
return donationsService.getBoostBadge(Locale.getDefault())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
|
||||
.map(Badges::fromServiceBadge)
|
||||
}
|
||||
|
|
|
@ -23,9 +23,11 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
|
|||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
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.PlatformCurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
|
||||
import java.math.BigDecimal
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
|
@ -37,7 +39,7 @@ class BoostViewModel(
|
|||
private val fetchTokenRequestCode: Int
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(BoostState(currencySelection = SignalStore.donationsValues().getBoostCurrency()))
|
||||
private val store = Store(BoostState(currencySelection = SignalStore.donationsValues().getOneTimeCurrency()))
|
||||
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||
private val disposables = CompositeDisposable()
|
||||
private val networkDisposable: Disposable
|
||||
|
@ -77,7 +79,7 @@ class BoostViewModel(
|
|||
fun refresh() {
|
||||
disposables.clear()
|
||||
|
||||
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
|
||||
val currencyObservable = SignalStore.donationsValues().observableOneTimeCurrency
|
||||
val allBoosts = boostRepository.getBoosts()
|
||||
val boostBadge = boostRepository.getBoostBadge()
|
||||
|
||||
|
@ -85,7 +87,7 @@ class BoostViewModel(
|
|||
val boostList = if (currency in boostMap) {
|
||||
boostMap[currency]!!
|
||||
} else {
|
||||
SignalStore.donationsValues().setBoostCurrency(PlatformCurrencyUtil.USD)
|
||||
SignalStore.donationsValues().setOneTimeCurrency(PlatformCurrencyUtil.USD)
|
||||
listOf()
|
||||
}
|
||||
|
||||
|
@ -140,7 +142,7 @@ class BoostViewModel(
|
|||
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
||||
donationPaymentRepository.continuePayment(boost.price, paymentData, Recipient.self().id, null, SubscriptionLevels.BOOST_LEVEL.toLong()).subscribeBy(
|
||||
onError = { throwable ->
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
|
|
|
@ -13,14 +13,14 @@ import java.util.Currency
|
|||
import java.util.Locale
|
||||
|
||||
class SetCurrencyViewModel(
|
||||
private val isBoost: Boolean,
|
||||
private val isOneTime: Boolean,
|
||||
supportedCurrencyCodes: List<String>
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(
|
||||
SetCurrencyState(
|
||||
selectedCurrencyCode = if (isBoost) {
|
||||
SignalStore.donationsValues().getBoostCurrency().currencyCode
|
||||
selectedCurrencyCode = if (isOneTime) {
|
||||
SignalStore.donationsValues().getOneTimeCurrency().currencyCode
|
||||
} else {
|
||||
SignalStore.donationsValues().getSubscriptionCurrency().currencyCode
|
||||
},
|
||||
|
@ -35,8 +35,8 @@ class SetCurrencyViewModel(
|
|||
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
||||
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
||||
|
||||
if (isBoost) {
|
||||
SignalStore.donationsValues().setBoostCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
if (isOneTime) {
|
||||
SignalStore.donationsValues().setOneTimeCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
} else {
|
||||
val currency = Currency.getInstance(selectedCurrencyCode)
|
||||
val subscriber = SignalStore.donationsValues().getSubscriber(currency)
|
||||
|
@ -83,9 +83,9 @@ class SetCurrencyViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
class Factory(private val isBoost: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
|
||||
class Factory(private val isOneTime: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(SetCurrencyViewModel(isBoost, supportedCurrencyCodes))!!
|
||||
return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,12 +19,21 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
|||
}
|
||||
|
||||
/**
|
||||
* Boost validation errors, which occur before the user could be charged.
|
||||
* Gifting recipient validation errors, which occur before the user could be charged for a gift.
|
||||
*/
|
||||
sealed class BoostError(message: String) : DonationError(DonationErrorSource.BOOST, Exception(message)) {
|
||||
object AmountTooSmallError : BoostError("Amount is too small")
|
||||
object AmountTooLargeError : BoostError("Amount is too large")
|
||||
object InvalidCurrencyError : BoostError("Currency is not supported")
|
||||
sealed class GiftRecipientVerificationError(cause: Throwable) : DonationError(DonationErrorSource.GIFT, cause) {
|
||||
object SelectedRecipientIsInvalid : GiftRecipientVerificationError(Exception("Selected recipient is invalid."))
|
||||
object SelectedRecipientDoesNotSupportGifts : GiftRecipientVerificationError(Exception("Selected recipient does not support gifts."))
|
||||
class FailedToFetchProfile(cause: Throwable) : GiftRecipientVerificationError(Exception("Failed to fetch recipient profile.", cause))
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time donation validation errors, which occur before the user could be charged.
|
||||
*/
|
||||
sealed class OneTimeDonationError(source: DonationErrorSource, message: String) : DonationError(source, Exception(message)) {
|
||||
class AmountTooSmallError(source: DonationErrorSource) : OneTimeDonationError(source, "Amount is too small")
|
||||
class AmountTooLargeError(source: DonationErrorSource) : OneTimeDonationError(source, "Amount is too large")
|
||||
class InvalidCurrencyError(source: DonationErrorSource) : OneTimeDonationError(source, "Currency is not supported")
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,6 +78,11 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
|||
*/
|
||||
class TimeoutWaitingForTokenError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Timed out waiting for badge redemption to complete."))
|
||||
|
||||
/**
|
||||
* Verification of request credentials object failed
|
||||
*/
|
||||
class FailedToValidateCredentialError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Failed to validate credential from server."))
|
||||
|
||||
/**
|
||||
* Some generic error not otherwise accounted for occurred during the redemption process.
|
||||
*/
|
||||
|
@ -134,13 +148,13 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
|||
}
|
||||
|
||||
@JvmStatic
|
||||
fun boostAmountTooSmall(): DonationError = BoostError.AmountTooSmallError
|
||||
fun oneTimeDonationAmountTooSmall(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooSmallError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun boostAmountTooLarge(): DonationError = BoostError.AmountTooLargeError
|
||||
fun oneTimeDonationAmountTooLarge(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooLargeError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidCurrencyForBoost(): DonationError = BoostError.InvalidCurrencyError
|
||||
fun invalidCurrencyForOneTimeDonation(source: DonationErrorSource): DonationError = OneTimeDonationError.InvalidCurrencyError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
|
||||
|
@ -148,6 +162,9 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
|||
@JvmStatic
|
||||
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun badgeCredentialVerificationFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.FailedToValidateCredentialError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun genericPaymentFailure(source: DonationErrorSource): DonationError = PaymentProcessingError.GenericError(source)
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ class DonationErrorParams<V> private constructor(
|
|||
callback: Callback<V>
|
||||
): DonationErrorParams<V> {
|
||||
return when (throwable) {
|
||||
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, throwable, callback)
|
||||
is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback)
|
||||
is DonationError.PaymentSetupError -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__error_processing_payment,
|
||||
|
@ -36,6 +37,13 @@ class DonationErrorParams<V> private constructor(
|
|||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__failed_to_validate_badge,
|
||||
message = R.string.DonationsErrors__could_not_validate,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
negativeAction = null
|
||||
)
|
||||
is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable, callback)
|
||||
else -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__couldnt_add_badge,
|
||||
message = R.string.DonationsErrors__your_badge_could_not,
|
||||
|
@ -45,6 +53,32 @@ class DonationErrorParams<V> private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun <V> getGenericRedemptionError(context: Context, genericError: DonationError.BadgeRedemptionError.GenericError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return when (genericError.source) {
|
||||
DonationErrorSource.GIFT -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__failed_to_send_gift_badge,
|
||||
message = R.string.DonationsErrors__could_not_send_gift_badge,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
negativeAction = null
|
||||
)
|
||||
else -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__couldnt_add_badge,
|
||||
message = R.string.DonationsErrors__your_badge_could_not,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
negativeAction = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getVerificationErrorParams(context: Context, verificationError: DonationError.GiftRecipientVerificationError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return DonationErrorParams(
|
||||
title = R.string.DonationsErrors__recipient_verification_failed,
|
||||
message = R.string.DonationsErrors__target_does_not_support_gifting,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
negativeAction = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return when (declinedError.declineCode) {
|
||||
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {
|
||||
|
|
|
@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
|||
enum class DonationErrorSource(private val code: String) {
|
||||
BOOST("boost"),
|
||||
SUBSCRIPTION("subscription"),
|
||||
GIFT("gift"),
|
||||
GIFT_REDEMPTION("gift-redemption"),
|
||||
KEEP_ALIVE("keep-alive"),
|
||||
UNKNOWN("unknown");
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ import android.view.View
|
|||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
|
@ -31,7 +30,6 @@ object ActiveSubscriptionPreference {
|
|||
class Model(
|
||||
val price: FiatMoney,
|
||||
val subscription: Subscription,
|
||||
val onAddBoostClick: () -> Unit,
|
||||
val renewalTimestamp: Long = -1L,
|
||||
val redemptionState: ManageDonationsState.SubscriptionRedemptionState,
|
||||
val activeSubscription: ActiveSubscription.Subscription,
|
||||
|
@ -57,7 +55,6 @@ object ActiveSubscriptionPreference {
|
|||
val title: TextView = itemView.findViewById(R.id.my_support_title)
|
||||
val price: TextView = itemView.findViewById(R.id.my_support_price)
|
||||
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
|
||||
val boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
|
||||
val progress: ProgressBar = itemView.findViewById(R.id.my_support_progress)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
|
@ -79,10 +76,6 @@ object ActiveSubscriptionPreference {
|
|||
ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS -> presentInProgressState()
|
||||
ManageDonationsState.SubscriptionRedemptionState.FAILED -> presentFailureState(model)
|
||||
}
|
||||
|
||||
boost.setOnClickListener {
|
||||
model.onAddBoostClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentRenewalState(model: Model) {
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
enum class ManageDonationsEvent {
|
||||
NOT_SUBSCRIBED,
|
||||
ERROR_GETTING_SUBSCRIPTION
|
||||
}
|
|
@ -1,11 +1,15 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import android.widget.Toast
|
||||
import android.content.Intent
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheet
|
||||
import org.thoughtcrime.securesms.badges.gifts.flow.GiftFlowActivity
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
|
@ -14,13 +18,17 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
|||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
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.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Currency
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
@ -28,7 +36,17 @@ import java.util.concurrent.TimeUnit
|
|||
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
|
||||
* a subscriber. Used to manage their current subscription, view badges, and boost.
|
||||
*/
|
||||
class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback {
|
||||
|
||||
private val supportTechSummary: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__make_a_recurring_monthly_donation))
|
||||
.append(" ")
|
||||
.append(
|
||||
SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_button_secondary_text)) {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeLearnMoreBottomSheetDialog())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
|
@ -36,8 +54,6 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
|||
}
|
||||
)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
|
@ -47,24 +63,23 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
|||
ActiveSubscriptionPreference.register(adapter)
|
||||
IndeterminateLoadingCircle.register(adapter)
|
||||
BadgePreview.register(adapter)
|
||||
NetworkFailure.register(adapter)
|
||||
|
||||
val expiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge()
|
||||
if (expiredGiftBadge != null) {
|
||||
SignalStore.donationsValues().setExpiredBadge(null)
|
||||
ExpiredGiftSheet.show(childFragmentManager, expiredGiftBadge)
|
||||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: ManageDonationsEvent ->
|
||||
when (event) {
|
||||
ManageDonationsEvent.NOT_SUBSCRIBED -> handleUserIsNotSubscribed()
|
||||
ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION -> handleErrorGettingSubscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(
|
||||
BadgePreview.Model(
|
||||
BadgePreview.BadgeModel.FeaturedModel(
|
||||
badge = state.featuredBadge
|
||||
)
|
||||
)
|
||||
|
@ -78,91 +93,176 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
|||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(32f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.ManageDonationsFragment__my_support,
|
||||
DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
|
||||
val activeSubscription = state.transactionState.activeSubscription.activeSubscription
|
||||
if (activeSubscription != null) {
|
||||
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.level == it.level }
|
||||
if (subscription != null) {
|
||||
space(DimensionUnit.DP.toPixels(12f).toInt())
|
||||
|
||||
val activeCurrency = Currency.getInstance(activeSubscription.currency)
|
||||
val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
|
||||
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
price = FiatMoney(activeAmount, activeCurrency),
|
||||
subscription = subscription,
|
||||
onAddBoostClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
},
|
||||
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod),
|
||||
redemptionState = state.getRedemptionState(),
|
||||
onContactSupport = {
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
|
||||
},
|
||||
activeSubscription = activeSubscription
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
presentSubscriptionSettings(activeSubscription, subscription, state.getRedemptionState())
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
presentNoSubscriptionSettings()
|
||||
}
|
||||
} else if (state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
|
||||
presentNetworkFailureSettings(state.getRedemptionState())
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
|
||||
isEnabled = state.getRedemptionState() != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
|
||||
}
|
||||
private fun DSLConfiguration.presentNetworkFailureSettings(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
|
||||
if (SignalStore.donationsValues().isLikelyASustainer()) {
|
||||
presentSubscriptionSettingsWithNetworkError(redemptionState)
|
||||
} else {
|
||||
presentNoSubscriptionSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentSubscriptionSettingsWithNetworkError(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
|
||||
presentSubscriptionSettingsWithState(redemptionState) {
|
||||
customPref(
|
||||
NetworkFailure.Model(
|
||||
onRetryClick = {
|
||||
viewModel.retry()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges())
|
||||
}
|
||||
private fun DSLConfiguration.presentSubscriptionSettings(
|
||||
activeSubscription: ActiveSubscription.Subscription,
|
||||
subscription: Subscription,
|
||||
redemptionState: ManageDonationsState.SubscriptionRedemptionState
|
||||
) {
|
||||
presentSubscriptionSettingsWithState(redemptionState) {
|
||||
val activeCurrency = Currency.getInstance(activeSubscription.currency)
|
||||
val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
|
||||
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
price = FiatMoney(activeAmount, activeCurrency),
|
||||
subscription = subscription,
|
||||
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod),
|
||||
redemptionState = redemptionState,
|
||||
onContactSupport = {
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
|
||||
},
|
||||
activeSubscription = activeSubscription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
||||
linkId = R.string.donate_url
|
||||
private fun DSLConfiguration.presentSubscriptionSettingsWithState(
|
||||
redemptionState: ManageDonationsState.SubscriptionRedemptionState,
|
||||
subscriptionBlock: DSLConfiguration.() -> Unit
|
||||
) {
|
||||
space(DimensionUnit.DP.toPixels(32f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.ManageDonationsFragment__my_subscription,
|
||||
DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(12f).toInt())
|
||||
|
||||
subscriptionBlock()
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
|
||||
isEnabled = redemptionState != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges())
|
||||
}
|
||||
)
|
||||
|
||||
presentOtherWaysToGive()
|
||||
|
||||
sectionHeaderPref(R.string.ManageDonationsFragment__more)
|
||||
|
||||
presentDonationReceipts()
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentNoSubscriptionSettings() {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.ManageDonationsFragment__make_a_monthly_donation),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(R.id.action_manageDonationsFragment_to_subscribeFragment)
|
||||
}
|
||||
)
|
||||
|
||||
presentOtherWaysToGive()
|
||||
|
||||
sectionHeaderPref(R.string.ManageDonationsFragment__receipts)
|
||||
|
||||
presentDonationReceipts()
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentOtherWaysToGive() {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.ManageDonationsFragment__other_ways_to_give)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__one_time_donation),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_boost_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
}
|
||||
)
|
||||
|
||||
if (FeatureFlags.giftBadges()) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__gift_a_badge),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_gift_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
|
||||
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUserIsNotSubscribed() {
|
||||
findNavController().popBackStack()
|
||||
private fun DSLConfiguration.presentDonationReceipts() {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleErrorGettingSubscription() {
|
||||
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
|
||||
override fun onMakeAMonthlyDonation() {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ data class ManageDonationsState(
|
|||
fun getRedemptionState(): SubscriptionRedemptionState {
|
||||
return when (transactionState) {
|
||||
TransactionState.Init -> subscriptionRedemptionState
|
||||
TransactionState.NetworkFailure -> subscriptionRedemptionState
|
||||
TransactionState.InTransaction -> SubscriptionRedemptionState.IN_PROGRESS
|
||||
is TransactionState.NotInTransaction -> getStateFromActiveSubscription(transactionState.activeSubscription) ?: subscriptionRedemptionState
|
||||
}
|
||||
|
@ -29,6 +30,7 @@ data class ManageDonationsState(
|
|||
|
||||
sealed class TransactionState {
|
||||
object Init : TransactionState()
|
||||
object NetworkFailure : TransactionState()
|
||||
object InTransaction : TransactionState()
|
||||
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
|
||||
}
|
||||
|
|
|
@ -3,18 +3,18 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
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.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
|
@ -23,22 +23,37 @@ class ManageDonationsViewModel(
|
|||
) : ViewModel() {
|
||||
|
||||
private val store = Store(ManageDonationsState())
|
||||
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
|
||||
private val disposables = CompositeDisposable()
|
||||
private val networkDisposable: Disposable
|
||||
|
||||
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
||||
val events: Observable<ManageDonationsEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
init {
|
||||
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
||||
state.copy(featuredBadge = self.featuredBadge)
|
||||
}
|
||||
|
||||
networkDisposable = InternetConnectionObserver
|
||||
.observe()
|
||||
.distinctUntilChanged()
|
||||
.subscribe { isConnected ->
|
||||
if (isConnected) {
|
||||
retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
if (!disposables.isDisposed && store.state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
|
||||
store.update { it.copy(transactionState = ManageDonationsState.TransactionState.Init) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
|
||||
|
@ -72,13 +87,13 @@ class ManageDonationsViewModel(
|
|||
store.update {
|
||||
it.copy(transactionState = transactionState)
|
||||
}
|
||||
|
||||
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && transactionState.activeSubscription.activeSubscription == null) {
|
||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Error retrieving subscription transaction state", throwable)
|
||||
|
||||
store.update {
|
||||
it.copy(transactionState = ManageDonationsState.TransactionState.NetworkFailure)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -71,6 +71,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
|
|||
val type: String = when (record.type) {
|
||||
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
|
||||
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
|
||||
DonationReceiptRecord.Type.GIFT -> getString(R.string.DonationReceiptListFragment__gift)
|
||||
}
|
||||
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
|
||||
|
||||
|
@ -142,6 +143,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
|
|||
when (record.type) {
|
||||
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
|
||||
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
|
||||
DonationReceiptRecord.Type.GIFT -> getString(R.string.DonationReceiptListFragment__gift)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
@ -44,6 +44,7 @@ object DonationReceiptListItem {
|
|||
when (model.record.type) {
|
||||
DonationReceiptRecord.Type.RECURRING -> R.string.DonationReceiptListFragment__recurring
|
||||
DonationReceiptRecord.Type.BOOST -> R.string.DonationReceiptListFragment__one_time
|
||||
DonationReceiptRecord.Type.GIFT -> R.string.DonationReceiptListFragment__gift
|
||||
}
|
||||
)
|
||||
moneyView.text = FiatMoneyUtil.format(context.resources, model.record.amount)
|
||||
|
|
|
@ -120,7 +120,7 @@ class SubscribeFragment : DSLSettingsFragment(
|
|||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
processingDonationPaymentDialog.hide()
|
||||
processingDonationPaymentDialog.dismiss()
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
|
||||
|
@ -133,7 +133,7 @@ class SubscribeFragment : DSLSettingsFragment(
|
|||
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
|
||||
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.selectedSubscription?.badge))
|
||||
customPref(BadgePreview.BadgeModel.SubscriptionModel(state.selectedSubscription?.badge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
|
|
|
@ -95,11 +95,12 @@ class DSLConfiguration {
|
|||
title: DSLSettingsText,
|
||||
summary: DSLSettingsText? = null,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
iconEnd: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Boolean)? = null
|
||||
) {
|
||||
val preference = ClickPreference(title, summary, icon, isEnabled, onClick, onLongClick)
|
||||
val preference = ClickPreference(title, summary, icon, iconEnd, isEnabled, onClick, onLongClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
|
@ -182,6 +183,7 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
|
|||
open val title: DSLSettingsText? = null,
|
||||
open val summary: DSLSettingsText? = null,
|
||||
open val icon: DSLSettingsIcon? = null,
|
||||
open val iconEnd: DSLSettingsIcon? = null,
|
||||
open val isEnabled: Boolean = true,
|
||||
) : MappingModel<T> {
|
||||
override fun areItemsTheSame(newItem: T): Boolean {
|
||||
|
@ -197,7 +199,8 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
|
|||
return areItemsTheSame(newItem) &&
|
||||
newItem.summary == summary &&
|
||||
newItem.icon == icon &&
|
||||
newItem.isEnabled == isEnabled
|
||||
newItem.isEnabled == isEnabled &&
|
||||
newItem.iconEnd == iconEnd
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -269,6 +272,7 @@ class ClickPreference(
|
|||
override val title: DSLSettingsText,
|
||||
override val summary: DSLSettingsText? = null,
|
||||
override val icon: DSLSettingsIcon? = null,
|
||||
override val iconEnd: DSLSettingsIcon? = null,
|
||||
override val isEnabled: Boolean = true,
|
||||
val onClick: () -> Unit,
|
||||
val onLongClick: (() -> Boolean)? = null
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
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 OutlinedSwitch {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.outlined_switch))
|
||||
}
|
||||
|
||||
class Model(
|
||||
val key: String = "OutlinedSwitch",
|
||||
val text: DSLSettingsText,
|
||||
val isChecked: Boolean,
|
||||
val isEnabled: Boolean,
|
||||
val onClick: (Model) -> Unit
|
||||
) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = newItem.key == key
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return areItemsTheSame(newItem) &&
|
||||
text == newItem.text &&
|
||||
isChecked == newItem.isChecked &&
|
||||
isEnabled == newItem.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val text: TextView = findViewById(R.id.outlined_switch_control_text)
|
||||
private val switch: SwitchMaterial = findViewById(R.id.outlined_switch_switch)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
text.text = model.text.resolve(context)
|
||||
switch.isChecked = model.isChecked
|
||||
switch.setOnClickListener { model.onClick(model) }
|
||||
itemView.isEnabled = model.isEnabled
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.signal.core.util.EditTextUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
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.text.AfterTextChanged
|
||||
|
||||
object TextInput {
|
||||
|
||||
sealed class TextInputEvent {
|
||||
data class OnKeyEvent(val keyEvent: KeyEvent) : TextInputEvent()
|
||||
data class OnEmojiEvent(val emoji: CharSequence) : TextInputEvent()
|
||||
}
|
||||
|
||||
fun register(adapter: MappingAdapter, events: Observable<TextInputEvent>) {
|
||||
adapter.registerFactory(MultilineModel::class.java, LayoutFactory({ MultilineViewHolder(it, events) }, R.layout.dsl_multiline_text_input))
|
||||
}
|
||||
|
||||
class MultilineModel(
|
||||
val text: CharSequence?,
|
||||
val hint: DSLSettingsText? = null,
|
||||
val onEmojiToggleClicked: (EditText) -> Unit,
|
||||
val onTextChanged: (CharSequence) -> Unit
|
||||
) : MappingModel<MultilineModel> {
|
||||
override fun areItemsTheSame(newItem: MultilineModel): Boolean = true
|
||||
|
||||
override fun areContentsTheSame(newItem: MultilineModel): Boolean = text == newItem.text
|
||||
}
|
||||
|
||||
class MultilineViewHolder(itemView: View, private val events: Observable<TextInputEvent>) : MappingViewHolder<MultilineModel>(itemView) {
|
||||
|
||||
private val inputLayout: TextInputLayout = itemView.findViewById(R.id.input_layout)
|
||||
private val input: EditText = itemView.findViewById<EditText>(R.id.input).apply {
|
||||
EditTextUtil.addGraphemeClusterLimitFilter(this, 700)
|
||||
}
|
||||
|
||||
private val emojiToggle: ImageView = itemView.findViewById(R.id.emoji_toggle)
|
||||
|
||||
private var textChangedListener: AfterTextChanged? = null
|
||||
private var eventDisposable: Disposable? = null
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
eventDisposable = events.subscribe {
|
||||
when (it) {
|
||||
is TextInputEvent.OnEmojiEvent -> input.append(it.emoji)
|
||||
is TextInputEvent.OnKeyEvent -> input.dispatchKeyEvent(it.keyEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
eventDisposable?.dispose()
|
||||
}
|
||||
|
||||
override fun bind(model: MultilineModel) {
|
||||
inputLayout.hint = model.hint?.resolve(context)
|
||||
|
||||
if (textChangedListener != null) {
|
||||
input.removeTextChangedListener(textChangedListener)
|
||||
}
|
||||
|
||||
if (model.text.toString() != input.text.toString()) {
|
||||
input.setText(model.text)
|
||||
}
|
||||
|
||||
textChangedListener = AfterTextChanged { model.onTextChanged(it.toString()) }
|
||||
input.addTextChangedListener(textChangedListener)
|
||||
|
||||
// Set Emoji Toggle according to state.
|
||||
emojiToggle.setOnClickListener {
|
||||
model.onEmojiToggleClicked(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -205,7 +205,7 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
|||
ThreadDatabase threadDatabase = SignalDatabase.threads();
|
||||
|
||||
MatrixCursor recentConversations = ContactsCursorRows.createMatrixCursor(RECENT_CONVERSATION_MAX);
|
||||
try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), groupsOnly, hideGroupsV1(mode), !smsEnabled(mode))) {
|
||||
try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), false, groupsOnly, hideGroupsV1(mode), !smsEnabled(mode))) {
|
||||
ThreadDatabase.Reader reader = threadDatabase.readerFor(rawConversations);
|
||||
ThreadRecord threadRecord;
|
||||
while ((threadRecord = reader.getNext()) != null) {
|
||||
|
|
|
@ -30,13 +30,19 @@ class ContactSearchConfiguration private constructor(
|
|||
*/
|
||||
data class Recents(
|
||||
val limit: Int = 25,
|
||||
val groupsOnly: Boolean = false,
|
||||
val mode: Mode = Mode.ALL,
|
||||
val includeInactiveGroups: Boolean = false,
|
||||
val includeGroupsV1: Boolean = false,
|
||||
val includeSms: Boolean = false,
|
||||
override val includeHeader: Boolean,
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
) : Section(SectionKey.RECENTS)
|
||||
) : Section(SectionKey.RECENTS) {
|
||||
enum class Mode {
|
||||
INDIVIDUALS,
|
||||
GROUPS,
|
||||
ALL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1:1 Recipients
|
||||
|
|
|
@ -21,17 +21,18 @@ import org.thoughtcrime.securesms.util.visible
|
|||
object ContactSearchItems {
|
||||
fun register(
|
||||
mappingAdapter: MappingAdapter,
|
||||
displayCheckBox: Boolean,
|
||||
recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit,
|
||||
storyListener: (ContactSearchData.Story, Boolean) -> Unit,
|
||||
expandListener: (ContactSearchData.Expand) -> Unit
|
||||
) {
|
||||
mappingAdapter.registerFactory(
|
||||
StoryModel::class.java,
|
||||
LayoutFactory({ StoryViewHolder(it, storyListener) }, R.layout.contact_search_item)
|
||||
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener) }, R.layout.contact_search_item)
|
||||
)
|
||||
mappingAdapter.registerFactory(
|
||||
RecipientModel::class.java,
|
||||
LayoutFactory({ KnownRecipientViewHolder(it, recipientListener) }, R.layout.contact_search_item)
|
||||
LayoutFactory({ KnownRecipientViewHolder(it, displayCheckBox, recipientListener) }, R.layout.contact_search_item)
|
||||
)
|
||||
mappingAdapter.registerFactory(
|
||||
HeaderModel::class.java,
|
||||
|
@ -78,7 +79,7 @@ object ContactSearchItems {
|
|||
}
|
||||
}
|
||||
|
||||
private class StoryViewHolder(itemView: View, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, onClick) {
|
||||
private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, onClick) {
|
||||
override fun isSelected(model: StoryModel): Boolean = model.isSelected
|
||||
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
|
||||
override fun getRecipient(model: StoryModel): Recipient = model.story.recipient
|
||||
|
@ -118,7 +119,7 @@ object ContactSearchItems {
|
|||
}
|
||||
}
|
||||
|
||||
private class KnownRecipientViewHolder(itemView: View, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, onClick) {
|
||||
private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, onClick) {
|
||||
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
|
||||
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
|
||||
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
|
||||
|
@ -127,7 +128,7 @@ object ContactSearchItems {
|
|||
/**
|
||||
* Base Recipient View Holder
|
||||
*/
|
||||
private abstract class BaseRecipientViewHolder<T, D : ContactSearchData>(itemView: View, val onClick: (D, Boolean) -> Unit) : MappingViewHolder<T>(itemView) {
|
||||
private abstract class BaseRecipientViewHolder<T, D : ContactSearchData>(itemView: View, private val displayCheckBox: Boolean, val onClick: (D, Boolean) -> Unit) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
|
||||
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
|
||||
|
@ -138,6 +139,7 @@ object ContactSearchItems {
|
|||
protected val smsTag: View = itemView.findViewById(R.id.sms_tag)
|
||||
|
||||
override fun bind(model: T) {
|
||||
checkbox.visible = displayCheckBox
|
||||
checkbox.isChecked = isSelected(model)
|
||||
itemView.setOnClickListener { onClick(getData(model), isSelected(model)) }
|
||||
|
||||
|
|
|
@ -64,7 +64,7 @@ sealed class ContactSearchKey {
|
|||
|
||||
@Parcelize
|
||||
data class ParcelableRecipientSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
|
||||
fun asContactSearchKey(): ContactSearchKey {
|
||||
fun asRecipientSearchKey(): RecipientSearchKey {
|
||||
return when (type) {
|
||||
ParcelableType.STORY -> RecipientSearchKey.Story(recipientId)
|
||||
ParcelableType.KNOWN_RECIPIENT -> RecipientSearchKey.KnownRecipient(recipientId)
|
||||
|
|
|
@ -12,6 +12,7 @@ class ContactSearchMediator(
|
|||
fragment: Fragment,
|
||||
recyclerView: RecyclerView,
|
||||
selectionLimits: SelectionLimits,
|
||||
displayCheckBox: Boolean,
|
||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
|
||||
) {
|
||||
|
||||
|
@ -23,6 +24,7 @@ class ContactSearchMediator(
|
|||
|
||||
ContactSearchItems.register(
|
||||
mappingAdapter = adapter,
|
||||
displayCheckBox = displayCheckBox,
|
||||
recipientListener = this::toggleSelection,
|
||||
storyListener = this::toggleSelection,
|
||||
expandListener = { viewModel.expandSection(it.sectionKey) }
|
||||
|
|
|
@ -42,7 +42,8 @@ open class ContactSearchPagedDataSourceRepository(
|
|||
return SignalDatabase.threads.getRecentConversationList(
|
||||
section.limit,
|
||||
section.includeInactiveGroups,
|
||||
section.groupsOnly,
|
||||
section.mode == ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS,
|
||||
section.mode == ContactSearchConfiguration.Section.Recents.Mode.GROUPS,
|
||||
!section.includeGroupsV1,
|
||||
!section.includeSms
|
||||
)
|
||||
|
|
|
@ -77,6 +77,9 @@ import org.signal.core.util.concurrent.SimpleTask;
|
|||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration;
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.received.ViewReceivedGiftBottomSheet;
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.sent.ViewSentGiftBottomSheet;
|
||||
import org.thoughtcrime.securesms.components.ConversationScrollToView;
|
||||
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusRepository;
|
||||
|
@ -308,6 +311,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
|||
|
||||
RecyclerViewColorizer recyclerViewColorizer = new RecyclerViewColorizer(list);
|
||||
|
||||
OpenableGiftItemDecoration openableGiftItemDecoration = new OpenableGiftItemDecoration(requireContext());
|
||||
getViewLifecycleOwner().getLifecycle().addObserver(openableGiftItemDecoration);
|
||||
|
||||
list.addItemDecoration(openableGiftItemDecoration);
|
||||
list.addItemDecoration(multiselectItemDecoration);
|
||||
list.setItemAnimator(conversationItemAnimator);
|
||||
|
||||
|
@ -413,6 +420,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
|||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
getChildFragmentManager().setFragmentResultListener(ViewReceivedGiftBottomSheet.REQUEST_KEY, getViewLifecycleOwner(), (key, bundle) -> {
|
||||
if (bundle.getBoolean(ViewReceivedGiftBottomSheet.RESULT_NOT_NOW, false)) {
|
||||
Snackbar.make(view.getRootView(), R.string.ConversationFragment__you_can_redeem_your_badge_later, Snackbar.LENGTH_SHORT)
|
||||
.setTextColor(Color.WHITE)
|
||||
.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private @NonNull GiphyMp4ProjectionRecycler initializeGiphyMp4() {
|
||||
int maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation();
|
||||
List<GiphyMp4ProjectionPlayerHolder> holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(),
|
||||
|
@ -1950,6 +1968,19 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
|||
|
||||
RecipientBottomSheetDialogFragment.create(target, recipient.get().getGroupId().orElse(null)).show(getParentFragmentManager(), "BOTTOM");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord) {
|
||||
if (!MessageRecordUtil.hasGiftBadge(messageRecord)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageRecord.isOutgoing()) {
|
||||
ViewSentGiftBottomSheet.show(getChildFragmentManager(), (MmsMessageRecord) messageRecord);
|
||||
} else {
|
||||
ViewReceivedGiftBottomSheet.show(getChildFragmentManager(), (MmsMessageRecord) messageRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshList() {
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.net.Uri;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
|
@ -33,6 +34,7 @@ public class ConversationIntents {
|
|||
private static final String EXTRA_STARTING_POSITION = "starting_position";
|
||||
private static final String EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP = "first_time_in_group";
|
||||
private static final String EXTRA_WITH_SEARCH_OPEN = "with_search_open";
|
||||
private static final String EXTRA_GIFT_BADGE = "gift_badge";
|
||||
|
||||
private ConversationIntents() {
|
||||
}
|
||||
|
@ -72,6 +74,7 @@ public class ConversationIntents {
|
|||
private final int startingPosition;
|
||||
private final boolean firstTimeInSelfCreatedGroup;
|
||||
private final boolean withSearchOpen;
|
||||
private final Badge giftBadge;
|
||||
|
||||
static Args from(@NonNull Intent intent) {
|
||||
if (isBubbleIntent(intent)) {
|
||||
|
@ -84,7 +87,8 @@ public class ConversationIntents {
|
|||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
-1,
|
||||
false,
|
||||
false);
|
||||
false,
|
||||
null);
|
||||
}
|
||||
|
||||
return new Args(RecipientId.from(Objects.requireNonNull(intent.getStringExtra(EXTRA_RECIPIENT))),
|
||||
|
@ -96,7 +100,8 @@ public class ConversationIntents {
|
|||
intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, ThreadDatabase.DistributionTypes.DEFAULT),
|
||||
intent.getIntExtra(EXTRA_STARTING_POSITION, -1),
|
||||
intent.getBooleanExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
|
||||
intent.getBooleanExtra(EXTRA_WITH_SEARCH_OPEN, false));
|
||||
intent.getBooleanExtra(EXTRA_WITH_SEARCH_OPEN, false),
|
||||
intent.getParcelableExtra(EXTRA_GIFT_BADGE));
|
||||
}
|
||||
|
||||
private Args(@NonNull RecipientId recipientId,
|
||||
|
@ -108,7 +113,8 @@ public class ConversationIntents {
|
|||
int distributionType,
|
||||
int startingPosition,
|
||||
boolean firstTimeInSelfCreatedGroup,
|
||||
boolean withSearchOpen)
|
||||
boolean withSearchOpen,
|
||||
@Nullable Badge giftBadge)
|
||||
{
|
||||
this.recipientId = recipientId;
|
||||
this.threadId = threadId;
|
||||
|
@ -120,6 +126,7 @@ public class ConversationIntents {
|
|||
this.startingPosition = startingPosition;
|
||||
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
|
||||
this.withSearchOpen = withSearchOpen;
|
||||
this.giftBadge = giftBadge;
|
||||
}
|
||||
|
||||
public @NonNull RecipientId getRecipientId() {
|
||||
|
@ -169,6 +176,10 @@ public class ConversationIntents {
|
|||
public boolean isWithSearchOpen() {
|
||||
return withSearchOpen;
|
||||
}
|
||||
|
||||
public @Nullable Badge getGiftBadge() {
|
||||
return giftBadge;
|
||||
}
|
||||
}
|
||||
|
||||
public final static class Builder {
|
||||
|
@ -187,6 +198,7 @@ public class ConversationIntents {
|
|||
private String dataType;
|
||||
private boolean firstTimeInSelfCreatedGroup;
|
||||
private boolean withSearchOpen;
|
||||
private Badge giftBadge;
|
||||
|
||||
private Builder(@NonNull Context context,
|
||||
@NonNull RecipientId recipientId,
|
||||
|
@ -255,7 +267,12 @@ public class ConversationIntents {
|
|||
this.firstTimeInSelfCreatedGroup = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public Builder withGiftBadge(@NonNull Badge badge) {
|
||||
this.giftBadge = badge;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Intent build() {
|
||||
if (stickerLocator != null && media != null) {
|
||||
throw new IllegalStateException("Cannot have both sticker and media array");
|
||||
|
@ -281,6 +298,7 @@ public class ConversationIntents {
|
|||
intent.putExtra(EXTRA_BORDERLESS, isBorderless);
|
||||
intent.putExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, firstTimeInSelfCreatedGroup);
|
||||
intent.putExtra(EXTRA_WITH_SEARCH_OPEN, withSearchOpen);
|
||||
intent.putExtra(EXTRA_GIFT_BADGE, giftBadge);
|
||||
|
||||
if (draftText != null) {
|
||||
intent.putExtra(EXTRA_TEXT, draftText);
|
||||
|
|
|
@ -71,6 +71,8 @@ import org.thoughtcrime.securesms.MediaPreviewActivity;
|
|||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView;
|
||||
import org.thoughtcrime.securesms.badges.gifts.GiftMessageView;
|
||||
import org.thoughtcrime.securesms.badges.gifts.OpenableGift;
|
||||
import org.thoughtcrime.securesms.components.AlertView;
|
||||
import org.thoughtcrime.securesms.components.AudioView;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
|
@ -147,6 +149,9 @@ import java.util.Optional;
|
|||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import kotlin.Unit;
|
||||
import kotlin.jvm.functions.Function1;
|
||||
|
||||
/**
|
||||
* A view that displays an individual conversation item within a conversation
|
||||
* thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter.
|
||||
|
@ -156,7 +161,8 @@ import java.util.concurrent.TimeUnit;
|
|||
*/
|
||||
|
||||
public final class ConversationItem extends RelativeLayout implements BindableConversationItem,
|
||||
RecipientForeverObserver
|
||||
RecipientForeverObserver,
|
||||
OpenableGift
|
||||
{
|
||||
private static final String TAG = Log.tag(ConversationItem.class);
|
||||
|
||||
|
@ -207,6 +213,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
private Stub<BorderlessImageView> stickerStub;
|
||||
private Stub<ViewOnceMessageView> revealableStub;
|
||||
private Stub<Button> callToActionStub;
|
||||
private Stub<GiftMessageView> giftViewStub;
|
||||
private @Nullable EventListener eventListener;
|
||||
|
||||
private int defaultBubbleColor;
|
||||
|
@ -224,6 +231,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||
private final Rect thumbnailMaskingRect = new Rect();
|
||||
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
|
||||
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
|
||||
|
||||
private final Context context;
|
||||
|
||||
|
@ -298,6 +306,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
this.badgeImageView = findViewById(R.id.badge);
|
||||
this.storyReactionLabelWrapper = findViewById(R.id.story_reacted_label_holder);
|
||||
this.storyReactionLabel = findViewById(R.id.story_reacted_label);
|
||||
this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub));
|
||||
|
||||
setOnClickListener(new ClickListener(null));
|
||||
|
||||
|
@ -914,6 +923,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
return MessageRecordUtil.isViewOnceMessage(messageRecord);
|
||||
}
|
||||
|
||||
private boolean isGiftMessage(MessageRecord messageRecord) {
|
||||
return MessageRecordUtil.hasGiftBadge(messageRecord);
|
||||
}
|
||||
|
||||
private void setBodyText(@NonNull MessageRecord messageRecord,
|
||||
@Nullable String searchQuery,
|
||||
boolean messageRequestAccepted)
|
||||
|
@ -935,7 +948,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
bodyText.setText(italics);
|
||||
bodyText.setVisibility(View.VISIBLE);
|
||||
bodyText.setOverflowText(null);
|
||||
} else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord)) {
|
||||
} else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord) || isGiftMessage(messageRecord)) {
|
||||
bodyText.setVisibility(View.GONE);
|
||||
} else {
|
||||
Spannable styledText = conversationMessage.getDisplayBody(getContext());
|
||||
|
@ -1004,6 +1017,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
|
||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
revealableStub.get().setMessage((MmsMessageRecord) messageRecord, hasWallpaper);
|
||||
revealableStub.get().setOnClickListener(revealableClickListener);
|
||||
|
@ -1020,6 +1034,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
|
||||
sharedContactStub.get().setEventListener(sharedContactEventListener);
|
||||
|
@ -1039,6 +1054,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
|
||||
|
@ -1083,6 +1099,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
|
||||
audioViewStub.get().setDownloadClickListener(singleDownloadClickListener);
|
||||
|
@ -1108,6 +1125,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
documentViewStub.get().setDocument(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(), showControls);
|
||||
|
@ -1130,6 +1148,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
|
||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
if (hasSticker(messageRecord)) {
|
||||
//noinspection ConstantConditions
|
||||
|
@ -1159,6 +1178,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
|
||||
mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(isCaptionlessMms(messageRecord) ? R.dimen.media_bubble_min_width_solo
|
||||
|
@ -1202,6 +1222,20 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
canPlayContent = (GiphyMp4PlaybackPolicy.autoplay() || allowedToPlayInline) && mediaItem != null;
|
||||
}
|
||||
|
||||
} else if (isGiftMessage(messageRecord)) {
|
||||
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(GONE);
|
||||
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(GONE);
|
||||
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(GONE);
|
||||
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
|
||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(GONE);
|
||||
|
||||
MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
|
||||
giftViewStub.get().setGiftBadge(glideRequests, Objects.requireNonNull(mmsMessageRecord.getGiftBadge()), messageRecord.isOutgoing(), giftMessageViewCallback);
|
||||
giftViewStub.get().setVisibility(VISIBLE);
|
||||
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else {
|
||||
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
|
||||
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
|
||||
|
@ -1210,6 +1244,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
|
||||
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
@ -1699,7 +1734,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
background = R.drawable.message_bubble_background_received_alone;
|
||||
outliner.setRadius(bigRadius);
|
||||
pulseOutliner.setRadius(bigRadius);
|
||||
bodyBubbleCorners = null;
|
||||
bodyBubbleCorners = new Projection.Corners(bigRadius);
|
||||
}
|
||||
} else if (isStartOfMessageCluster(current, previous, isGroupThread)) {
|
||||
if (current.isOutgoing()) {
|
||||
|
@ -1711,7 +1746,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
background = R.drawable.message_bubble_background_received_start;
|
||||
setOutlinerRadii(outliner, bigRadius, bigRadius, bigRadius, smallRadius);
|
||||
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, bigRadius, smallRadius);
|
||||
bodyBubbleCorners = null;
|
||||
bodyBubbleCorners = getBodyBubbleCorners(bigRadius, bigRadius, bigRadius, smallRadius);
|
||||
}
|
||||
} else if (isEndOfMessageCluster(current, next, isGroupThread)) {
|
||||
if (current.isOutgoing()) {
|
||||
|
@ -1723,7 +1758,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
background = R.drawable.message_bubble_background_received_end;
|
||||
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, bigRadius);
|
||||
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, bigRadius);
|
||||
bodyBubbleCorners = null;
|
||||
bodyBubbleCorners = getBodyBubbleCorners(smallRadius, bigRadius, bigRadius, bigRadius);
|
||||
}
|
||||
} else {
|
||||
if (current.isOutgoing()) {
|
||||
|
@ -1735,7 +1770,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
background = R.drawable.message_bubble_background_received_middle;
|
||||
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, smallRadius);
|
||||
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, smallRadius);
|
||||
bodyBubbleCorners = null;
|
||||
bodyBubbleCorners = getBodyBubbleCorners(smallRadius, bigRadius, bigRadius, smallRadius);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2004,6 +2039,41 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Projection getOpenableGiftProjection() {
|
||||
boolean isViewedAndIncoming = !messageRecord.isOutgoing() && messageRecord.getViewedReceiptCount() > 0;
|
||||
if (!isGiftMessage(messageRecord) || messageRecord.isRemoteDelete() || isViewedAndIncoming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners)
|
||||
.translateX(bodyBubble.getTranslationX())
|
||||
.translateX(getTranslationX())
|
||||
.scale(bodyBubble.getScaleX());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getGiftId() {
|
||||
return messageRecord.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOpenGiftCallback(@NonNull Function1<? super OpenableGift, Unit> openGift) {
|
||||
if (giftViewStub.resolved()) {
|
||||
bodyBubble.setOnClickListener(unused -> openGift.invoke(this));
|
||||
giftViewStub.get().onGiftNotOpened();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearOpenGiftCallback() {
|
||||
if (giftViewStub.resolved()) {
|
||||
bodyBubble.setOnClickListener(null);
|
||||
bodyBubble.setClickable(false);
|
||||
giftViewStub.get().onGiftOpened();
|
||||
}
|
||||
}
|
||||
|
||||
private class SharedContactEventListener implements SharedContactView.EventListener {
|
||||
@Override
|
||||
public void onAddToContactsClicked(@NonNull Contact contact) {
|
||||
|
@ -2179,6 +2249,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
}
|
||||
}
|
||||
|
||||
private class GiftMessageViewCallback implements GiftMessageView.Callback {
|
||||
|
||||
@Override
|
||||
public void onViewGiftBadgeClicked() {
|
||||
eventListener.onViewGiftBadgeClicked(messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
private class ClickListener implements View.OnClickListener {
|
||||
private OnClickListener parent;
|
||||
|
||||
|
|
|
@ -114,6 +114,7 @@ import org.thoughtcrime.securesms.TransportOption;
|
|||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
|
||||
import org.thoughtcrime.securesms.audio.AudioRecorder;
|
||||
import org.thoughtcrime.securesms.badges.gifts.thanks.GiftThanksSheet;
|
||||
import org.thoughtcrime.securesms.components.AnimatingToggle;
|
||||
import org.thoughtcrime.securesms.components.ComposeText;
|
||||
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
|
||||
|
@ -485,6 +486,9 @@ public class ConversationParentFragment extends Fragment
|
|||
|
||||
// TODO [alex] LargeScreenSupport -- This will need to be built from requireArguments()
|
||||
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
|
||||
if (savedInstanceState == null && args.getGiftBadge() != null) {
|
||||
GiftThanksSheet.show(getChildFragmentManager(), args.getRecipientId(), args.getGiftBadge());
|
||||
}
|
||||
|
||||
isSearchRequested = args.isWithSearchOpen();
|
||||
|
||||
|
@ -2979,7 +2983,7 @@ public class ConversationParentFragment extends Fragment
|
|||
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
|
||||
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null);
|
||||
List<Mention> mentions = new ArrayList<>(result.getMentions());
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, result.getStoryType(), null, false, quote, Collections.emptyList(), Collections.emptyList(), mentions);
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, result.getStoryType(), null, false, quote, Collections.emptyList(), Collections.emptyList(), mentions, null);
|
||||
OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message);
|
||||
|
||||
final Context context = requireContext().getApplicationContext();
|
||||
|
@ -3055,7 +3059,7 @@ public class ConversationParentFragment extends Fragment
|
|||
}
|
||||
}
|
||||
|
||||
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, StoryType.NONE, null, false, quote, contacts, previews, mentions);
|
||||
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, StoryType.NONE, null, false, quote, contacts, previews, mentions, null);
|
||||
|
||||
final SettableFuture<Void> future = new SettableFuture<>();
|
||||
final Context context = requireContext().getApplicationContext();
|
||||
|
|
|
@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
|||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
@ -82,6 +83,7 @@ final class MenuState {
|
|||
boolean hasInMemory = false;
|
||||
boolean hasPendingMedia = false;
|
||||
boolean mediaIsSelected = false;
|
||||
boolean hasGift = false;
|
||||
|
||||
for (MultiselectPart part : selectedParts) {
|
||||
MessageRecord messageRecord = part.getMessageRecord();
|
||||
|
@ -115,6 +117,10 @@ final class MenuState {
|
|||
if (messageRecord.isRemoteDelete()) {
|
||||
remoteDelete = true;
|
||||
}
|
||||
|
||||
if (MessageRecordUtil.hasGiftBadge(messageRecord)) {
|
||||
hasGift = true;
|
||||
}
|
||||
}
|
||||
|
||||
boolean shouldShowForwardAction = !actionMessage &&
|
||||
|
@ -122,6 +128,7 @@ final class MenuState {
|
|||
!viewOnce &&
|
||||
!remoteDelete &&
|
||||
!hasPendingMedia &&
|
||||
!hasGift &&
|
||||
selectedParts.size() <= MAX_FORWARDABLE_COUNT;
|
||||
|
||||
int uniqueRecords = selectedParts.stream()
|
||||
|
@ -144,6 +151,7 @@ final class MenuState {
|
|||
!viewOnce &&
|
||||
messageRecord.isMms() &&
|
||||
!hasPendingMedia &&
|
||||
!hasGift &&
|
||||
!messageRecord.isMmsNotification() &&
|
||||
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
|
||||
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
|
||||
|
@ -152,7 +160,7 @@ final class MenuState {
|
|||
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
|
||||
}
|
||||
|
||||
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText)
|
||||
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift)
|
||||
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
|
||||
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
|
||||
.build();
|
||||
|
@ -180,6 +188,7 @@ final class MenuState {
|
|||
messageRecord.isSecure() &&
|
||||
(!conversationRecipient.isGroup() || conversationRecipient.isActiveGroup()) &&
|
||||
!messageRecord.getRecipient().isBlocked() &&
|
||||
!MessageRecordUtil.hasGiftBadge(messageRecord) &&
|
||||
!conversationRecipient.isReleaseNotes();
|
||||
}
|
||||
|
||||
|
|
|
@ -107,7 +107,7 @@ class MultiselectForwardFragment :
|
|||
view.minimumHeight = resources.displayMetrics.heightPixels
|
||||
|
||||
val contactSearchRecycler: RecyclerView = view.findViewById(R.id.contact_selection_list)
|
||||
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), this::getConfiguration)
|
||||
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), !isSingleRecipientSelection(), this::getConfiguration)
|
||||
|
||||
callback = findListener()!!
|
||||
disposables.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
@ -155,24 +155,11 @@ class MultiselectForwardFragment :
|
|||
}
|
||||
|
||||
sendButton.setOnClickListener {
|
||||
sendButton.isEnabled = false
|
||||
|
||||
StoryDialogs.guardWithAddToYourStoryDialog(
|
||||
requireContext(),
|
||||
contactSearchMediator.getSelectedContacts(),
|
||||
onAddToStory = {
|
||||
performSend()
|
||||
},
|
||||
onEditViewers = {
|
||||
sendButton.isEnabled = true
|
||||
HideStoryFromDialogFragment().show(childFragmentManager, null)
|
||||
},
|
||||
onCancel = {
|
||||
sendButton.isEnabled = true
|
||||
}
|
||||
)
|
||||
onSend(it)
|
||||
}
|
||||
|
||||
sendButton.visible = !isSingleRecipientSelection()
|
||||
|
||||
shareSelectionRecycler.adapter = shareSelectionAdapter
|
||||
|
||||
bottomBar.visible = false
|
||||
|
@ -180,6 +167,11 @@ class MultiselectForwardFragment :
|
|||
container.addView(bottomBarAndSpacer)
|
||||
|
||||
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { contactSelection ->
|
||||
if (contactSelection.isNotEmpty() && isSingleRecipientSelection()) {
|
||||
onSend(sendButton)
|
||||
return@observe
|
||||
}
|
||||
|
||||
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
|
||||
|
||||
addMessage.visible = !forceDisableAddMessage && contactSelection.any { key -> key !is ContactSearchKey.RecipientSearchKey.Story } && getMultiShareArgs().isNotEmpty()
|
||||
|
@ -276,6 +268,25 @@ class MultiselectForwardFragment :
|
|||
.show()
|
||||
}
|
||||
|
||||
private fun onSend(sendButton: View) {
|
||||
sendButton.isEnabled = false
|
||||
|
||||
StoryDialogs.guardWithAddToYourStoryDialog(
|
||||
requireContext(),
|
||||
contactSearchMediator.getSelectedContacts(),
|
||||
onAddToStory = {
|
||||
performSend()
|
||||
},
|
||||
onEditViewers = {
|
||||
sendButton.isEnabled = true
|
||||
HideStoryFromDialogFragment().show(childFragmentManager, null)
|
||||
},
|
||||
onCancel = {
|
||||
sendButton.isEnabled = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun performSend() {
|
||||
viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
|
||||
}
|
||||
|
@ -390,6 +401,10 @@ class MultiselectForwardFragment :
|
|||
return Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)
|
||||
}
|
||||
|
||||
private fun isSingleRecipientSelection(): Boolean {
|
||||
return requireArguments().getBoolean(ARG_SELECT_SINGLE_RECIPIENT, false)
|
||||
}
|
||||
|
||||
private fun isSelectedMediaValidForStories(): Boolean {
|
||||
return requireListener<Callback>().canSendMediaToStories() && getMultiShareArgs().all { it.isValidForStories }
|
||||
}
|
||||
|
@ -422,6 +437,7 @@ class MultiselectForwardFragment :
|
|||
const val ARG_TITLE = "multiselect.forward.fragment.title"
|
||||
const val ARG_FORCE_DISABLE_ADD_MESSAGE = "multiselect.forward.fragment.force.disable.add.message"
|
||||
const val ARG_FORCE_SELECTION_ONLY = "multiselect.forward.fragment.force.disable.add.message"
|
||||
const val ARG_SELECT_SINGLE_RECIPIENT = "multiselect.forward.framgent.select.single.recipient"
|
||||
const val RESULT_KEY = "result_key"
|
||||
const val RESULT_SELECTION = "result_selection_recipients"
|
||||
const val RESULT_SENT = "result_sent"
|
||||
|
@ -460,6 +476,7 @@ class MultiselectForwardFragment :
|
|||
putInt(ARG_TITLE, multiselectForwardFragmentArgs.title)
|
||||
putBoolean(ARG_FORCE_DISABLE_ADD_MESSAGE, multiselectForwardFragmentArgs.forceDisableAddMessage)
|
||||
putBoolean(ARG_FORCE_SELECTION_ONLY, multiselectForwardFragmentArgs.forceSelectionOnly)
|
||||
putBoolean(ARG_SELECT_SINGLE_RECIPIENT, multiselectForwardFragmentArgs.selectSingleRecipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,13 +28,15 @@ import java.util.function.Consumer
|
|||
* @param title The title to display at the top of the sheet
|
||||
* @param forceDisableAddMessage Hide the add message field even if it would normally be available.
|
||||
* @param forceSelectionOnly Force the fragment to only select recipients, never actually performing the send.
|
||||
* @param selectSingleRecipient Only allow the selection of a single recipient.
|
||||
*/
|
||||
class MultiselectForwardFragmentArgs @JvmOverloads constructor(
|
||||
val canSendToNonPush: Boolean,
|
||||
val multiShareArgs: List<MultiShareArgs> = listOf(),
|
||||
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to,
|
||||
val forceDisableAddMessage: Boolean = false,
|
||||
val forceSelectionOnly: Boolean = false
|
||||
val forceSelectionOnly: Boolean = false,
|
||||
val selectSingleRecipient: Boolean = false
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -121,6 +121,9 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
|
|||
public abstract void markSmsStatus(long id, int status);
|
||||
public abstract void markDownloadState(long messageId, long state);
|
||||
public abstract void markIncomingNotificationReceived(long threadId);
|
||||
public abstract void markGiftRedemptionCompleted(long messageId);
|
||||
public abstract void markGiftRedemptionStarted(long messageId);
|
||||
public abstract void markGiftRedemptionFailed(long messageId);
|
||||
|
||||
public abstract Set<MessageUpdate> incrementReceiptCount(SyncMessageId messageId, long timestamp, @NonNull ReceiptType receiptType, boolean storiesOnly);
|
||||
abstract @NonNull MmsSmsDatabase.TimestampReadResult setTimestampRead(SyncMessageId messageId, long proposedExpireStarted, @NonNull Map<Long, Long> threadToLatestRead);
|
||||
|
|
|
@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.database.model.StoryResult;
|
|||
import org.thoughtcrime.securesms.database.model.StoryType;
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
|
||||
|
@ -83,6 +84,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
|
|||
import org.thoughtcrime.securesms.sms.IncomingTextMessage;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
@ -1578,6 +1580,11 @@ public class MmsDatabase extends MessageDatabase {
|
|||
return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn);
|
||||
}
|
||||
|
||||
GiftBadge giftBadge = null;
|
||||
if (body != null && Types.isGiftBadge(outboxType)) {
|
||||
giftBadge = GiftBadge.parseFrom(Base64.decode(body));
|
||||
}
|
||||
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
|
||||
body,
|
||||
attachments,
|
||||
|
@ -1594,7 +1601,8 @@ public class MmsDatabase extends MessageDatabase {
|
|||
previews,
|
||||
mentions,
|
||||
networkFailures,
|
||||
mismatches);
|
||||
mismatches,
|
||||
giftBadge);
|
||||
|
||||
if (Types.isSecureType(outboxType)) {
|
||||
return new OutgoingSecureMediaMessage(message);
|
||||
|
@ -1791,10 +1799,20 @@ public class MmsDatabase extends MessageDatabase {
|
|||
type |= Types.EXPIRATION_TIMER_UPDATE_BIT;
|
||||
}
|
||||
|
||||
boolean hasSpecialType = false;
|
||||
if (retrieved.isStoryReaction()) {
|
||||
hasSpecialType = true;
|
||||
type |= Types.SPECIAL_TYPE_STORY_REACTION;
|
||||
}
|
||||
|
||||
if (retrieved.getGiftBadge() != null) {
|
||||
if (hasSpecialType) {
|
||||
throw new MmsException("Cannot insert message with multiple special types.");
|
||||
}
|
||||
|
||||
type |= Types.SPECIAL_TYPE_GIFT_BADGE;
|
||||
}
|
||||
|
||||
return insertMessageInbox(retrieved, "", threadId, type);
|
||||
}
|
||||
|
||||
|
@ -1858,6 +1876,55 @@ public class MmsDatabase extends MessageDatabase {
|
|||
TrimThreadJob.enqueueAsync(threadId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markGiftRedemptionCompleted(long messageId) {
|
||||
markGiftRedemptionState(messageId, GiftBadge.RedemptionState.REDEEMED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markGiftRedemptionStarted(long messageId) {
|
||||
markGiftRedemptionState(messageId, GiftBadge.RedemptionState.STARTED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markGiftRedemptionFailed(long messageId) {
|
||||
markGiftRedemptionState(messageId, GiftBadge.RedemptionState.FAILED);
|
||||
}
|
||||
|
||||
private void markGiftRedemptionState(long messageId, @NonNull GiftBadge.RedemptionState redemptionState) {
|
||||
String[] projection = SqlUtil.buildArgs(BODY, THREAD_ID);
|
||||
String where = "(" + MESSAGE_BOX + " & " + Types.SPECIAL_TYPES_MASK + " = " + Types.SPECIAL_TYPE_GIFT_BADGE + ") AND " +
|
||||
ID + " = ?";
|
||||
String[] args = SqlUtil.buildArgs(messageId);
|
||||
boolean updated = false;
|
||||
long threadId = -1;
|
||||
|
||||
getWritableDatabase().beginTransaction();
|
||||
try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, projection, where, args, null, null, null)) {
|
||||
if (cursor.moveToFirst()) {
|
||||
GiftBadge giftBadge = GiftBadge.parseFrom(Base64.decode(CursorUtil.requireString(cursor, BODY)));
|
||||
GiftBadge updatedBadge = giftBadge.toBuilder().setRedemptionState(redemptionState).build();
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
|
||||
contentValues.put(BODY, Base64.encodeBytes(updatedBadge.toByteArray()));
|
||||
|
||||
updated = getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, args) > 0;
|
||||
threadId = CursorUtil.requireLong(cursor, THREAD_ID);
|
||||
|
||||
getWritableDatabase().setTransactionSuccessful();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to mark gift badge " + redemptionState.name(), e, true);
|
||||
} finally {
|
||||
getWritableDatabase().endTransaction();
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId, true));
|
||||
notifyConversationListeners(threadId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long insertMessageOutbox(@NonNull OutgoingMediaMessage message,
|
||||
long threadId,
|
||||
|
@ -1899,10 +1966,20 @@ public class MmsDatabase extends MessageDatabase {
|
|||
type |= Types.EXPIRATION_TIMER_UPDATE_BIT;
|
||||
}
|
||||
|
||||
boolean hasSpecialType = false;
|
||||
if (message.isStoryReaction()) {
|
||||
hasSpecialType = true;
|
||||
type |= Types.SPECIAL_TYPE_STORY_REACTION;
|
||||
}
|
||||
|
||||
if (message.getGiftBadge() != null) {
|
||||
if (hasSpecialType) {
|
||||
throw new MmsException("Cannot insert message with multiple special types.");
|
||||
}
|
||||
|
||||
type |= Types.SPECIAL_TYPE_GIFT_BADGE;
|
||||
}
|
||||
|
||||
Map<RecipientId, EarlyReceiptCache.Receipt> earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.getSentTimeMillis());
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
|
@ -2421,7 +2498,8 @@ public class MmsDatabase extends MessageDatabase {
|
|||
-1,
|
||||
null,
|
||||
message.getStoryType(),
|
||||
message.getParentStoryId());
|
||||
message.getParentStoryId(),
|
||||
message.getGiftBadge());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2476,6 +2554,7 @@ public class MmsDatabase extends MessageDatabase {
|
|||
long receiptTimestamp = CursorUtil.requireLong(cursor, MmsSmsColumns.RECEIPT_TIMESTAMP);
|
||||
StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE));
|
||||
ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID));
|
||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY));
|
||||
|
||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
||||
readReceiptCount = 0;
|
||||
|
@ -2492,13 +2571,21 @@ public class MmsDatabase extends MessageDatabase {
|
|||
|
||||
SlideDeck slideDeck = new SlideDeck(context, new MmsNotificationAttachment(status, messageSize));
|
||||
|
||||
GiftBadge giftBadge = null;
|
||||
if (body != null && Types.isGiftBadge(mailbox)) {
|
||||
try {
|
||||
giftBadge = GiftBadge.parseFrom(Base64.decode(body));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Error parsing gift badge", e);
|
||||
}
|
||||
}
|
||||
|
||||
return new NotificationMmsMessageRecord(id, recipient, recipient,
|
||||
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId,
|
||||
contentLocationBytes, messageSize, expiry, status,
|
||||
transactionIdBytes, mailbox, subscriptionId, slideDeck,
|
||||
readReceiptCount, viewedReceiptCount, receiptTimestamp, storyType,
|
||||
parentStoryId);
|
||||
parentStoryId, giftBadge);
|
||||
}
|
||||
|
||||
private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) {
|
||||
|
@ -2558,13 +2645,22 @@ public class MmsDatabase extends MessageDatabase {
|
|||
Log.w(TAG, "Error parsing message ranges", e);
|
||||
}
|
||||
|
||||
GiftBadge giftBadge = null;
|
||||
if (body != null && Types.isGiftBadge(box)) {
|
||||
try {
|
||||
giftBadge = GiftBadge.parseFrom(Base64.decode(body));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Error parsing gift badge", e);
|
||||
}
|
||||
}
|
||||
|
||||
return new MediaMmsMessageRecord(id, recipient, recipient,
|
||||
addressDeviceId, dateSent, dateReceived, dateServer, deliveryReceiptCount,
|
||||
threadId, body, slideDeck, partCount, box, mismatches,
|
||||
networkFailures, subscriptionId, expiresIn, expireStarted,
|
||||
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(),
|
||||
remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges,
|
||||
storyType, parentStoryId);
|
||||
storyType, parentStoryId, giftBadge);
|
||||
}
|
||||
|
||||
private Set<IdentityKeyMismatch> getMismatchedIdentities(String document) {
|
||||
|
|
|
@ -138,13 +138,14 @@ public interface MmsSmsColumns {
|
|||
// Special message types
|
||||
public static final long SPECIAL_TYPES_MASK = 0xF00000000L;
|
||||
public static final long SPECIAL_TYPE_STORY_REACTION = 0x100000000L;
|
||||
|
||||
public static boolean isSpecialType(long type) {
|
||||
return (type & SPECIAL_TYPES_MASK) != 0L;
|
||||
}
|
||||
public static final long SPECIAL_TYPE_GIFT_BADGE = 0x200000000L;
|
||||
|
||||
public static boolean isStoryReaction(long type) {
|
||||
return (type & SPECIAL_TYPE_STORY_REACTION) == SPECIAL_TYPE_STORY_REACTION;
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_STORY_REACTION;
|
||||
}
|
||||
|
||||
public static boolean isGiftBadge(long type) {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_GIFT_BADGE;
|
||||
}
|
||||
|
||||
public static boolean isDraftMessageType(long type) {
|
||||
|
|
|
@ -1491,6 +1491,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
|||
value = Bitmask.update(value, Capabilities.ANNOUNCEMENT_GROUPS, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isAnnouncementGroup).serialize().toLong())
|
||||
value = Bitmask.update(value, Capabilities.CHANGE_NUMBER, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isChangeNumber).serialize().toLong())
|
||||
value = Bitmask.update(value, Capabilities.STORIES, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isStories).serialize().toLong())
|
||||
value = Bitmask.update(value, Capabilities.GIFT_BADGES, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isGiftBadges).serialize().toLong())
|
||||
|
||||
val values = ContentValues(1).apply {
|
||||
put(CAPABILITIES, value)
|
||||
|
@ -3086,6 +3087,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
|||
announcementGroupCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.ANNOUNCEMENT_GROUPS, Capabilities.BIT_LENGTH).toInt()),
|
||||
changeNumberCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.CHANGE_NUMBER, Capabilities.BIT_LENGTH).toInt()),
|
||||
storiesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.STORIES, Capabilities.BIT_LENGTH).toInt()),
|
||||
giftBadgesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.GIFT_BADGES, Capabilities.BIT_LENGTH).toInt()),
|
||||
insightsBannerTier = InsightsBannerTier.fromId(cursor.requireInt(SEEN_INVITE_REMINDER)),
|
||||
storageId = Base64.decodeNullableOrThrow(cursor.requireString(STORAGE_SERVICE_ID)),
|
||||
mentionSetting = MentionSetting.fromId(cursor.requireInt(MENTION_SETTING)),
|
||||
|
@ -3403,6 +3405,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
|
|||
const val ANNOUNCEMENT_GROUPS = 3
|
||||
const val CHANGE_NUMBER = 4
|
||||
const val STORIES = 5
|
||||
const val GIFT_BADGES = 6
|
||||
}
|
||||
|
||||
enum class VibrateState(val id: Int) {
|
||||
|
|
|
@ -1696,6 +1696,21 @@ public class SmsDatabase extends MessageDatabase {
|
|||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markGiftRedemptionCompleted(long messageId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markGiftRedemptionStarted(long messageId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markGiftRedemptionFailed(long messageId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageDatabase.Reader getMessages(Collection<Long> messageIds) {
|
||||
throw new UnsupportedOperationException();
|
||||
|
|
|
@ -573,10 +573,10 @@ public class ThreadDatabase extends Database {
|
|||
}
|
||||
|
||||
public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean hideV1Groups) {
|
||||
return getRecentConversationList(limit, includeInactiveGroups, false, hideV1Groups, false);
|
||||
return getRecentConversationList(limit, includeInactiveGroups, false, false, hideV1Groups, false);
|
||||
}
|
||||
|
||||
public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean groupsOnly, boolean hideV1Groups, boolean hideSms) {
|
||||
public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean individualsOnly, boolean groupsOnly, boolean hideV1Groups, boolean hideSms) {
|
||||
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
|
||||
String query = !includeInactiveGroups ? MEANINGFUL_MESSAGES + " != 0 AND (" + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " IS NULL OR " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1)"
|
||||
: MEANINGFUL_MESSAGES + " != 0";
|
||||
|
@ -585,6 +585,10 @@ public class ThreadDatabase extends Database {
|
|||
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " NOT NULL";
|
||||
}
|
||||
|
||||
if (individualsOnly) {
|
||||
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " IS NULL";
|
||||
}
|
||||
|
||||
if (hideV1Groups) {
|
||||
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_TYPE + " != " + RecipientDatabase.GroupType.SIGNAL_V1.getId();
|
||||
}
|
||||
|
|
|
@ -13,7 +13,8 @@ data class DonationReceiptRecord(
|
|||
) {
|
||||
enum class Type(val code: String) {
|
||||
RECURRING("recurring"),
|
||||
BOOST("boost");
|
||||
BOOST("boost"),
|
||||
GIFT("gift");
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String): Type {
|
||||
|
@ -46,5 +47,15 @@ data class DonationReceiptRecord(
|
|||
type = Type.BOOST
|
||||
)
|
||||
}
|
||||
|
||||
fun createForGift(amount: FiatMoney): DonationReceiptRecord {
|
||||
return DonationReceiptRecord(
|
||||
id = -1L,
|
||||
amount = amount,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
subscriptionLevel = -1,
|
||||
type = Type.GIFT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.database.SmsDatabase.Status;
|
|||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
@ -93,13 +94,14 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
|||
long receiptTimestamp,
|
||||
@Nullable BodyRangeList messageRanges,
|
||||
@NonNull StoryType storyType,
|
||||
@Nullable ParentStoryId parentStoryId)
|
||||
@Nullable ParentStoryId parentStoryId,
|
||||
@Nullable GiftBadge giftBadge)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
|
||||
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
|
||||
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
|
||||
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp,
|
||||
storyType, parentStoryId);
|
||||
storyType, parentStoryId, giftBadge);
|
||||
this.partCount = partCount;
|
||||
this.mentionsSelf = mentionsSelf;
|
||||
this.messageRanges = messageRanges;
|
||||
|
@ -153,7 +155,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
|||
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId());
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge());
|
||||
}
|
||||
|
||||
public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List<DatabaseAttachment> attachments) {
|
||||
|
@ -174,7 +176,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
|||
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), slideDeck,
|
||||
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||
getReadReceiptCount(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId());
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge());
|
||||
}
|
||||
|
||||
private static @NonNull List<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {
|
||||
|
|
|
@ -7,6 +7,7 @@ import androidx.annotation.Nullable;
|
|||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
|
@ -24,6 +25,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
|||
private final @NonNull List<LinkPreview> linkPreviews = new LinkedList<>();
|
||||
private final @NonNull StoryType storyType;
|
||||
private final @Nullable ParentStoryId parentStoryId;
|
||||
private final @Nullable GiftBadge giftBadge;
|
||||
|
||||
private final boolean viewOnce;
|
||||
|
||||
|
@ -38,7 +40,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
|||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
|
||||
@NonNull List<ReactionRecord> reactions, boolean remoteDelete, long notifiedTimestamp,
|
||||
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
|
||||
@Nullable ParentStoryId parentStoryId)
|
||||
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId,
|
||||
dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount,
|
||||
|
@ -50,6 +52,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
|||
this.viewOnce = viewOnce;
|
||||
this.storyType = storyType;
|
||||
this.parentStoryId = parentStoryId;
|
||||
this.giftBadge = giftBadge;
|
||||
|
||||
this.contacts.addAll(contacts);
|
||||
this.linkPreviews.addAll(linkPreviews);
|
||||
|
@ -104,4 +107,8 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
|||
public @NonNull List<LinkPreview> getLinkPreviews() {
|
||||
return linkPreviews;
|
||||
}
|
||||
|
||||
public @Nullable GiftBadge getGiftBadge() {
|
||||
return giftBadge;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ import androidx.annotation.Nullable;
|
|||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
|
@ -54,13 +55,13 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
|
|||
long expiry, int status, byte[] transactionId, long mailbox,
|
||||
int subscriptionId, SlideDeck slideDeck, int readReceiptCount,
|
||||
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
|
||||
@Nullable ParentStoryId parentStoryId)
|
||||
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge)
|
||||
{
|
||||
super(id, "", conversationRecipient, individualRecipient, recipientDeviceId,
|
||||
dateSent, dateReceived, -1, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
|
||||
new HashSet<>(), new HashSet<>(), subscriptionId,
|
||||
0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false,
|
||||
Collections.emptyList(), false, 0, viewedReceiptCount, receiptTimestamp, storyType, parentStoryId);
|
||||
Collections.emptyList(), false, 0, viewedReceiptCount, receiptTimestamp, storyType, parentStoryId, giftBadge);
|
||||
|
||||
this.contentLocation = contentLocation;
|
||||
this.messageSize = messageSize;
|
||||
|
|
|
@ -70,6 +70,7 @@ data class RecipientRecord(
|
|||
val announcementGroupCapability: Recipient.Capability,
|
||||
val changeNumberCapability: Recipient.Capability,
|
||||
val storiesCapability: Recipient.Capability,
|
||||
val giftBadgesCapability: Recipient.Capability,
|
||||
val insightsBannerTier: InsightsBannerTier,
|
||||
val storageId: ByteArray?,
|
||||
val mentionSetting: MentionSetting,
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
package org.thoughtcrime.securesms.glide
|
||||
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.Key
|
||||
import com.bumptech.glide.load.Options
|
||||
import com.bumptech.glide.load.data.DataFetcher
|
||||
import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.load.model.ModelLoader
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.OkHttpClient
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
|
||||
import org.whispersystems.signalservice.api.push.TrustStore
|
||||
import org.whispersystems.signalservice.api.util.Tls12SocketFactory
|
||||
import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
import java.io.InputStream
|
||||
import java.lang.Exception
|
||||
import java.security.KeyManagementException
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.Locale
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* Glide Model allowing the direct loading of a GiftBadge.
|
||||
*
|
||||
* This model will first resolve a GiftBadge into a Badge, and then it will delegate to the Badge loader.
|
||||
*/
|
||||
data class GiftBadgeModel(val giftBadge: GiftBadge) : Key {
|
||||
class Loader(val client: OkHttpClient) : ModelLoader<GiftBadgeModel, InputStream> {
|
||||
override fun buildLoadData(model: GiftBadgeModel, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream>? {
|
||||
return ModelLoader.LoadData(model, Fetcher(client, model))
|
||||
}
|
||||
|
||||
override fun handles(model: GiftBadgeModel): Boolean = true
|
||||
}
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
}
|
||||
|
||||
class Fetcher(
|
||||
private val client: OkHttpClient,
|
||||
private val giftBadge: GiftBadgeModel
|
||||
) : DataFetcher<InputStream> {
|
||||
|
||||
private var okHttpStreamFetcher: OkHttpStreamFetcher? = null
|
||||
|
||||
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) {
|
||||
try {
|
||||
val receiptCredentialPresentation = ReceiptCredentialPresentation(giftBadge.giftBadge.redemptionToken.toByteArray())
|
||||
val giftBadgeResponse = ApplicationDependencies.getDonationsService().getGiftBadge(Locale.getDefault(), receiptCredentialPresentation.receiptLevel).blockingGet()
|
||||
if (giftBadgeResponse.result.isPresent) {
|
||||
val badge = Badges.fromServiceBadge(giftBadgeResponse.result.get())
|
||||
okHttpStreamFetcher = OkHttpStreamFetcher(client, GlideUrl(badge.imageUrl.toString()))
|
||||
okHttpStreamFetcher?.loadData(priority, callback)
|
||||
} else if (giftBadgeResponse.applicationError.isPresent) {
|
||||
callback.onLoadFailed(Exception(giftBadgeResponse.applicationError.get()))
|
||||
} else if (giftBadgeResponse.executionError.isPresent) {
|
||||
callback.onLoadFailed(Exception(giftBadgeResponse.executionError.get()))
|
||||
} else {
|
||||
callback.onLoadFailed(Exception("No result or error in service response."))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onLoadFailed(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
okHttpStreamFetcher?.cleanup()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
okHttpStreamFetcher?.cancel()
|
||||
}
|
||||
|
||||
override fun getDataClass(): Class<InputStream> {
|
||||
return InputStream::class.java
|
||||
}
|
||||
|
||||
override fun getDataSource(): DataSource {
|
||||
return DataSource.REMOTE
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val client: OkHttpClient) : ModelLoaderFactory<GiftBadgeModel, InputStream> {
|
||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<GiftBadgeModel, InputStream> {
|
||||
return Loader(client)
|
||||
}
|
||||
|
||||
override fun teardown() {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createFactory(): Factory {
|
||||
return try {
|
||||
val baseClient = ApplicationDependencies.getOkHttpClient()
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
val trustStore: TrustStore = SignalServiceTrustStore(ApplicationDependencies.getApplication())
|
||||
val trustManagers = BlacklistingTrustManager.createFor(trustStore)
|
||||
|
||||
sslContext.init(null, trustManagers, null)
|
||||
|
||||
val client = baseClient.newBuilder()
|
||||
.sslSocketFactory(Tls12SocketFactory(sslContext.socketFactory), trustManagers[0] as X509TrustManager)
|
||||
.connectionSpecs(Util.immutableList(ConnectionSpec.RESTRICTED_TLS))
|
||||
.build()
|
||||
|
||||
Factory(client)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: KeyManagementException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -23,6 +23,8 @@ import org.thoughtcrime.securesms.jobmanager.Data;
|
|||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
@ -39,29 +41,38 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
|
||||
public static final String KEY = "BoostReceiptCredentialsSubmissionJob";
|
||||
|
||||
private static final String BOOST_QUEUE = "BoostReceiptRedemption";
|
||||
private static final String GIFT_QUEUE = "GiftReceiptRedemption";
|
||||
|
||||
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
|
||||
private static final String DATA_PAYMENT_INTENT_ID = "data.payment.intent.id";
|
||||
private static final String DATA_ERROR_SOURCE = "data.error.source";
|
||||
private static final String DATA_BADGE_LEVEL = "data.badge.level";
|
||||
|
||||
private ReceiptCredentialRequestContext requestContext;
|
||||
|
||||
private final String paymentIntentId;
|
||||
private final DonationErrorSource donationErrorSource;
|
||||
private final String paymentIntentId;
|
||||
private final long badgeLevel;
|
||||
|
||||
static BoostReceiptRequestResponseJob createJob(StripeApi.PaymentIntent paymentIntent) {
|
||||
private static BoostReceiptRequestResponseJob createJob(StripeApi.PaymentIntent paymentIntent, DonationErrorSource donationErrorSource, long badgeLevel) {
|
||||
return new BoostReceiptRequestResponseJob(
|
||||
new Parameters
|
||||
.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue("BoostReceiptRedemption")
|
||||
.setQueue(donationErrorSource == DonationErrorSource.BOOST ? BOOST_QUEUE : GIFT_QUEUE)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build(),
|
||||
null,
|
||||
paymentIntent.getId()
|
||||
paymentIntent.getId(),
|
||||
donationErrorSource,
|
||||
badgeLevel
|
||||
);
|
||||
}
|
||||
|
||||
public static JobManager.Chain createJobChain(StripeApi.PaymentIntent paymentIntent) {
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent);
|
||||
public static JobManager.Chain createJobChainForBoost(@NonNull StripeApi.PaymentIntent paymentIntent) {
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
|
||||
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
|
||||
|
||||
|
@ -71,18 +82,38 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
.then(refreshOwnProfileJob);
|
||||
}
|
||||
|
||||
public static JobManager.Chain createJobChainForGift(@NonNull StripeApi.PaymentIntent paymentIntent,
|
||||
@NonNull RecipientId recipientId,
|
||||
@Nullable String additionalMessage,
|
||||
long badgeLevel)
|
||||
{
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.GIFT, badgeLevel);
|
||||
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
|
||||
|
||||
|
||||
return ApplicationDependencies.getJobManager()
|
||||
.startChain(requestReceiptJob)
|
||||
.then(giftSendJob);
|
||||
}
|
||||
|
||||
private BoostReceiptRequestResponseJob(@NonNull Parameters parameters,
|
||||
@Nullable ReceiptCredentialRequestContext requestContext,
|
||||
@NonNull String paymentIntentId)
|
||||
@NonNull String paymentIntentId,
|
||||
@NonNull DonationErrorSource donationErrorSource,
|
||||
long badgeLevel)
|
||||
{
|
||||
super(parameters);
|
||||
this.requestContext = requestContext;
|
||||
this.paymentIntentId = paymentIntentId;
|
||||
this.requestContext = requestContext;
|
||||
this.paymentIntentId = paymentIntentId;
|
||||
this.donationErrorSource = donationErrorSource;
|
||||
this.badgeLevel = badgeLevel;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
Data.Builder builder = new Data.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId);
|
||||
Data.Builder builder = new Data.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId)
|
||||
.putString(DATA_ERROR_SOURCE, donationErrorSource.serialize())
|
||||
.putLong(DATA_BADGE_LEVEL, badgeLevel);
|
||||
|
||||
if (requestContext != null) {
|
||||
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
|
||||
|
@ -124,16 +155,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
.blockingGet();
|
||||
|
||||
if (response.getApplicationError().isPresent()) {
|
||||
handleApplicationError(context, response);
|
||||
handleApplicationError(context, response, donationErrorSource);
|
||||
} else if (response.getResult().isPresent()) {
|
||||
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
|
||||
|
||||
if (!isCredentialValid(receiptCredential)) {
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST));
|
||||
DonationError.routeDonationError(context, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
|
||||
throw new IOException("Could not validate receipt credential");
|
||||
}
|
||||
|
||||
Log.d(TAG, "Validated credential. Handing off to redemption job.", true);
|
||||
Log.d(TAG, "Validated credential. Handing off to next job.", true);
|
||||
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
|
||||
setOutputData(new Data.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
|
||||
receiptCredentialPresentation.serialize())
|
||||
|
@ -144,7 +175,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
}
|
||||
}
|
||||
|
||||
private static void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
|
||||
private static void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
|
||||
Throwable applicationException = response.getApplicationError().get();
|
||||
switch (response.getStatus()) {
|
||||
case 204:
|
||||
|
@ -152,15 +183,15 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
throw new RetryableException();
|
||||
case 400:
|
||||
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST));
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
throw new Exception(applicationException);
|
||||
case 402:
|
||||
Log.w(TAG, "User payment failed.", applicationException, true);
|
||||
DonationError.routeDonationError(context, DonationError.genericPaymentFailure(DonationErrorSource.BOOST));
|
||||
DonationError.routeDonationError(context, DonationError.genericPaymentFailure(donationErrorSource));
|
||||
throw new Exception(applicationException);
|
||||
case 409:
|
||||
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST));
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
throw new Exception(applicationException);
|
||||
default:
|
||||
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true);
|
||||
|
@ -197,17 +228,17 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
|
||||
* - expiration time should have the following characteristics:
|
||||
* - expiration_time mod 86400 == 0
|
||||
* - expiration_time is between now and 60 days from now
|
||||
* - expiration_time is between now and 90 days from now
|
||||
*/
|
||||
private boolean isCredentialValid(@NonNull ReceiptCredential receiptCredential) {
|
||||
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
||||
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(60);
|
||||
boolean isCorrectLevel = receiptCredential.getReceiptLevel() == 1;
|
||||
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(90);
|
||||
boolean isCorrectLevel = receiptCredential.getReceiptLevel() == badgeLevel;
|
||||
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
|
||||
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
|
||||
boolean isExpirationWithinMax = receiptCredential.getReceiptExpirationTime() <= maxExpirationTime;
|
||||
|
||||
Log.d(TAG, "Credential validation: isCorrectLevel(" + isCorrectLevel +
|
||||
Log.d(TAG, "Credential validation: isCorrectLevel(" + isCorrectLevel + " actual: " + receiptCredential.getReceiptLevel() + ", expected: " + badgeLevel +
|
||||
") isExpiration86400(" + isExpiration86400 +
|
||||
") isExpirationInTheFuture(" + isExpirationInTheFuture +
|
||||
") isExpirationWithinMax(" + isExpirationWithinMax + ")", true);
|
||||
|
@ -226,16 +257,18 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
public static class Factory implements Job.Factory<BoostReceiptRequestResponseJob> {
|
||||
@Override
|
||||
public @NonNull BoostReceiptRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
|
||||
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
|
||||
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.BOOST.serialize()));
|
||||
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
|
||||
|
||||
try {
|
||||
if (data.hasString(DATA_REQUEST_BYTES)) {
|
||||
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
|
||||
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
|
||||
|
||||
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId);
|
||||
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel);
|
||||
} else {
|
||||
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId);
|
||||
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel);
|
||||
}
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IllegalStateException(e);
|
||||
|
|
|
@ -68,7 +68,7 @@ public class ConversationShortcutUpdateJob extends BaseJob {
|
|||
int maxShortcuts = ConversationUtil.getMaxShortcuts(context);
|
||||
List<Recipient> ranked = new ArrayList<>(maxShortcuts);
|
||||
|
||||
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentConversationList(maxShortcuts, false, false, true, !Util.isDefaultSmsProvider(context)))) {
|
||||
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentConversationList(maxShortcuts, false, false, false, true, !Util.isDefaultSmsProvider(context)))) {
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null) {
|
||||
ranked.add(record.getRecipient().resolve());
|
||||
|
|
|
@ -1,16 +1,25 @@
|
|||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
|
@ -23,17 +32,24 @@ import java.util.concurrent.TimeUnit;
|
|||
* presentation object via setOutputData. This is expected to be the byte[] blob of a ReceiptCredentialPresentation object.
|
||||
*/
|
||||
public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
|
||||
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
|
||||
private static final long NO_ID = -1L;
|
||||
|
||||
public static final String SUBSCRIPTION_QUEUE = "ReceiptRedemption";
|
||||
public static final String KEY = "DonationReceiptRedemptionJob";
|
||||
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
|
||||
public static final String DATA_ERROR_SOURCE = "data.error.source";
|
||||
public static final String DATA_GIFT_MESSAGE_ID = "data.gift.message.id";
|
||||
public static final String DATA_PRIMARY = "data.primary";
|
||||
|
||||
private final long giftMessageId;
|
||||
private final boolean makePrimary;
|
||||
private final DonationErrorSource errorSource;
|
||||
|
||||
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource) {
|
||||
return new DonationReceiptRedemptionJob(
|
||||
NO_ID,
|
||||
false,
|
||||
errorSource,
|
||||
new Job.Parameters
|
||||
.Builder()
|
||||
|
@ -47,6 +63,8 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
|
||||
public static DonationReceiptRedemptionJob createJobForBoost() {
|
||||
return new DonationReceiptRedemptionJob(
|
||||
NO_ID,
|
||||
false,
|
||||
DonationErrorSource.BOOST,
|
||||
new Job.Parameters
|
||||
.Builder()
|
||||
|
@ -57,14 +75,40 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
.build());
|
||||
}
|
||||
|
||||
private DonationReceiptRedemptionJob(@NonNull DonationErrorSource errorSource, @NonNull Job.Parameters parameters) {
|
||||
public static JobManager.Chain createJobChainForGift(long messageId, boolean primary) {
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = new DonationReceiptRedemptionJob(
|
||||
messageId,
|
||||
primary,
|
||||
DonationErrorSource.GIFT_REDEMPTION,
|
||||
new Job.Parameters
|
||||
.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue("GiftReceiptRedemption-" + messageId)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.build());
|
||||
|
||||
RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob();
|
||||
|
||||
return ApplicationDependencies.getJobManager()
|
||||
.startChain(redeemReceiptJob)
|
||||
.then(refreshOwnProfileJob);
|
||||
}
|
||||
|
||||
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, @NonNull Job.Parameters parameters) {
|
||||
super(parameters);
|
||||
this.errorSource = errorSource;
|
||||
this.giftMessageId = giftMessageId;
|
||||
this.makePrimary = primary;
|
||||
this.errorSource = errorSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return new Data.Builder().putString(DATA_ERROR_SOURCE, errorSource.serialize()).build();
|
||||
return new Data.Builder()
|
||||
.putString(DATA_ERROR_SOURCE, errorSource.serialize())
|
||||
.putLong(DATA_GIFT_MESSAGE_ID, giftMessageId)
|
||||
.putBoolean(DATA_PRIMARY, makePrimary)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -78,31 +122,31 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
Log.d(TAG, "Marking subscription failure", true);
|
||||
SignalStore.donationsValues().markSubscriptionRedemptionFailed();
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue();
|
||||
} else if (giftMessageId != NO_ID) {
|
||||
SignalDatabase.mms().markGiftRedemptionFailed(giftMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAdded() {
|
||||
if (giftMessageId != NO_ID) {
|
||||
SignalDatabase.mms().markGiftRedemptionStarted(giftMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
Data inputData = getInputData();
|
||||
|
||||
if (inputData == null) {
|
||||
Log.w(TAG, "No input data. Exiting.", null, true);
|
||||
ReceiptCredentialPresentation presentation = getPresentation();
|
||||
if (presentation == null) {
|
||||
Log.d(TAG, "No presentation available. Exiting.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION);
|
||||
if (presentationBytes == null) {
|
||||
Log.d(TAG, "No response data. Exiting.", null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
ReceiptCredentialPresentation presentation = new ReceiptCredentialPresentation(presentationBytes);
|
||||
|
||||
Log.d(TAG, "Attempting to redeem token... isForSubscription: " + isForSubscription(), true);
|
||||
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
|
||||
.redeemReceipt(presentation,
|
||||
SignalStore.donationsValues().getDisplayBadgesOnProfile(),
|
||||
false)
|
||||
makePrimary)
|
||||
.blockingGet();
|
||||
|
||||
if (response.getApplicationError().isPresent()) {
|
||||
|
@ -124,6 +168,61 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
if (isForSubscription()) {
|
||||
Log.d(TAG, "Clearing subscription failure", true);
|
||||
SignalStore.donationsValues().clearSubscriptionRedemptionFailed();
|
||||
} else if (giftMessageId != NO_ID) {
|
||||
Log.d(TAG, "Marking gift redemption completed for " + giftMessageId);
|
||||
SignalDatabase.mms().markGiftRedemptionCompleted(giftMessageId);
|
||||
MessageDatabase.MarkedMessageInfo markedMessageInfo = SignalDatabase.mms().setIncomingMessageViewed(giftMessageId);
|
||||
if (markedMessageInfo != null) {
|
||||
Log.d(TAG, "Marked gift message viewed for " + giftMessageId);
|
||||
ApplicationDependencies.getJobManager()
|
||||
.add(new SendViewedReceiptJob(markedMessageInfo.getThreadId(),
|
||||
markedMessageInfo.getSyncMessageId().getRecipientId(),
|
||||
markedMessageInfo.getSyncMessageId().getTimetamp(),
|
||||
markedMessageInfo.getMessageId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable ReceiptCredentialPresentation getPresentation() throws InvalidInputException, NoSuchMessageException {
|
||||
if (giftMessageId == NO_ID) {
|
||||
return getPresentationFromInputData();
|
||||
} else {
|
||||
return getPresentationFromGiftMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable ReceiptCredentialPresentation getPresentationFromInputData() throws InvalidInputException {
|
||||
Data inputData = getInputData();
|
||||
|
||||
if (inputData == null) {
|
||||
Log.w(TAG, "No input data. Exiting.", true);
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION);
|
||||
if (presentationBytes == null) {
|
||||
Log.d(TAG, "No response data. Exiting.", true);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReceiptCredentialPresentation(presentationBytes);
|
||||
}
|
||||
|
||||
private @Nullable ReceiptCredentialPresentation getPresentationFromGiftMessage() throws InvalidInputException, NoSuchMessageException {
|
||||
MessageRecord messageRecord = SignalDatabase.mms().getMessageRecord(giftMessageId);
|
||||
|
||||
if (MessageRecordUtil.hasGiftBadge(messageRecord)) {
|
||||
GiftBadge giftBadge = MessageRecordUtil.requireGiftBadge(messageRecord);
|
||||
if (giftBadge.getRedemptionState() == GiftBadge.RedemptionState.REDEEMED) {
|
||||
Log.d(TAG, "Already redeemed this gift badge. Exiting.", true);
|
||||
return null;
|
||||
} else {
|
||||
Log.d(TAG, "Attempting redemption of badge in state " + giftBadge.getRedemptionState().name());
|
||||
return new ReceiptCredentialPresentation(giftBadge.getRedemptionToken().toByteArray());
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No gift badge on message record. Exiting.", true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -143,9 +242,11 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
@Override
|
||||
public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
String serializedErrorSource = data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.UNKNOWN.serialize());
|
||||
long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID);
|
||||
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
|
||||
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
|
||||
|
||||
return new DonationReceiptRedemptionJob(errorSource, parameters);
|
||||
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
113
app/src/main/java/org/thoughtcrime/securesms/jobs/GiftSendJob.kt
Normal file
113
app/src/main/java/org/thoughtcrime/securesms/jobs/GiftSendJob.kt
Normal file
|
@ -0,0 +1,113 @@
|
|||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.gifts.Gifts
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.jobmanager.Data
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareSender
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Sends a message to the given recipient containing a redeemable badge token.
|
||||
* This job assumes that the client has already determined whether the given recipient can receive a gift badge.
|
||||
*/
|
||||
class GiftSendJob private constructor(parameters: Parameters, private val recipientId: RecipientId, private val additionalMessage: String?) : Job(parameters) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GiftSendJob::class.java)
|
||||
|
||||
const val KEY = "SendGiftJob"
|
||||
const val DATA_RECIPIENT_ID = "data.recipient.id"
|
||||
const val DATA_ADDITIONAL_MESSAGE = "data.additional.message"
|
||||
}
|
||||
|
||||
constructor(recipientId: RecipientId, additionalMessage: String?) :
|
||||
this(
|
||||
parameters = Parameters.Builder()
|
||||
.build(),
|
||||
recipientId = recipientId,
|
||||
additionalMessage = additionalMessage
|
||||
)
|
||||
|
||||
override fun serialize(): Data = Data.Builder()
|
||||
.putLong(DATA_RECIPIENT_ID, recipientId.toLong())
|
||||
.putString(DATA_ADDITIONAL_MESSAGE, additionalMessage)
|
||||
.build()
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun run(): Result {
|
||||
Log.i(TAG, "Getting data and generating message for gift send to $recipientId")
|
||||
|
||||
val token = this.inputData?.getStringAsBlob(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION) ?: return Result.failure()
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
|
||||
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
Log.w(TAG, "Invalid recipient $recipientId for gift send.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val thread = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
|
||||
val outgoingMessage = Gifts.createOutgoingGiftMessage(
|
||||
recipient = recipient,
|
||||
expiresIn = TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
|
||||
sentTimestamp = System.currentTimeMillis(),
|
||||
giftBadge = GiftBadge.newBuilder().setRedemptionToken(ByteString.copyFrom(token)).build()
|
||||
)
|
||||
|
||||
Log.i(TAG, "Sending gift badge to $recipientId...")
|
||||
var didInsert = false
|
||||
MessageSender.send(context, outgoingMessage, thread, false, null) {
|
||||
didInsert = true
|
||||
}
|
||||
|
||||
return if (didInsert) {
|
||||
Log.i(TAG, "Successfully inserted outbox message for gift", true)
|
||||
|
||||
if (additionalMessage != null) {
|
||||
Log.i(TAG, "Sending additional message...")
|
||||
|
||||
val result = MultiShareSender.sendSync(
|
||||
MultiShareArgs.Builder(setOf(ContactSearchKey.RecipientSearchKey.KnownRecipient(recipientId)))
|
||||
.withDraftText(additionalMessage)
|
||||
.build()
|
||||
)
|
||||
|
||||
if (result.containsFailures()) {
|
||||
Log.w(TAG, "Failed to send additional message, but gift sent fine.", true)
|
||||
}
|
||||
|
||||
Result.success()
|
||||
} else {
|
||||
Result.success()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Failed to insert outbox message for gift", true)
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
Log.w(TAG, "Failed to submit send of gift badge to $recipientId")
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<GiftSendJob> {
|
||||
override fun create(parameters: Parameters, data: Data): GiftSendJob {
|
||||
val recipientId = RecipientId.from(data.getLong(DATA_RECIPIENT_ID))
|
||||
val additionalMessage = data.getStringOrDefault(DATA_ADDITIONAL_MESSAGE, null)
|
||||
|
||||
return GiftSendJob(parameters, recipientId, additionalMessage)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -94,6 +94,7 @@ public final class JobManagerFactories {
|
|||
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());
|
||||
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
|
||||
put(FontDownloaderJob.KEY, new FontDownloaderJob.Factory());
|
||||
put(GiftSendJob.KEY, new GiftSendJob.Factory());
|
||||
put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory());
|
||||
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
|
||||
put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory());
|
||||
|
|
|
@ -119,6 +119,10 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
|
||||
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
|
||||
|
||||
if (message.getGiftBadge() != null) {
|
||||
throw new MmsException("Cannot send a gift badge to a group!");
|
||||
}
|
||||
|
||||
if (!SignalDatabase.groups().isActive(group.requireGroupId()) && !isGv2UpdateMessage(message)) {
|
||||
throw new MmsException("Inactive group!");
|
||||
}
|
||||
|
|
|
@ -211,6 +211,7 @@ public class PushMediaSendJob extends PushSendJob {
|
|||
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
|
||||
List<SharedContact> sharedContacts = getSharedContactsFor(message);
|
||||
List<SignalServicePreview> previews = getPreviewsFor(message);
|
||||
SignalServiceDataMessage.GiftBadge giftBadge = getGiftBadgeFor(message);
|
||||
SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder()
|
||||
.withBody(message.getBody())
|
||||
.withAttachments(serviceAttachments)
|
||||
|
@ -221,6 +222,7 @@ public class PushMediaSendJob extends PushSendJob {
|
|||
.withSticker(sticker.orElse(null))
|
||||
.withSharedContacts(sharedContacts)
|
||||
.withPreviews(previews)
|
||||
.withGiftBadge(giftBadge)
|
||||
.asExpirationUpdate(message.isExpirationUpdate());
|
||||
|
||||
if (message.getParentStoryId() != null) {
|
||||
|
@ -245,6 +247,10 @@ public class PushMediaSendJob extends PushSendJob {
|
|||
mediaMessageBuilder.withQuote(getQuoteFor(message).orElse(null));
|
||||
}
|
||||
|
||||
if (message.getGiftBadge() != null) {
|
||||
mediaMessageBuilder.withBody(null);
|
||||
}
|
||||
|
||||
SignalServiceDataMessage mediaMessage = mediaMessageBuilder.build();
|
||||
|
||||
if (Util.equals(SignalStore.account().getAci(), address.getServiceId())) {
|
||||
|
|
|
@ -16,6 +16,8 @@ import org.greenrobot.eventbus.ThreadMode;
|
|||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.thoughtcrime.securesms.TextSecureExpiredException;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
|
@ -26,6 +28,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
|||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
|
@ -43,6 +46,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
|||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
|
@ -419,6 +423,21 @@ public abstract class PushSendJob extends SendJob {
|
|||
.toList();
|
||||
}
|
||||
|
||||
@Nullable SignalServiceDataMessage.GiftBadge getGiftBadgeFor(@NonNull OutgoingMediaMessage message) throws UndeliverableMessageException {
|
||||
GiftBadge giftBadge = message.getGiftBadge();
|
||||
if (giftBadge == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
ReceiptCredentialPresentation presentation = new ReceiptCredentialPresentation(giftBadge.getRedemptionToken().toByteArray());
|
||||
|
||||
return new SignalServiceDataMessage.GiftBadge(presentation);
|
||||
} catch (InvalidInputException invalidInputException) {
|
||||
throw new UndeliverableMessageException(invalidInputException);
|
||||
}
|
||||
}
|
||||
|
||||
protected void rotateSenderCertificateIfNecessary() throws IOException {
|
||||
try {
|
||||
Collection<CertificateType> requiredCertificateTypes = SignalStore.phoneNumberPrivacy()
|
||||
|
|
|
@ -111,6 +111,7 @@ public class RefreshAttributesJob extends BaseJob {
|
|||
"\n Announcement Groups? " + capabilities.isAnnouncementGroup() +
|
||||
"\n Change Number? " + capabilities.isChangeNumber() +
|
||||
"\n Stories? " + capabilities.isStories() +
|
||||
"\n Gift Badges? " + capabilities.isGiftBadges() +
|
||||
"\n UUID? " + capabilities.isUuid());
|
||||
|
||||
SignalServiceAccountManager signalAccountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
||||
|
|
|
@ -255,6 +255,8 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
boolean localHasSubscriptionBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isSubscription);
|
||||
boolean remoteHasBoostBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost);
|
||||
boolean localHasBoostBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost);
|
||||
boolean remoteHasGiftBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isGift);
|
||||
boolean localHasGiftBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isGift);
|
||||
|
||||
if (!remoteHasSubscriptionBadges && localHasSubscriptionBadges) {
|
||||
Badge mostRecentExpiration = Recipient.self()
|
||||
|
@ -307,6 +309,19 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
SignalStore.donationsValues().setExpiredBadge(mostRecentExpiration);
|
||||
}
|
||||
|
||||
if (!remoteHasGiftBadges && localHasGiftBadges) {
|
||||
Badge mostRecentExpiration = Recipient.self()
|
||||
.getBadges()
|
||||
.stream()
|
||||
.filter(badge -> badge.getCategory() == Badge.Category.Donor)
|
||||
.filter(badge -> isGift(badge.getId()))
|
||||
.max(Comparator.comparingLong(Badge::getExpirationTimestamp))
|
||||
.get();
|
||||
|
||||
Log.d(TAG, "Marking gift badge as expired, should notify next time the manage donations screen is open.", true);
|
||||
SignalStore.donationsValues().setExpiredGiftBadge(mostRecentExpiration);
|
||||
}
|
||||
|
||||
boolean userHasVisibleBadges = badges.stream().anyMatch(SignalServiceProfile.Badge::isVisible);
|
||||
boolean userHasInvisibleBadges = badges.stream().anyMatch(b -> !b.isVisible());
|
||||
|
||||
|
@ -326,13 +341,17 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||
}
|
||||
|
||||
private static boolean isSubscription(String badgeId) {
|
||||
return !Objects.equals(badgeId, Badge.BOOST_BADGE_ID);
|
||||
return !isBoost(badgeId) && !isGift(badgeId);
|
||||
}
|
||||
|
||||
private static boolean isBoost(String badgeId) {
|
||||
return Objects.equals(badgeId, Badge.BOOST_BADGE_ID);
|
||||
}
|
||||
|
||||
private static boolean isGift(String badgeId) {
|
||||
return Objects.equals(badgeId, Badge.GIFT_BADGE_ID);
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<RefreshOwnProfileJob> {
|
||||
|
||||
@Override
|
||||
|
|
|
@ -297,11 +297,11 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
|
||||
* - expiration time should have the following characteristics:
|
||||
* - expiration_time mod 86400 == 0
|
||||
* - expiration_time is between now and 60 days from now
|
||||
* - expiration_time is between now and 90 days from now
|
||||
*/
|
||||
private static boolean isCredentialValid(@NonNull ActiveSubscription.Subscription subscription, @NonNull ReceiptCredential receiptCredential) {
|
||||
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
||||
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(60);
|
||||
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(90);
|
||||
boolean isSameLevel = subscription.getLevel() == receiptCredential.getReceiptLevel();
|
||||
boolean isExpirationAfterSub = subscription.getEndOfCurrentPeriod() < receiptCredential.getReceiptExpirationTime();
|
||||
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
|
||||
|
|
|
@ -25,11 +25,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
|||
private val TAG = Log.tag(DonationsValues::class.java)
|
||||
|
||||
private const val KEY_SUBSCRIPTION_CURRENCY_CODE = "donation.currency.code"
|
||||
private const val KEY_CURRENCY_CODE_BOOST = "donation.currency.code.boost"
|
||||
private const val KEY_CURRENCY_CODE_ONE_TIME = "donation.currency.code.boost"
|
||||
private const val KEY_SUBSCRIBER_ID_PREFIX = "donation.subscriber.id."
|
||||
private const val KEY_LAST_KEEP_ALIVE_LAUNCH = "donation.last.successful.ping"
|
||||
private const val KEY_LAST_END_OF_PERIOD_SECONDS = "donation.last.end.of.period"
|
||||
private const val EXPIRED_BADGE = "donation.expired.badge"
|
||||
private const val EXPIRED_GIFT_BADGE = "donation.expired.gift.badge"
|
||||
private const val USER_MANUALLY_CANCELLED = "donation.user.manually.cancelled"
|
||||
private const val KEY_LEVEL_OPERATION_PREFIX = "donation.level.operation."
|
||||
private const val KEY_LEVEL_HISTORY = "donation.level.history"
|
||||
|
@ -46,7 +47,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
|||
override fun onFirstEverAppLaunch() = Unit
|
||||
|
||||
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(
|
||||
KEY_CURRENCY_CODE_BOOST,
|
||||
KEY_CURRENCY_CODE_ONE_TIME,
|
||||
KEY_LAST_KEEP_ALIVE_LAUNCH,
|
||||
KEY_LAST_END_OF_PERIOD_SECONDS,
|
||||
SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT,
|
||||
|
@ -59,8 +60,8 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
|||
private val subscriptionCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) }
|
||||
val observableSubscriptionCurrency: Observable<Currency> by lazy { subscriptionCurrencyPublisher }
|
||||
|
||||
private val boostCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getBoostCurrency()) }
|
||||
val observableBoostCurrency: Observable<Currency> by lazy { boostCurrencyPublisher }
|
||||
private val oneTimeCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getOneTimeCurrency()) }
|
||||
val observableOneTimeCurrency: Observable<Currency> by lazy { oneTimeCurrencyPublisher }
|
||||
|
||||
fun getSubscriptionCurrency(): Currency {
|
||||
val currencyCode = getString(KEY_SUBSCRIPTION_CURRENCY_CODE, null)
|
||||
|
@ -87,20 +88,20 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
|||
}
|
||||
}
|
||||
|
||||
fun getBoostCurrency(): Currency {
|
||||
val boostCurrencyCode = getString(KEY_CURRENCY_CODE_BOOST, null)
|
||||
return if (boostCurrencyCode == null) {
|
||||
fun getOneTimeCurrency(): Currency {
|
||||
val oneTimeCurrency = getString(KEY_CURRENCY_CODE_ONE_TIME, null)
|
||||
return if (oneTimeCurrency == null) {
|
||||
val currency = getSubscriptionCurrency()
|
||||
setBoostCurrency(currency)
|
||||
setOneTimeCurrency(currency)
|
||||
currency
|
||||
} else {
|
||||
Currency.getInstance(boostCurrencyCode)
|
||||
Currency.getInstance(oneTimeCurrency)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBoostCurrency(currency: Currency) {
|
||||
putString(KEY_CURRENCY_CODE_BOOST, currency.currencyCode)
|
||||
boostCurrencyPublisher.onNext(currency)
|
||||
fun setOneTimeCurrency(currency: Currency) {
|
||||
putString(KEY_CURRENCY_CODE_ONE_TIME, currency.currencyCode)
|
||||
oneTimeCurrencyPublisher.onNext(currency)
|
||||
}
|
||||
|
||||
fun getSubscriber(currency: Currency): Subscriber? {
|
||||
|
@ -179,6 +180,20 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
|||
return Badges.fromDatabaseBadge(BadgeList.Badge.parseFrom(badgeBytes))
|
||||
}
|
||||
|
||||
fun setExpiredGiftBadge(badge: Badge?) {
|
||||
if (badge != null) {
|
||||
putBlob(EXPIRED_GIFT_BADGE, Badges.toDatabaseBadge(badge).toByteArray())
|
||||
} else {
|
||||
remove(EXPIRED_GIFT_BADGE)
|
||||
}
|
||||
}
|
||||
|
||||
fun getExpiredGiftBadge(): Badge? {
|
||||
val badgeBytes = getBlob(EXPIRED_GIFT_BADGE, null) ?: return null
|
||||
|
||||
return Badges.fromDatabaseBadge(BadgeList.Badge.parseFrom(badgeBytes))
|
||||
}
|
||||
|
||||
fun getLastKeepAliveLaunchTime(): Long {
|
||||
return getLong(KEY_LAST_KEEP_ALIVE_LAUNCH, 0L)
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ sealed class MediaSelectionDestination {
|
|||
|
||||
companion object {
|
||||
fun fromParcel(parcelables: List<ContactSearchKey.ParcelableRecipientSearchKey>): MultipleRecipients {
|
||||
return MultipleRecipients(parcelables.map { it.asContactSearchKey() }.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java))
|
||||
return MultipleRecipients(parcelables.map { it.asRecipientSearchKey() }.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -244,7 +244,8 @@ class MediaSelectionRepository(context: Context) {
|
|||
emptyList(),
|
||||
mentions,
|
||||
mutableSetOf(),
|
||||
mutableSetOf()
|
||||
mutableSetOf(),
|
||||
null
|
||||
)
|
||||
|
||||
if (isStory && preUploadResults.size > 1) {
|
||||
|
|
|
@ -140,7 +140,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
|
|||
|
||||
setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY) { _, bundle ->
|
||||
val parcelizedKeys: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
|
||||
val contactSearchKeys = parcelizedKeys.map { it.asContactSearchKey() }
|
||||
val contactSearchKeys = parcelizedKeys.map { it.asRecipientSearchKey() }
|
||||
performSend(contactSearchKeys)
|
||||
}
|
||||
|
||||
|
|
|
@ -64,7 +64,8 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
|
|||
mediator = ContactSearchMediator(
|
||||
this,
|
||||
contactRecycler,
|
||||
FeatureFlags.shareSelectionLimit()
|
||||
FeatureFlags.shareSelectionLimit(),
|
||||
true
|
||||
) { state ->
|
||||
ContactSearchConfiguration.build {
|
||||
query = state.query
|
||||
|
|
|
@ -122,7 +122,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
|
|||
}
|
||||
|
||||
val contactsRecyclerView: RecyclerView = view.findViewById(R.id.contacts_container)
|
||||
contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit()) { contactSearchState ->
|
||||
contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit(), true) { contactSearchState ->
|
||||
ContactSearchConfiguration.build {
|
||||
query = contactSearchState.query
|
||||
|
||||
|
|
|
@ -75,7 +75,8 @@ class TextStoryPostSendRepository {
|
|||
listOfNotNull(linkPreview),
|
||||
emptyList(),
|
||||
mutableSetOf(),
|
||||
mutableSetOf()
|
||||
mutableSetOf(),
|
||||
null
|
||||
)
|
||||
|
||||
messages.add(OutgoingSecureMediaMessage(message))
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue