mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-08 17:29:02 +01:00
Implement badge gifting behind feature flag.
This commit is contained in:
committed by
Greyson Parrelli
parent
5d16d1cd23
commit
a4a4665aaa
@@ -417,6 +417,11 @@
|
|||||||
android:windowSoftInputMode="stateAlwaysHidden">
|
android:windowSoftInputMode="stateAlwaysHidden">
|
||||||
</activity>
|
</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"
|
<activity android:name=".wallpaper.ChatWallpaperActivity"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
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.
|
* asking if the user has set a Signal PIN or not.
|
||||||
*/
|
*/
|
||||||
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
|
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 */
|
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||||
boolean onUrlClicked(@NonNull String url);
|
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.R
|
||||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||||
import org.thoughtcrime.securesms.badges.models.Badge
|
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.GlideApp
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.util.ScreenDensity
|
||||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||||
|
|
||||||
class BadgeImageView @JvmOverloads constructor(
|
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() {
|
private fun clearDrawable() {
|
||||||
setImageDrawable(null)
|
setImageDrawable(null)
|
||||||
isClickable = false
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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()
|
||||||
|
}
|
||||||
+237
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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
|
||||||
|
)
|
||||||
+52
@@ -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 {
|
companion object {
|
||||||
const val BOOST_BADGE_ID = "BOOST"
|
const val BOOST_BADGE_ID = "BOOST"
|
||||||
|
const val GIFT_BADGE_ID = "GIFT"
|
||||||
|
|
||||||
private val SELECTION_CHANGED = Any()
|
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.R
|
||||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
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.adapter.mapping.MappingViewHolder
|
||||||
|
|
||||||
object BadgePreview {
|
object BadgePreview {
|
||||||
|
|
||||||
fun register(mappingAdapter: MappingAdapter) {
|
fun register(mappingAdapter: MappingAdapter) {
|
||||||
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
|
mappingAdapter.registerFactory(BadgeModel.FeaturedModel::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.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 badge: Badge?
|
||||||
}
|
abstract val recipient: Recipient
|
||||||
|
|
||||||
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
|
data class FeaturedModel(override val badge: Badge?) : BadgeModel<FeaturedModel>() {
|
||||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
override val recipient: Recipient = Recipient.self()
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
||||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
override val recipient: Recipient = Recipient.self()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getChangePayload(newItem: Model): Any? {
|
data class GiftedBadgeModel(override val badge: Badge?, override val recipient: Recipient) : BadgeModel<GiftedBadgeModel>()
|
||||||
return Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
override fun areItemsTheSame(newItem: T): Boolean {
|
||||||
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
|
return badge?.id == newItem.badge?.id && recipient.id == newItem.recipient.id
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
|
override fun areContentsTheSame(newItem: T): Boolean {
|
||||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
return badge == newItem.badge && recipient.hasSameContent(newItem.recipient)
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(newItem: SubscriptionModel): Any? {
|
|
||||||
return Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +48,7 @@ object BadgePreview {
|
|||||||
|
|
||||||
override fun bind(model: T) {
|
override fun bind(model: T) {
|
||||||
if (payload.isEmpty()) {
|
if (payload.isEmpty()) {
|
||||||
avatar.setRecipient(Recipient.self())
|
avatar.setRecipient(model.recipient)
|
||||||
avatar.disableQuickContact()
|
avatar.disableQuickContact()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val previewView: View = requireView().findViewById(R.id.preview)
|
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.bindTo(viewLifecycleOwner.lifecycle)
|
||||||
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
|
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
|
||||||
@@ -79,7 +79,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
|||||||
hasBoundPreview = true
|
hasBoundPreview = true
|
||||||
}
|
}
|
||||||
|
|
||||||
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
|
previewViewHolder.bind(BadgePreview.BadgeModel.FeaturedModel(state.selectedBadge))
|
||||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
|
|||||||
|
|
||||||
private fun getConfiguration(state: BecomeASustainerState): DSLConfiguration {
|
private fun getConfiguration(state: BecomeASustainerState): DSLConfiguration {
|
||||||
return configure {
|
return configure {
|
||||||
customPref(BadgePreview.Model(badge = state.badge))
|
customPref(BadgePreview.BadgeModel.FeaturedModel(badge = state.badge))
|
||||||
|
|
||||||
sectionHeaderPref(
|
sectionHeaderPref(
|
||||||
title = DSLSettingsText.from(
|
title = DSLSettingsText.from(
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class DSLSettingsAdapter : MappingAdapter() {
|
|||||||
|
|
||||||
abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||||
protected val iconView: ImageView = itemView.findViewById(R.id.icon)
|
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 titleView: TextView = itemView.findViewById(R.id.title)
|
||||||
protected val summaryView: TextView = itemView.findViewById(R.id.summary)
|
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.setImageDrawable(icon)
|
||||||
iconView.visible = icon != null
|
iconView.visible = icon != null
|
||||||
|
|
||||||
|
val iconEnd = model.iconEnd?.resolve(context)
|
||||||
|
iconEndView?.setImageDrawable(iconEnd)
|
||||||
|
iconEndView?.visible = iconEnd != null
|
||||||
|
|
||||||
val title = model.title?.resolve(context)
|
val title = model.title?.resolve(context)
|
||||||
if (title != null) {
|
if (title != null) {
|
||||||
titleView.text = model.title?.resolve(context)
|
titleView.text = model.title?.resolve(context)
|
||||||
|
|||||||
+30
-53
@@ -15,9 +15,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
|||||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
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.components.settings.configure
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
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) {
|
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
|
||||||
|
|
||||||
private val viewModel: AppSettingsViewModel by viewModels(
|
private val viewModel: AppSettingsViewModel by viewModels()
|
||||||
factoryProducer = {
|
|
||||||
AppSettingsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||||
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
viewModel.refreshActiveSubscription()
|
viewModel.refreshExpiredGiftBadge()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
|
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
|
||||||
@@ -76,13 +70,22 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
|
if (FeatureFlags.donorBadges() && PlayServicesUtil.getPlayServicesStatus(requireContext()) == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
|
||||||
customPref(
|
|
||||||
PaymentsPreference(
|
clickPref(
|
||||||
unreadCount = state.unreadPaymentsCount
|
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||||
) {
|
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
|
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()
|
dividerPref()
|
||||||
|
|
||||||
|
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
|
||||||
|
customPref(
|
||||||
|
PaymentsPreference(
|
||||||
|
unreadCount = state.unreadPaymentsCount
|
||||||
|
) {
|
||||||
|
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dividerPref()
|
||||||
|
|
||||||
clickPref(
|
clickPref(
|
||||||
title = DSLSettingsText.from(R.string.preferences__help),
|
title = DSLSettingsText.from(R.string.preferences__help),
|
||||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
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()) {
|
if (FeatureFlags.internalUser()) {
|
||||||
dividerPref()
|
dividerPref()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,5 +5,5 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
|||||||
data class AppSettingsState(
|
data class AppSettingsState(
|
||||||
val self: Recipient,
|
val self: Recipient,
|
||||||
val unreadPaymentsCount: Int,
|
val unreadPaymentsCount: Int,
|
||||||
val hasActiveSubscription: Boolean
|
val hasExpiredGiftBadge: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-43
@@ -2,22 +2,14 @@ package org.thoughtcrime.securesms.components.settings.app
|
|||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
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.conversationlist.model.UnreadPaymentsLiveData
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
|
||||||
import org.thoughtcrime.securesms.util.livedata.Store
|
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 unreadPaymentsLiveData = UnreadPaymentsLiveData()
|
||||||
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
|
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) }
|
store.update(selfLiveData) { self, state -> state.copy(self = self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshActiveSubscription() {
|
fun refreshExpiredGiftBadge() {
|
||||||
if (!FeatureFlags.donorBadges()) {
|
store.update { it.copy(hasExpiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge() != null) }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-29
@@ -12,9 +12,9 @@ import org.signal.core.util.money.FiatMoney
|
|||||||
import org.signal.donations.GooglePayApi
|
import org.signal.donations.GooglePayApi
|
||||||
import org.signal.donations.GooglePayPaymentSource
|
import org.signal.donations.GooglePayPaymentSource
|
||||||
import org.signal.donations.StripeApi
|
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.DonationError
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
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.SignalDatabase
|
||||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
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.jobs.SubscriptionReceiptRequestResponseJob
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||||
import org.thoughtcrime.securesms.util.Environment
|
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.IdempotencyKey
|
||||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret
|
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret
|
||||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -47,7 +52,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
* 1. Confirm the SetupIntent via the Stripe API
|
* 1. Confirm the SetupIntent via the Stripe API
|
||||||
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
|
* 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. 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 PaymentIntent via the Stripe API
|
||||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
* 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)
|
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
|
/**
|
||||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
* @param price The amount to charce the local user
|
||||||
return stripeApi.createPaymentIntent(price, application.getString(R.string.Boost__thank_you_for_your_donation))
|
* @param paymentData PaymentData from Google Pay that describes the payment method
|
||||||
.onErrorResumeNext { Single.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it)) }
|
* @param badgeRecipient Who will be getting the badge
|
||||||
.flatMapCompletable { result ->
|
* @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self)
|
||||||
Log.d(TAG, "Created payment intent for $price.", true)
|
*/
|
||||||
when (result) {
|
fun continuePayment(price: FiatMoney, paymentData: PaymentData, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
|
||||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall())
|
val verifyRecipient = Completable.fromAction {
|
||||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge())
|
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
|
||||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost())
|
val recipient = Recipient.resolved(badgeRecipient)
|
||||||
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent)
|
|
||||||
|
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 {
|
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)
|
Log.d(TAG, "Confirming payment intent...", true)
|
||||||
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext {
|
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 {
|
val waitOnRedemption = Completable.create {
|
||||||
Log.d(TAG, "Confirmed payment intent. Recording boost receipt and submitting badge reimbursement job chain.", true)
|
val donationReceiptRecord = if (isBoost) {
|
||||||
SignalDatabase.donationReceipts.addReceipt(DonationReceiptRecord.createForBoost(price))
|
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)
|
val countDownLatch = CountDownLatch(1)
|
||||||
var finalJobState: JobTracker.JobState? = null
|
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) {
|
if (jobState.isComplete) {
|
||||||
finalJobState = jobState
|
finalJobState = jobState
|
||||||
countDownLatch.countDown()
|
countDownLatch.countDown()
|
||||||
@@ -164,25 +231,25 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
|||||||
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||||
when (finalJobState) {
|
when (finalJobState) {
|
||||||
JobTracker.JobState.SUCCESS -> {
|
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()
|
it.onComplete()
|
||||||
}
|
}
|
||||||
JobTracker.JobState.FAILURE -> {
|
JobTracker.JobState.FAILURE -> {
|
||||||
Log.d(TAG, "Boost request response job chain failed permanently.", true)
|
Log.d(TAG, "$donationTypeLabel request response job chain failed permanently.", true)
|
||||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST))
|
it.onError(DonationError.genericBadgeRedemptionFailure(donationErrorSource))
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(TAG, "Boost request response job chain ignored due to in-progress jobs.", true)
|
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
|
||||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Boost redemption timed out waiting for job completion.", true)
|
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
|
||||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||||
}
|
}
|
||||||
} catch (e: InterruptedException) {
|
} catch (e: InterruptedException) {
|
||||||
Log.d(TAG, "Boost redemption job interrupted", e, true)
|
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
|
||||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
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})")
|
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
|
||||||
return ApplicationDependencies
|
return ApplicationDependencies
|
||||||
.getDonationsService()
|
.getDonationsService()
|
||||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, description)
|
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
|
||||||
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
|
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
|
||||||
.map {
|
.map {
|
||||||
StripeApi.PaymentIntent(it.id, it.clientSecret)
|
StripeApi.PaymentIntent(it.id, it.clientSecret)
|
||||||
|
|||||||
+3
@@ -1,6 +1,7 @@
|
|||||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||||
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import org.signal.core.util.money.FiatMoney
|
import org.signal.core.util.money.FiatMoney
|
||||||
import org.thoughtcrime.securesms.badges.Badges
|
import org.thoughtcrime.securesms.badges.Badges
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
@@ -23,6 +24,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
|
|||||||
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
||||||
return if (localSubscription != null) {
|
return if (localSubscription != null) {
|
||||||
donationsService.getSubscription(localSubscription.subscriberId)
|
donationsService.getSubscription(localSubscription.subscriberId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
|
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
|
||||||
} else {
|
} else {
|
||||||
Single.just(ActiveSubscription.EMPTY)
|
Single.just(ActiveSubscription.EMPTY)
|
||||||
@@ -30,6 +32,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getSubscriptions(): Single<List<Subscription>> = donationsService.getSubscriptionLevels(Locale.getDefault())
|
fun getSubscriptions(): Single<List<Subscription>> = donationsService.getSubscriptionLevels(Locale.getDefault())
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
|
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
|
||||||
.map { subscriptionLevels ->
|
.map { subscriptionLevels ->
|
||||||
subscriptionLevels.levels.map { (code, level) ->
|
subscriptionLevels.levels.map { (code, level) ->
|
||||||
|
|||||||
+3
@@ -1,6 +1,7 @@
|
|||||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||||
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import org.signal.core.util.money.FiatMoney
|
import org.signal.core.util.money.FiatMoney
|
||||||
import org.thoughtcrime.securesms.badges.Badges
|
import org.thoughtcrime.securesms.badges.Badges
|
||||||
import org.thoughtcrime.securesms.badges.models.Badge
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
@@ -16,6 +17,7 @@ class BoostRepository(private val donationsService: DonationsService) {
|
|||||||
|
|
||||||
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
|
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
|
||||||
return donationsService.boostAmounts
|
return donationsService.boostAmounts
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
|
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
|
||||||
.map { result ->
|
.map { result ->
|
||||||
result
|
result
|
||||||
@@ -27,6 +29,7 @@ class BoostRepository(private val donationsService: DonationsService) {
|
|||||||
|
|
||||||
fun getBoostBadge(): Single<Badge> {
|
fun getBoostBadge(): Single<Badge> {
|
||||||
return donationsService.getBoostBadge(Locale.getDefault())
|
return donationsService.getBoostBadge(Locale.getDefault())
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
|
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
|
||||||
.map(Badges::fromServiceBadge)
|
.map(Badges::fromServiceBadge)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -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.components.settings.app.subscription.errors.DonationErrorSource
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||||
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||||
import org.thoughtcrime.securesms.util.livedata.Store
|
import org.thoughtcrime.securesms.util.livedata.Store
|
||||||
|
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import java.text.DecimalFormatSymbols
|
import java.text.DecimalFormatSymbols
|
||||||
@@ -37,7 +39,7 @@ class BoostViewModel(
|
|||||||
private val fetchTokenRequestCode: Int
|
private val fetchTokenRequestCode: Int
|
||||||
) : ViewModel() {
|
) : 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 eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||||
private val disposables = CompositeDisposable()
|
private val disposables = CompositeDisposable()
|
||||||
private val networkDisposable: Disposable
|
private val networkDisposable: Disposable
|
||||||
@@ -77,7 +79,7 @@ class BoostViewModel(
|
|||||||
fun refresh() {
|
fun refresh() {
|
||||||
disposables.clear()
|
disposables.clear()
|
||||||
|
|
||||||
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
|
val currencyObservable = SignalStore.donationsValues().observableOneTimeCurrency
|
||||||
val allBoosts = boostRepository.getBoosts()
|
val allBoosts = boostRepository.getBoosts()
|
||||||
val boostBadge = boostRepository.getBoostBadge()
|
val boostBadge = boostRepository.getBoostBadge()
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ class BoostViewModel(
|
|||||||
val boostList = if (currency in boostMap) {
|
val boostList = if (currency in boostMap) {
|
||||||
boostMap[currency]!!
|
boostMap[currency]!!
|
||||||
} else {
|
} else {
|
||||||
SignalStore.donationsValues().setBoostCurrency(PlatformCurrencyUtil.USD)
|
SignalStore.donationsValues().setOneTimeCurrency(PlatformCurrencyUtil.USD)
|
||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ class BoostViewModel(
|
|||||||
|
|
||||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
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 ->
|
onError = { throwable ->
|
||||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||||
val donationError: DonationError = if (throwable is DonationError) {
|
val donationError: DonationError = if (throwable is DonationError) {
|
||||||
|
|||||||
+7
-7
@@ -13,14 +13,14 @@ import java.util.Currency
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class SetCurrencyViewModel(
|
class SetCurrencyViewModel(
|
||||||
private val isBoost: Boolean,
|
private val isOneTime: Boolean,
|
||||||
supportedCurrencyCodes: List<String>
|
supportedCurrencyCodes: List<String>
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val store = Store(
|
private val store = Store(
|
||||||
SetCurrencyState(
|
SetCurrencyState(
|
||||||
selectedCurrencyCode = if (isBoost) {
|
selectedCurrencyCode = if (isOneTime) {
|
||||||
SignalStore.donationsValues().getBoostCurrency().currencyCode
|
SignalStore.donationsValues().getOneTimeCurrency().currencyCode
|
||||||
} else {
|
} else {
|
||||||
SignalStore.donationsValues().getSubscriptionCurrency().currencyCode
|
SignalStore.donationsValues().getSubscriptionCurrency().currencyCode
|
||||||
},
|
},
|
||||||
@@ -35,8 +35,8 @@ class SetCurrencyViewModel(
|
|||||||
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
||||||
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
||||||
|
|
||||||
if (isBoost) {
|
if (isOneTime) {
|
||||||
SignalStore.donationsValues().setBoostCurrency(Currency.getInstance(selectedCurrencyCode))
|
SignalStore.donationsValues().setOneTimeCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||||
} else {
|
} else {
|
||||||
val currency = Currency.getInstance(selectedCurrencyCode)
|
val currency = Currency.getInstance(selectedCurrencyCode)
|
||||||
val subscriber = SignalStore.donationsValues().getSubscriber(currency)
|
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 {
|
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||||
return modelClass.cast(SetCurrencyViewModel(isBoost, supportedCurrencyCodes))!!
|
return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-8
@@ -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)) {
|
sealed class GiftRecipientVerificationError(cause: Throwable) : DonationError(DonationErrorSource.GIFT, cause) {
|
||||||
object AmountTooSmallError : BoostError("Amount is too small")
|
object SelectedRecipientIsInvalid : GiftRecipientVerificationError(Exception("Selected recipient is invalid."))
|
||||||
object AmountTooLargeError : BoostError("Amount is too large")
|
object SelectedRecipientDoesNotSupportGifts : GiftRecipientVerificationError(Exception("Selected recipient does not support gifts."))
|
||||||
object InvalidCurrencyError : BoostError("Currency is not supported")
|
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."))
|
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.
|
* 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
|
@JvmStatic
|
||||||
fun boostAmountTooSmall(): DonationError = BoostError.AmountTooSmallError
|
fun oneTimeDonationAmountTooSmall(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooSmallError(source)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun boostAmountTooLarge(): DonationError = BoostError.AmountTooLargeError
|
fun oneTimeDonationAmountTooLarge(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooLargeError(source)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun invalidCurrencyForBoost(): DonationError = BoostError.InvalidCurrencyError
|
fun invalidCurrencyForOneTimeDonation(source: DonationErrorSource): DonationError = OneTimeDonationError.InvalidCurrencyError(source)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
|
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
|
||||||
@@ -148,6 +162,9 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)
|
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun badgeCredentialVerificationFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.FailedToValidateCredentialError(source)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun genericPaymentFailure(source: DonationErrorSource): DonationError = PaymentProcessingError.GenericError(source)
|
fun genericPaymentFailure(source: DonationErrorSource): DonationError = PaymentProcessingError.GenericError(source)
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -23,6 +23,7 @@ class DonationErrorParams<V> private constructor(
|
|||||||
callback: Callback<V>
|
callback: Callback<V>
|
||||||
): DonationErrorParams<V> {
|
): DonationErrorParams<V> {
|
||||||
return when (throwable) {
|
return when (throwable) {
|
||||||
|
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, throwable, callback)
|
||||||
is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback)
|
is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback)
|
||||||
is DonationError.PaymentSetupError -> DonationErrorParams(
|
is DonationError.PaymentSetupError -> DonationErrorParams(
|
||||||
title = R.string.DonationsErrors__error_processing_payment,
|
title = R.string.DonationsErrors__error_processing_payment,
|
||||||
@@ -36,6 +37,13 @@ class DonationErrorParams<V> private constructor(
|
|||||||
positiveAction = callback.onOk(context),
|
positiveAction = callback.onOk(context),
|
||||||
negativeAction = null
|
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(
|
else -> DonationErrorParams(
|
||||||
title = R.string.DonationsErrors__couldnt_add_badge,
|
title = R.string.DonationsErrors__couldnt_add_badge,
|
||||||
message = R.string.DonationsErrors__your_badge_could_not,
|
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> {
|
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {
|
||||||
return when (declinedError.declineCode) {
|
return when (declinedError.declineCode) {
|
||||||
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {
|
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {
|
||||||
|
|||||||
+2
@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
|||||||
enum class DonationErrorSource(private val code: String) {
|
enum class DonationErrorSource(private val code: String) {
|
||||||
BOOST("boost"),
|
BOOST("boost"),
|
||||||
SUBSCRIPTION("subscription"),
|
SUBSCRIPTION("subscription"),
|
||||||
|
GIFT("gift"),
|
||||||
|
GIFT_REDEMPTION("gift-redemption"),
|
||||||
KEEP_ALIVE("keep-alive"),
|
KEEP_ALIVE("keep-alive"),
|
||||||
UNKNOWN("unknown");
|
UNKNOWN("unknown");
|
||||||
|
|
||||||
|
|||||||
-7
@@ -5,7 +5,6 @@ import android.view.View
|
|||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import org.signal.core.util.money.FiatMoney
|
import org.signal.core.util.money.FiatMoney
|
||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||||
@@ -31,7 +30,6 @@ object ActiveSubscriptionPreference {
|
|||||||
class Model(
|
class Model(
|
||||||
val price: FiatMoney,
|
val price: FiatMoney,
|
||||||
val subscription: Subscription,
|
val subscription: Subscription,
|
||||||
val onAddBoostClick: () -> Unit,
|
|
||||||
val renewalTimestamp: Long = -1L,
|
val renewalTimestamp: Long = -1L,
|
||||||
val redemptionState: ManageDonationsState.SubscriptionRedemptionState,
|
val redemptionState: ManageDonationsState.SubscriptionRedemptionState,
|
||||||
val activeSubscription: ActiveSubscription.Subscription,
|
val activeSubscription: ActiveSubscription.Subscription,
|
||||||
@@ -57,7 +55,6 @@ object ActiveSubscriptionPreference {
|
|||||||
val title: TextView = itemView.findViewById(R.id.my_support_title)
|
val title: TextView = itemView.findViewById(R.id.my_support_title)
|
||||||
val price: TextView = itemView.findViewById(R.id.my_support_price)
|
val price: TextView = itemView.findViewById(R.id.my_support_price)
|
||||||
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
|
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)
|
val progress: ProgressBar = itemView.findViewById(R.id.my_support_progress)
|
||||||
|
|
||||||
override fun bind(model: Model) {
|
override fun bind(model: Model) {
|
||||||
@@ -79,10 +76,6 @@ object ActiveSubscriptionPreference {
|
|||||||
ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS -> presentInProgressState()
|
ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS -> presentInProgressState()
|
||||||
ManageDonationsState.SubscriptionRedemptionState.FAILED -> presentFailureState(model)
|
ManageDonationsState.SubscriptionRedemptionState.FAILED -> presentFailureState(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
boost.setOnClickListener {
|
|
||||||
model.onAddBoostClick()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun presentRenewalState(model: Model) {
|
private fun presentRenewalState(model: Model) {
|
||||||
|
|||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
|
||||||
|
|
||||||
enum class ManageDonationsEvent {
|
|
||||||
NOT_SUBSCRIBED,
|
|
||||||
ERROR_GETTING_SUBSCRIPTION
|
|
||||||
}
|
|
||||||
+171
-71
@@ -1,11 +1,15 @@
|
|||||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
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.fragment.app.viewModels
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import org.signal.core.util.DimensionUnit
|
import org.signal.core.util.DimensionUnit
|
||||||
import org.signal.core.util.money.FiatMoney
|
import org.signal.core.util.money.FiatMoney
|
||||||
import org.thoughtcrime.securesms.R
|
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.badges.models.BadgePreview
|
||||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
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.DSLSettingsText
|
||||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
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.SubscriptionsRepository
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||||
import org.thoughtcrime.securesms.components.settings.configure
|
import org.thoughtcrime.securesms.components.settings.configure
|
||||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
import org.thoughtcrime.securesms.help.HelpFragment
|
import org.thoughtcrime.securesms.help.HelpFragment
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.subscription.Subscription
|
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.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||||
|
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||||
import java.util.Currency
|
import java.util.Currency
|
||||||
import java.util.concurrent.TimeUnit
|
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
|
* 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.
|
* 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(
|
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||||
factoryProducer = {
|
factoryProducer = {
|
||||||
@@ -36,8 +54,6 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
private val lifecycleDisposable = LifecycleDisposable()
|
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
@@ -47,24 +63,23 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
|||||||
ActiveSubscriptionPreference.register(adapter)
|
ActiveSubscriptionPreference.register(adapter)
|
||||||
IndeterminateLoadingCircle.register(adapter)
|
IndeterminateLoadingCircle.register(adapter)
|
||||||
BadgePreview.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 ->
|
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
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 {
|
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
|
||||||
return configure {
|
return configure {
|
||||||
customPref(
|
customPref(
|
||||||
BadgePreview.Model(
|
BadgePreview.BadgeModel.FeaturedModel(
|
||||||
badge = state.featuredBadge
|
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) {
|
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
|
||||||
val activeSubscription = state.transactionState.activeSubscription.activeSubscription
|
val activeSubscription = state.transactionState.activeSubscription.activeSubscription
|
||||||
if (activeSubscription != null) {
|
if (activeSubscription != null) {
|
||||||
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.level == it.level }
|
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.level == it.level }
|
||||||
if (subscription != null) {
|
if (subscription != null) {
|
||||||
space(DimensionUnit.DP.toPixels(12f).toInt())
|
presentSubscriptionSettings(activeSubscription, subscription, state.getRedemptionState())
|
||||||
|
|
||||||
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()
|
|
||||||
} else {
|
} else {
|
||||||
customPref(IndeterminateLoadingCircle)
|
customPref(IndeterminateLoadingCircle)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
customPref(IndeterminateLoadingCircle)
|
presentNoSubscriptionSettings()
|
||||||
}
|
}
|
||||||
|
} else if (state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
|
||||||
|
presentNetworkFailureSettings(state.getRedemptionState())
|
||||||
} else {
|
} else {
|
||||||
customPref(IndeterminateLoadingCircle)
|
customPref(IndeterminateLoadingCircle)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clickPref(
|
private fun DSLConfiguration.presentNetworkFailureSettings(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
|
||||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
|
if (SignalStore.donationsValues().isLikelyASustainer()) {
|
||||||
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
|
presentSubscriptionSettingsWithNetworkError(redemptionState)
|
||||||
isEnabled = state.getRedemptionState() != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
|
} else {
|
||||||
onClick = {
|
presentNoSubscriptionSettings()
|
||||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun DSLConfiguration.presentSubscriptionSettingsWithNetworkError(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
|
||||||
|
presentSubscriptionSettingsWithState(redemptionState) {
|
||||||
|
customPref(
|
||||||
|
NetworkFailure.Model(
|
||||||
|
onRetryClick = {
|
||||||
|
viewModel.retry()
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clickPref(
|
private fun DSLConfiguration.presentSubscriptionSettings(
|
||||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
|
activeSubscription: ActiveSubscription.Subscription,
|
||||||
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
|
subscription: Subscription,
|
||||||
onClick = {
|
redemptionState: ManageDonationsState.SubscriptionRedemptionState
|
||||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges())
|
) {
|
||||||
}
|
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(
|
private fun DSLConfiguration.presentSubscriptionSettingsWithState(
|
||||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
|
redemptionState: ManageDonationsState.SubscriptionRedemptionState,
|
||||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
subscriptionBlock: DSLConfiguration.() -> Unit
|
||||||
linkId = R.string.donate_url
|
) {
|
||||||
|
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(
|
clickPref(
|
||||||
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts),
|
title = DSLSettingsText.from(R.string.ManageDonationsFragment__gift_a_badge),
|
||||||
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
|
icon = DSLSettingsIcon.from(R.drawable.ic_gift_24),
|
||||||
onClick = {
|
onClick = {
|
||||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
|
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleUserIsNotSubscribed() {
|
private fun DSLConfiguration.presentDonationReceipts() {
|
||||||
findNavController().popBackStack()
|
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() {
|
override fun onMakeAMonthlyDonation() {
|
||||||
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
|
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -14,6 +14,7 @@ data class ManageDonationsState(
|
|||||||
fun getRedemptionState(): SubscriptionRedemptionState {
|
fun getRedemptionState(): SubscriptionRedemptionState {
|
||||||
return when (transactionState) {
|
return when (transactionState) {
|
||||||
TransactionState.Init -> subscriptionRedemptionState
|
TransactionState.Init -> subscriptionRedemptionState
|
||||||
|
TransactionState.NetworkFailure -> subscriptionRedemptionState
|
||||||
TransactionState.InTransaction -> SubscriptionRedemptionState.IN_PROGRESS
|
TransactionState.InTransaction -> SubscriptionRedemptionState.IN_PROGRESS
|
||||||
is TransactionState.NotInTransaction -> getStateFromActiveSubscription(transactionState.activeSubscription) ?: subscriptionRedemptionState
|
is TransactionState.NotInTransaction -> getStateFromActiveSubscription(transactionState.activeSubscription) ?: subscriptionRedemptionState
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ data class ManageDonationsState(
|
|||||||
|
|
||||||
sealed class TransactionState {
|
sealed class TransactionState {
|
||||||
object Init : TransactionState()
|
object Init : TransactionState()
|
||||||
|
object NetworkFailure : TransactionState()
|
||||||
object InTransaction : TransactionState()
|
object InTransaction : TransactionState()
|
||||||
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
|
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-10
@@ -3,18 +3,18 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
|||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
|
||||||
import io.reactivex.rxjava3.core.Observable
|
import io.reactivex.rxjava3.core.Observable
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||||
|
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||||
import org.thoughtcrime.securesms.util.livedata.Store
|
import org.thoughtcrime.securesms.util.livedata.Store
|
||||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||||
|
|
||||||
@@ -23,22 +23,37 @@ class ManageDonationsViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val store = Store(ManageDonationsState())
|
private val store = Store(ManageDonationsState())
|
||||||
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
|
|
||||||
private val disposables = CompositeDisposable()
|
private val disposables = CompositeDisposable()
|
||||||
|
private val networkDisposable: Disposable
|
||||||
|
|
||||||
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
||||||
val events: Observable<ManageDonationsEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
||||||
state.copy(featuredBadge = self.featuredBadge)
|
state.copy(featuredBadge = self.featuredBadge)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
networkDisposable = InternetConnectionObserver
|
||||||
|
.observe()
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.subscribe { isConnected ->
|
||||||
|
if (isConnected) {
|
||||||
|
retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
disposables.clear()
|
disposables.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun retry() {
|
||||||
|
if (!disposables.isDisposed && store.state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
|
||||||
|
store.update { it.copy(transactionState = ManageDonationsState.TransactionState.Init) }
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
disposables.clear()
|
disposables.clear()
|
||||||
|
|
||||||
@@ -72,13 +87,13 @@ class ManageDonationsViewModel(
|
|||||||
store.update {
|
store.update {
|
||||||
it.copy(transactionState = transactionState)
|
it.copy(transactionState = transactionState)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && transactionState.activeSubscription.activeSubscription == null) {
|
|
||||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError = {
|
onError = { throwable ->
|
||||||
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
|
Log.w(TAG, "Error retrieving subscription transaction state", throwable)
|
||||||
|
|
||||||
|
store.update {
|
||||||
|
it.copy(transactionState = ManageDonationsState.TransactionState.NetworkFailure)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
@@ -71,6 +71,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
|
|||||||
val type: String = when (record.type) {
|
val type: String = when (record.type) {
|
||||||
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
|
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.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
|
||||||
|
DonationReceiptRecord.Type.GIFT -> getString(R.string.DonationReceiptListFragment__gift)
|
||||||
}
|
}
|
||||||
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
|
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
|
|||||||
when (record.type) {
|
when (record.type) {
|
||||||
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
|
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.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
|
||||||
|
DonationReceiptRecord.Type.GIFT -> getString(R.string.DonationReceiptListFragment__gift)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -44,6 +44,7 @@ object DonationReceiptListItem {
|
|||||||
when (model.record.type) {
|
when (model.record.type) {
|
||||||
DonationReceiptRecord.Type.RECURRING -> R.string.DonationReceiptListFragment__recurring
|
DonationReceiptRecord.Type.RECURRING -> R.string.DonationReceiptListFragment__recurring
|
||||||
DonationReceiptRecord.Type.BOOST -> R.string.DonationReceiptListFragment__one_time
|
DonationReceiptRecord.Type.BOOST -> R.string.DonationReceiptListFragment__one_time
|
||||||
|
DonationReceiptRecord.Type.GIFT -> R.string.DonationReceiptListFragment__gift
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
moneyView.text = FiatMoneyUtil.format(context.resources, model.record.amount)
|
moneyView.text = FiatMoneyUtil.format(context.resources, model.record.amount)
|
||||||
|
|||||||
+2
-2
@@ -120,7 +120,7 @@ class SubscribeFragment : DSLSettingsFragment(
|
|||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
processingDonationPaymentDialog.hide()
|
processingDonationPaymentDialog.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
|
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
|
||||||
@@ -133,7 +133,7 @@ class SubscribeFragment : DSLSettingsFragment(
|
|||||||
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
|
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
|
||||||
|
|
||||||
return configure {
|
return configure {
|
||||||
customPref(BadgePreview.SubscriptionModel(state.selectedSubscription?.badge))
|
customPref(BadgePreview.BadgeModel.SubscriptionModel(state.selectedSubscription?.badge))
|
||||||
|
|
||||||
sectionHeaderPref(
|
sectionHeaderPref(
|
||||||
title = DSLSettingsText.from(
|
title = DSLSettingsText.from(
|
||||||
|
|||||||
@@ -95,11 +95,12 @@ class DSLConfiguration {
|
|||||||
title: DSLSettingsText,
|
title: DSLSettingsText,
|
||||||
summary: DSLSettingsText? = null,
|
summary: DSLSettingsText? = null,
|
||||||
icon: DSLSettingsIcon? = null,
|
icon: DSLSettingsIcon? = null,
|
||||||
|
iconEnd: DSLSettingsIcon? = null,
|
||||||
isEnabled: Boolean = true,
|
isEnabled: Boolean = true,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: (() -> Boolean)? = null
|
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)
|
children.add(preference)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +183,7 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
|
|||||||
open val title: DSLSettingsText? = null,
|
open val title: DSLSettingsText? = null,
|
||||||
open val summary: DSLSettingsText? = null,
|
open val summary: DSLSettingsText? = null,
|
||||||
open val icon: DSLSettingsIcon? = null,
|
open val icon: DSLSettingsIcon? = null,
|
||||||
|
open val iconEnd: DSLSettingsIcon? = null,
|
||||||
open val isEnabled: Boolean = true,
|
open val isEnabled: Boolean = true,
|
||||||
) : MappingModel<T> {
|
) : MappingModel<T> {
|
||||||
override fun areItemsTheSame(newItem: T): Boolean {
|
override fun areItemsTheSame(newItem: T): Boolean {
|
||||||
@@ -197,7 +199,8 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
|
|||||||
return areItemsTheSame(newItem) &&
|
return areItemsTheSame(newItem) &&
|
||||||
newItem.summary == summary &&
|
newItem.summary == summary &&
|
||||||
newItem.icon == icon &&
|
newItem.icon == icon &&
|
||||||
newItem.isEnabled == isEnabled
|
newItem.isEnabled == isEnabled &&
|
||||||
|
newItem.iconEnd == iconEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +272,7 @@ class ClickPreference(
|
|||||||
override val title: DSLSettingsText,
|
override val title: DSLSettingsText,
|
||||||
override val summary: DSLSettingsText? = null,
|
override val summary: DSLSettingsText? = null,
|
||||||
override val icon: DSLSettingsIcon? = null,
|
override val icon: DSLSettingsIcon? = null,
|
||||||
|
override val iconEnd: DSLSettingsIcon? = null,
|
||||||
override val isEnabled: Boolean = true,
|
override val isEnabled: Boolean = true,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
val onLongClick: (() -> Boolean)? = null
|
val onLongClick: (() -> Boolean)? = null
|
||||||
|
|||||||
+47
@@ -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();
|
ThreadDatabase threadDatabase = SignalDatabase.threads();
|
||||||
|
|
||||||
MatrixCursor recentConversations = ContactsCursorRows.createMatrixCursor(RECENT_CONVERSATION_MAX);
|
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);
|
ThreadDatabase.Reader reader = threadDatabase.readerFor(rawConversations);
|
||||||
ThreadRecord threadRecord;
|
ThreadRecord threadRecord;
|
||||||
while ((threadRecord = reader.getNext()) != null) {
|
while ((threadRecord = reader.getNext()) != null) {
|
||||||
|
|||||||
+8
-2
@@ -30,13 +30,19 @@ class ContactSearchConfiguration private constructor(
|
|||||||
*/
|
*/
|
||||||
data class Recents(
|
data class Recents(
|
||||||
val limit: Int = 25,
|
val limit: Int = 25,
|
||||||
val groupsOnly: Boolean = false,
|
val mode: Mode = Mode.ALL,
|
||||||
val includeInactiveGroups: Boolean = false,
|
val includeInactiveGroups: Boolean = false,
|
||||||
val includeGroupsV1: Boolean = false,
|
val includeGroupsV1: Boolean = false,
|
||||||
val includeSms: Boolean = false,
|
val includeSms: Boolean = false,
|
||||||
override val includeHeader: Boolean,
|
override val includeHeader: Boolean,
|
||||||
override val expandConfig: ExpandConfig? = null
|
override val expandConfig: ExpandConfig? = null
|
||||||
) : Section(SectionKey.RECENTS)
|
) : Section(SectionKey.RECENTS) {
|
||||||
|
enum class Mode {
|
||||||
|
INDIVIDUALS,
|
||||||
|
GROUPS,
|
||||||
|
ALL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1:1 Recipients
|
* 1:1 Recipients
|
||||||
|
|||||||
@@ -21,17 +21,18 @@ import org.thoughtcrime.securesms.util.visible
|
|||||||
object ContactSearchItems {
|
object ContactSearchItems {
|
||||||
fun register(
|
fun register(
|
||||||
mappingAdapter: MappingAdapter,
|
mappingAdapter: MappingAdapter,
|
||||||
|
displayCheckBox: Boolean,
|
||||||
recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit,
|
recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit,
|
||||||
storyListener: (ContactSearchData.Story, Boolean) -> Unit,
|
storyListener: (ContactSearchData.Story, Boolean) -> Unit,
|
||||||
expandListener: (ContactSearchData.Expand) -> Unit
|
expandListener: (ContactSearchData.Expand) -> Unit
|
||||||
) {
|
) {
|
||||||
mappingAdapter.registerFactory(
|
mappingAdapter.registerFactory(
|
||||||
StoryModel::class.java,
|
StoryModel::class.java,
|
||||||
LayoutFactory({ StoryViewHolder(it, storyListener) }, R.layout.contact_search_item)
|
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener) }, R.layout.contact_search_item)
|
||||||
)
|
)
|
||||||
mappingAdapter.registerFactory(
|
mappingAdapter.registerFactory(
|
||||||
RecipientModel::class.java,
|
RecipientModel::class.java,
|
||||||
LayoutFactory({ KnownRecipientViewHolder(it, recipientListener) }, R.layout.contact_search_item)
|
LayoutFactory({ KnownRecipientViewHolder(it, displayCheckBox, recipientListener) }, R.layout.contact_search_item)
|
||||||
)
|
)
|
||||||
mappingAdapter.registerFactory(
|
mappingAdapter.registerFactory(
|
||||||
HeaderModel::class.java,
|
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 isSelected(model: StoryModel): Boolean = model.isSelected
|
||||||
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
|
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
|
||||||
override fun getRecipient(model: StoryModel): Recipient = model.story.recipient
|
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 isSelected(model: RecipientModel): Boolean = model.isSelected
|
||||||
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
|
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
|
||||||
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
|
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
|
||||||
@@ -127,7 +128,7 @@ object ContactSearchItems {
|
|||||||
/**
|
/**
|
||||||
* Base Recipient View Holder
|
* 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 avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
|
||||||
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
|
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)
|
protected val smsTag: View = itemView.findViewById(R.id.sms_tag)
|
||||||
|
|
||||||
override fun bind(model: T) {
|
override fun bind(model: T) {
|
||||||
|
checkbox.visible = displayCheckBox
|
||||||
checkbox.isChecked = isSelected(model)
|
checkbox.isChecked = isSelected(model)
|
||||||
itemView.setOnClickListener { onClick(getData(model), isSelected(model)) }
|
itemView.setOnClickListener { onClick(getData(model), isSelected(model)) }
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ sealed class ContactSearchKey {
|
|||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class ParcelableRecipientSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
|
data class ParcelableRecipientSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
|
||||||
fun asContactSearchKey(): ContactSearchKey {
|
fun asRecipientSearchKey(): RecipientSearchKey {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
ParcelableType.STORY -> RecipientSearchKey.Story(recipientId)
|
ParcelableType.STORY -> RecipientSearchKey.Story(recipientId)
|
||||||
ParcelableType.KNOWN_RECIPIENT -> RecipientSearchKey.KnownRecipient(recipientId)
|
ParcelableType.KNOWN_RECIPIENT -> RecipientSearchKey.KnownRecipient(recipientId)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ContactSearchMediator(
|
|||||||
fragment: Fragment,
|
fragment: Fragment,
|
||||||
recyclerView: RecyclerView,
|
recyclerView: RecyclerView,
|
||||||
selectionLimits: SelectionLimits,
|
selectionLimits: SelectionLimits,
|
||||||
|
displayCheckBox: Boolean,
|
||||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
|
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class ContactSearchMediator(
|
|||||||
|
|
||||||
ContactSearchItems.register(
|
ContactSearchItems.register(
|
||||||
mappingAdapter = adapter,
|
mappingAdapter = adapter,
|
||||||
|
displayCheckBox = displayCheckBox,
|
||||||
recipientListener = this::toggleSelection,
|
recipientListener = this::toggleSelection,
|
||||||
storyListener = this::toggleSelection,
|
storyListener = this::toggleSelection,
|
||||||
expandListener = { viewModel.expandSection(it.sectionKey) }
|
expandListener = { viewModel.expandSection(it.sectionKey) }
|
||||||
|
|||||||
+2
-1
@@ -42,7 +42,8 @@ open class ContactSearchPagedDataSourceRepository(
|
|||||||
return SignalDatabase.threads.getRecentConversationList(
|
return SignalDatabase.threads.getRecentConversationList(
|
||||||
section.limit,
|
section.limit,
|
||||||
section.includeInactiveGroups,
|
section.includeInactiveGroups,
|
||||||
section.groupsOnly,
|
section.mode == ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS,
|
||||||
|
section.mode == ContactSearchConfiguration.Section.Recents.Mode.GROUPS,
|
||||||
!section.includeGroupsV1,
|
!section.includeGroupsV1,
|
||||||
!section.includeSms
|
!section.includeSms
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ import org.signal.core.util.concurrent.SimpleTask;
|
|||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.LoggingFragment;
|
import org.thoughtcrime.securesms.LoggingFragment;
|
||||||
import org.thoughtcrime.securesms.R;
|
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.ConversationScrollToView;
|
||||||
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
||||||
import org.thoughtcrime.securesms.components.TypingStatusRepository;
|
import org.thoughtcrime.securesms.components.TypingStatusRepository;
|
||||||
@@ -308,6 +311,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
|||||||
|
|
||||||
RecyclerViewColorizer recyclerViewColorizer = new RecyclerViewColorizer(list);
|
RecyclerViewColorizer recyclerViewColorizer = new RecyclerViewColorizer(list);
|
||||||
|
|
||||||
|
OpenableGiftItemDecoration openableGiftItemDecoration = new OpenableGiftItemDecoration(requireContext());
|
||||||
|
getViewLifecycleOwner().getLifecycle().addObserver(openableGiftItemDecoration);
|
||||||
|
|
||||||
|
list.addItemDecoration(openableGiftItemDecoration);
|
||||||
list.addItemDecoration(multiselectItemDecoration);
|
list.addItemDecoration(multiselectItemDecoration);
|
||||||
list.setItemAnimator(conversationItemAnimator);
|
list.setItemAnimator(conversationItemAnimator);
|
||||||
|
|
||||||
@@ -413,6 +420,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
|||||||
return view;
|
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() {
|
private @NonNull GiphyMp4ProjectionRecycler initializeGiphyMp4() {
|
||||||
int maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation();
|
int maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation();
|
||||||
List<GiphyMp4ProjectionPlayerHolder> holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(),
|
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");
|
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() {
|
public void refreshList() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.net.Uri;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||||
import org.thoughtcrime.securesms.mediasend.Media;
|
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_STARTING_POSITION = "starting_position";
|
||||||
private static final String EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP = "first_time_in_group";
|
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_WITH_SEARCH_OPEN = "with_search_open";
|
||||||
|
private static final String EXTRA_GIFT_BADGE = "gift_badge";
|
||||||
|
|
||||||
private ConversationIntents() {
|
private ConversationIntents() {
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,7 @@ public class ConversationIntents {
|
|||||||
private final int startingPosition;
|
private final int startingPosition;
|
||||||
private final boolean firstTimeInSelfCreatedGroup;
|
private final boolean firstTimeInSelfCreatedGroup;
|
||||||
private final boolean withSearchOpen;
|
private final boolean withSearchOpen;
|
||||||
|
private final Badge giftBadge;
|
||||||
|
|
||||||
static Args from(@NonNull Intent intent) {
|
static Args from(@NonNull Intent intent) {
|
||||||
if (isBubbleIntent(intent)) {
|
if (isBubbleIntent(intent)) {
|
||||||
@@ -84,7 +87,8 @@ public class ConversationIntents {
|
|||||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||||
-1,
|
-1,
|
||||||
false,
|
false,
|
||||||
false);
|
false,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Args(RecipientId.from(Objects.requireNonNull(intent.getStringExtra(EXTRA_RECIPIENT))),
|
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_DISTRIBUTION_TYPE, ThreadDatabase.DistributionTypes.DEFAULT),
|
||||||
intent.getIntExtra(EXTRA_STARTING_POSITION, -1),
|
intent.getIntExtra(EXTRA_STARTING_POSITION, -1),
|
||||||
intent.getBooleanExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
|
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,
|
private Args(@NonNull RecipientId recipientId,
|
||||||
@@ -108,7 +113,8 @@ public class ConversationIntents {
|
|||||||
int distributionType,
|
int distributionType,
|
||||||
int startingPosition,
|
int startingPosition,
|
||||||
boolean firstTimeInSelfCreatedGroup,
|
boolean firstTimeInSelfCreatedGroup,
|
||||||
boolean withSearchOpen)
|
boolean withSearchOpen,
|
||||||
|
@Nullable Badge giftBadge)
|
||||||
{
|
{
|
||||||
this.recipientId = recipientId;
|
this.recipientId = recipientId;
|
||||||
this.threadId = threadId;
|
this.threadId = threadId;
|
||||||
@@ -120,6 +126,7 @@ public class ConversationIntents {
|
|||||||
this.startingPosition = startingPosition;
|
this.startingPosition = startingPosition;
|
||||||
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
|
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
|
||||||
this.withSearchOpen = withSearchOpen;
|
this.withSearchOpen = withSearchOpen;
|
||||||
|
this.giftBadge = giftBadge;
|
||||||
}
|
}
|
||||||
|
|
||||||
public @NonNull RecipientId getRecipientId() {
|
public @NonNull RecipientId getRecipientId() {
|
||||||
@@ -169,6 +176,10 @@ public class ConversationIntents {
|
|||||||
public boolean isWithSearchOpen() {
|
public boolean isWithSearchOpen() {
|
||||||
return withSearchOpen;
|
return withSearchOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public @Nullable Badge getGiftBadge() {
|
||||||
|
return giftBadge;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public final static class Builder {
|
public final static class Builder {
|
||||||
@@ -187,6 +198,7 @@ public class ConversationIntents {
|
|||||||
private String dataType;
|
private String dataType;
|
||||||
private boolean firstTimeInSelfCreatedGroup;
|
private boolean firstTimeInSelfCreatedGroup;
|
||||||
private boolean withSearchOpen;
|
private boolean withSearchOpen;
|
||||||
|
private Badge giftBadge;
|
||||||
|
|
||||||
private Builder(@NonNull Context context,
|
private Builder(@NonNull Context context,
|
||||||
@NonNull RecipientId recipientId,
|
@NonNull RecipientId recipientId,
|
||||||
@@ -255,7 +267,12 @@ public class ConversationIntents {
|
|||||||
this.firstTimeInSelfCreatedGroup = true;
|
this.firstTimeInSelfCreatedGroup = true;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Builder withGiftBadge(@NonNull Badge badge) {
|
||||||
|
this.giftBadge = badge;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public @NonNull Intent build() {
|
public @NonNull Intent build() {
|
||||||
if (stickerLocator != null && media != null) {
|
if (stickerLocator != null && media != null) {
|
||||||
throw new IllegalStateException("Cannot have both sticker and media array");
|
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_BORDERLESS, isBorderless);
|
||||||
intent.putExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, firstTimeInSelfCreatedGroup);
|
intent.putExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, firstTimeInSelfCreatedGroup);
|
||||||
intent.putExtra(EXTRA_WITH_SEARCH_OPEN, withSearchOpen);
|
intent.putExtra(EXTRA_WITH_SEARCH_OPEN, withSearchOpen);
|
||||||
|
intent.putExtra(EXTRA_GIFT_BADGE, giftBadge);
|
||||||
|
|
||||||
if (draftText != null) {
|
if (draftText != null) {
|
||||||
intent.putExtra(EXTRA_TEXT, draftText);
|
intent.putExtra(EXTRA_TEXT, draftText);
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ import org.thoughtcrime.securesms.MediaPreviewActivity;
|
|||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||||
import org.thoughtcrime.securesms.badges.BadgeImageView;
|
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.AlertView;
|
||||||
import org.thoughtcrime.securesms.components.AudioView;
|
import org.thoughtcrime.securesms.components.AudioView;
|
||||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||||
@@ -147,6 +149,9 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import kotlin.Unit;
|
||||||
|
import kotlin.jvm.functions.Function1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A view that displays an individual conversation item within a conversation
|
* A view that displays an individual conversation item within a conversation
|
||||||
* thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter.
|
* 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,
|
public final class ConversationItem extends RelativeLayout implements BindableConversationItem,
|
||||||
RecipientForeverObserver
|
RecipientForeverObserver,
|
||||||
|
OpenableGift
|
||||||
{
|
{
|
||||||
private static final String TAG = Log.tag(ConversationItem.class);
|
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<BorderlessImageView> stickerStub;
|
||||||
private Stub<ViewOnceMessageView> revealableStub;
|
private Stub<ViewOnceMessageView> revealableStub;
|
||||||
private Stub<Button> callToActionStub;
|
private Stub<Button> callToActionStub;
|
||||||
|
private Stub<GiftMessageView> giftViewStub;
|
||||||
private @Nullable EventListener eventListener;
|
private @Nullable EventListener eventListener;
|
||||||
|
|
||||||
private int defaultBubbleColor;
|
private int defaultBubbleColor;
|
||||||
@@ -224,6 +231,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||||
private final Rect thumbnailMaskingRect = new Rect();
|
private final Rect thumbnailMaskingRect = new Rect();
|
||||||
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
|
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
|
||||||
|
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
|
||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
|
|
||||||
@@ -298,6 +306,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
this.badgeImageView = findViewById(R.id.badge);
|
this.badgeImageView = findViewById(R.id.badge);
|
||||||
this.storyReactionLabelWrapper = findViewById(R.id.story_reacted_label_holder);
|
this.storyReactionLabelWrapper = findViewById(R.id.story_reacted_label_holder);
|
||||||
this.storyReactionLabel = findViewById(R.id.story_reacted_label);
|
this.storyReactionLabel = findViewById(R.id.story_reacted_label);
|
||||||
|
this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub));
|
||||||
|
|
||||||
setOnClickListener(new ClickListener(null));
|
setOnClickListener(new ClickListener(null));
|
||||||
|
|
||||||
@@ -914,6 +923,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
return MessageRecordUtil.isViewOnceMessage(messageRecord);
|
return MessageRecordUtil.isViewOnceMessage(messageRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isGiftMessage(MessageRecord messageRecord) {
|
||||||
|
return MessageRecordUtil.hasGiftBadge(messageRecord);
|
||||||
|
}
|
||||||
|
|
||||||
private void setBodyText(@NonNull MessageRecord messageRecord,
|
private void setBodyText(@NonNull MessageRecord messageRecord,
|
||||||
@Nullable String searchQuery,
|
@Nullable String searchQuery,
|
||||||
boolean messageRequestAccepted)
|
boolean messageRequestAccepted)
|
||||||
@@ -935,7 +948,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
bodyText.setText(italics);
|
bodyText.setText(italics);
|
||||||
bodyText.setVisibility(View.VISIBLE);
|
bodyText.setVisibility(View.VISIBLE);
|
||||||
bodyText.setOverflowText(null);
|
bodyText.setOverflowText(null);
|
||||||
} else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord)) {
|
} else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord) || isGiftMessage(messageRecord)) {
|
||||||
bodyText.setVisibility(View.GONE);
|
bodyText.setVisibility(View.GONE);
|
||||||
} else {
|
} else {
|
||||||
Spannable styledText = conversationMessage.getDisplayBody(getContext());
|
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 (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
|
||||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.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().setMessage((MmsMessageRecord) messageRecord, hasWallpaper);
|
||||||
revealableStub.get().setOnClickListener(revealableClickListener);
|
revealableStub.get().setOnClickListener(revealableClickListener);
|
||||||
@@ -1020,6 +1034,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||||
if (revealableStub.resolved()) revealableStub.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().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
|
||||||
sharedContactStub.get().setEventListener(sharedContactEventListener);
|
sharedContactStub.get().setEventListener(sharedContactEventListener);
|
||||||
@@ -1039,6 +1054,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
|
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
|
||||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||||
|
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||||
|
|
||||||
//noinspection ConstantConditions
|
//noinspection ConstantConditions
|
||||||
LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
|
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 (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||||
if (revealableStub.resolved()) revealableStub.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().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
|
||||||
audioViewStub.get().setDownloadClickListener(singleDownloadClickListener);
|
audioViewStub.get().setDownloadClickListener(singleDownloadClickListener);
|
||||||
@@ -1108,6 +1125,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||||
|
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||||
|
|
||||||
//noinspection ConstantConditions
|
//noinspection ConstantConditions
|
||||||
documentViewStub.get().setDocument(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(), showControls);
|
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 (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
|
||||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||||
|
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||||
|
|
||||||
if (hasSticker(messageRecord)) {
|
if (hasSticker(messageRecord)) {
|
||||||
//noinspection ConstantConditions
|
//noinspection ConstantConditions
|
||||||
@@ -1159,6 +1178,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||||
if (revealableStub.resolved()) revealableStub.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();
|
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
|
||||||
mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(isCaptionlessMms(messageRecord) ? R.dimen.media_bubble_min_width_solo
|
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;
|
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 {
|
} else {
|
||||||
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
|
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
|
||||||
if (audioViewStub.resolved()) audioViewStub.get().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 (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||||
if (revealableStub.resolved()) revealableStub.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.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, 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;
|
background = R.drawable.message_bubble_background_received_alone;
|
||||||
outliner.setRadius(bigRadius);
|
outliner.setRadius(bigRadius);
|
||||||
pulseOutliner.setRadius(bigRadius);
|
pulseOutliner.setRadius(bigRadius);
|
||||||
bodyBubbleCorners = null;
|
bodyBubbleCorners = new Projection.Corners(bigRadius);
|
||||||
}
|
}
|
||||||
} else if (isStartOfMessageCluster(current, previous, isGroupThread)) {
|
} else if (isStartOfMessageCluster(current, previous, isGroupThread)) {
|
||||||
if (current.isOutgoing()) {
|
if (current.isOutgoing()) {
|
||||||
@@ -1711,7 +1746,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
background = R.drawable.message_bubble_background_received_start;
|
background = R.drawable.message_bubble_background_received_start;
|
||||||
setOutlinerRadii(outliner, bigRadius, bigRadius, bigRadius, smallRadius);
|
setOutlinerRadii(outliner, bigRadius, bigRadius, bigRadius, smallRadius);
|
||||||
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, bigRadius, smallRadius);
|
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, bigRadius, smallRadius);
|
||||||
bodyBubbleCorners = null;
|
bodyBubbleCorners = getBodyBubbleCorners(bigRadius, bigRadius, bigRadius, smallRadius);
|
||||||
}
|
}
|
||||||
} else if (isEndOfMessageCluster(current, next, isGroupThread)) {
|
} else if (isEndOfMessageCluster(current, next, isGroupThread)) {
|
||||||
if (current.isOutgoing()) {
|
if (current.isOutgoing()) {
|
||||||
@@ -1723,7 +1758,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
background = R.drawable.message_bubble_background_received_end;
|
background = R.drawable.message_bubble_background_received_end;
|
||||||
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, bigRadius);
|
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, bigRadius);
|
||||||
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, bigRadius);
|
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, bigRadius);
|
||||||
bodyBubbleCorners = null;
|
bodyBubbleCorners = getBodyBubbleCorners(smallRadius, bigRadius, bigRadius, bigRadius);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (current.isOutgoing()) {
|
if (current.isOutgoing()) {
|
||||||
@@ -1735,7 +1770,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||||||
background = R.drawable.message_bubble_background_received_middle;
|
background = R.drawable.message_bubble_background_received_middle;
|
||||||
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, smallRadius);
|
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, smallRadius);
|
||||||
setOutlinerRadii(pulseOutliner, 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 {
|
private class SharedContactEventListener implements SharedContactView.EventListener {
|
||||||
@Override
|
@Override
|
||||||
public void onAddToContactsClicked(@NonNull Contact contact) {
|
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 class ClickListener implements View.OnClickListener {
|
||||||
private OnClickListener parent;
|
private OnClickListener parent;
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -114,6 +114,7 @@ import org.thoughtcrime.securesms.TransportOption;
|
|||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||||
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
|
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
|
||||||
import org.thoughtcrime.securesms.audio.AudioRecorder;
|
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.AnimatingToggle;
|
||||||
import org.thoughtcrime.securesms.components.ComposeText;
|
import org.thoughtcrime.securesms.components.ComposeText;
|
||||||
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
|
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()
|
// TODO [alex] LargeScreenSupport -- This will need to be built from requireArguments()
|
||||||
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
|
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
|
||||||
|
if (savedInstanceState == null && args.getGiftBadge() != null) {
|
||||||
|
GiftThanksSheet.show(getChildFragmentManager(), args.getRecipientId(), args.getGiftBadge());
|
||||||
|
}
|
||||||
|
|
||||||
isSearchRequested = args.isWithSearchOpen();
|
isSearchRequested = args.isWithSearchOpen();
|
||||||
|
|
||||||
@@ -2979,7 +2983,7 @@ public class ConversationParentFragment extends Fragment
|
|||||||
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
|
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
|
||||||
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null);
|
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null);
|
||||||
List<Mention> mentions = new ArrayList<>(result.getMentions());
|
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);
|
OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message);
|
||||||
|
|
||||||
final Context context = requireContext().getApplicationContext();
|
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 SettableFuture<Void> future = new SettableFuture<>();
|
||||||
final Context context = requireContext().getApplicationContext();
|
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.MessageRecord;
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||||
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -82,6 +83,7 @@ final class MenuState {
|
|||||||
boolean hasInMemory = false;
|
boolean hasInMemory = false;
|
||||||
boolean hasPendingMedia = false;
|
boolean hasPendingMedia = false;
|
||||||
boolean mediaIsSelected = false;
|
boolean mediaIsSelected = false;
|
||||||
|
boolean hasGift = false;
|
||||||
|
|
||||||
for (MultiselectPart part : selectedParts) {
|
for (MultiselectPart part : selectedParts) {
|
||||||
MessageRecord messageRecord = part.getMessageRecord();
|
MessageRecord messageRecord = part.getMessageRecord();
|
||||||
@@ -115,6 +117,10 @@ final class MenuState {
|
|||||||
if (messageRecord.isRemoteDelete()) {
|
if (messageRecord.isRemoteDelete()) {
|
||||||
remoteDelete = true;
|
remoteDelete = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (MessageRecordUtil.hasGiftBadge(messageRecord)) {
|
||||||
|
hasGift = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean shouldShowForwardAction = !actionMessage &&
|
boolean shouldShowForwardAction = !actionMessage &&
|
||||||
@@ -122,6 +128,7 @@ final class MenuState {
|
|||||||
!viewOnce &&
|
!viewOnce &&
|
||||||
!remoteDelete &&
|
!remoteDelete &&
|
||||||
!hasPendingMedia &&
|
!hasPendingMedia &&
|
||||||
|
!hasGift &&
|
||||||
selectedParts.size() <= MAX_FORWARDABLE_COUNT;
|
selectedParts.size() <= MAX_FORWARDABLE_COUNT;
|
||||||
|
|
||||||
int uniqueRecords = selectedParts.stream()
|
int uniqueRecords = selectedParts.stream()
|
||||||
@@ -144,6 +151,7 @@ final class MenuState {
|
|||||||
!viewOnce &&
|
!viewOnce &&
|
||||||
messageRecord.isMms() &&
|
messageRecord.isMms() &&
|
||||||
!hasPendingMedia &&
|
!hasPendingMedia &&
|
||||||
|
!hasGift &&
|
||||||
!messageRecord.isMmsNotification() &&
|
!messageRecord.isMmsNotification() &&
|
||||||
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
|
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
|
||||||
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
|
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
|
||||||
@@ -152,7 +160,7 @@ final class MenuState {
|
|||||||
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
|
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText)
|
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift)
|
||||||
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
|
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
|
||||||
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
|
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
|
||||||
.build();
|
.build();
|
||||||
@@ -180,6 +188,7 @@ final class MenuState {
|
|||||||
messageRecord.isSecure() &&
|
messageRecord.isSecure() &&
|
||||||
(!conversationRecipient.isGroup() || conversationRecipient.isActiveGroup()) &&
|
(!conversationRecipient.isGroup() || conversationRecipient.isActiveGroup()) &&
|
||||||
!messageRecord.getRecipient().isBlocked() &&
|
!messageRecord.getRecipient().isBlocked() &&
|
||||||
|
!MessageRecordUtil.hasGiftBadge(messageRecord) &&
|
||||||
!conversationRecipient.isReleaseNotes();
|
!conversationRecipient.isReleaseNotes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-17
@@ -107,7 +107,7 @@ class MultiselectForwardFragment :
|
|||||||
view.minimumHeight = resources.displayMetrics.heightPixels
|
view.minimumHeight = resources.displayMetrics.heightPixels
|
||||||
|
|
||||||
val contactSearchRecycler: RecyclerView = view.findViewById(R.id.contact_selection_list)
|
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()!!
|
callback = findListener()!!
|
||||||
disposables.bindTo(viewLifecycleOwner.lifecycle)
|
disposables.bindTo(viewLifecycleOwner.lifecycle)
|
||||||
@@ -155,24 +155,11 @@ class MultiselectForwardFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendButton.setOnClickListener {
|
sendButton.setOnClickListener {
|
||||||
sendButton.isEnabled = false
|
onSend(it)
|
||||||
|
|
||||||
StoryDialogs.guardWithAddToYourStoryDialog(
|
|
||||||
requireContext(),
|
|
||||||
contactSearchMediator.getSelectedContacts(),
|
|
||||||
onAddToStory = {
|
|
||||||
performSend()
|
|
||||||
},
|
|
||||||
onEditViewers = {
|
|
||||||
sendButton.isEnabled = true
|
|
||||||
HideStoryFromDialogFragment().show(childFragmentManager, null)
|
|
||||||
},
|
|
||||||
onCancel = {
|
|
||||||
sendButton.isEnabled = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendButton.visible = !isSingleRecipientSelection()
|
||||||
|
|
||||||
shareSelectionRecycler.adapter = shareSelectionAdapter
|
shareSelectionRecycler.adapter = shareSelectionAdapter
|
||||||
|
|
||||||
bottomBar.visible = false
|
bottomBar.visible = false
|
||||||
@@ -180,6 +167,11 @@ class MultiselectForwardFragment :
|
|||||||
container.addView(bottomBarAndSpacer)
|
container.addView(bottomBarAndSpacer)
|
||||||
|
|
||||||
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { contactSelection ->
|
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { contactSelection ->
|
||||||
|
if (contactSelection.isNotEmpty() && isSingleRecipientSelection()) {
|
||||||
|
onSend(sendButton)
|
||||||
|
return@observe
|
||||||
|
}
|
||||||
|
|
||||||
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
|
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
|
||||||
|
|
||||||
addMessage.visible = !forceDisableAddMessage && contactSelection.any { key -> key !is ContactSearchKey.RecipientSearchKey.Story } && getMultiShareArgs().isNotEmpty()
|
addMessage.visible = !forceDisableAddMessage && contactSelection.any { key -> key !is ContactSearchKey.RecipientSearchKey.Story } && getMultiShareArgs().isNotEmpty()
|
||||||
@@ -276,6 +268,25 @@ class MultiselectForwardFragment :
|
|||||||
.show()
|
.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() {
|
private fun performSend() {
|
||||||
viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
|
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)
|
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 {
|
private fun isSelectedMediaValidForStories(): Boolean {
|
||||||
return requireListener<Callback>().canSendMediaToStories() && getMultiShareArgs().all { it.isValidForStories }
|
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_TITLE = "multiselect.forward.fragment.title"
|
||||||
const val ARG_FORCE_DISABLE_ADD_MESSAGE = "multiselect.forward.fragment.force.disable.add.message"
|
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_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_KEY = "result_key"
|
||||||
const val RESULT_SELECTION = "result_selection_recipients"
|
const val RESULT_SELECTION = "result_selection_recipients"
|
||||||
const val RESULT_SENT = "result_sent"
|
const val RESULT_SENT = "result_sent"
|
||||||
@@ -460,6 +476,7 @@ class MultiselectForwardFragment :
|
|||||||
putInt(ARG_TITLE, multiselectForwardFragmentArgs.title)
|
putInt(ARG_TITLE, multiselectForwardFragmentArgs.title)
|
||||||
putBoolean(ARG_FORCE_DISABLE_ADD_MESSAGE, multiselectForwardFragmentArgs.forceDisableAddMessage)
|
putBoolean(ARG_FORCE_DISABLE_ADD_MESSAGE, multiselectForwardFragmentArgs.forceDisableAddMessage)
|
||||||
putBoolean(ARG_FORCE_SELECTION_ONLY, multiselectForwardFragmentArgs.forceSelectionOnly)
|
putBoolean(ARG_FORCE_SELECTION_ONLY, multiselectForwardFragmentArgs.forceSelectionOnly)
|
||||||
|
putBoolean(ARG_SELECT_SINGLE_RECIPIENT, multiselectForwardFragmentArgs.selectSingleRecipient)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -28,13 +28,15 @@ import java.util.function.Consumer
|
|||||||
* @param title The title to display at the top of the sheet
|
* @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 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 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(
|
class MultiselectForwardFragmentArgs @JvmOverloads constructor(
|
||||||
val canSendToNonPush: Boolean,
|
val canSendToNonPush: Boolean,
|
||||||
val multiShareArgs: List<MultiShareArgs> = listOf(),
|
val multiShareArgs: List<MultiShareArgs> = listOf(),
|
||||||
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to,
|
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to,
|
||||||
val forceDisableAddMessage: Boolean = false,
|
val forceDisableAddMessage: Boolean = false,
|
||||||
val forceSelectionOnly: Boolean = false
|
val forceSelectionOnly: Boolean = false,
|
||||||
|
val selectSingleRecipient: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
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 markSmsStatus(long id, int status);
|
||||||
public abstract void markDownloadState(long messageId, long state);
|
public abstract void markDownloadState(long messageId, long state);
|
||||||
public abstract void markIncomingNotificationReceived(long threadId);
|
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);
|
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);
|
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.StoryType;
|
||||||
import org.thoughtcrime.securesms.database.model.StoryViewState;
|
import org.thoughtcrime.securesms.database.model.StoryViewState;
|
||||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
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.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||||
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
|
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.IncomingTextMessage;
|
||||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||||
import org.thoughtcrime.securesms.stories.Stories;
|
import org.thoughtcrime.securesms.stories.Stories;
|
||||||
|
import org.thoughtcrime.securesms.util.Base64;
|
||||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
@@ -1578,6 +1580,11 @@ public class MmsDatabase extends MessageDatabase {
|
|||||||
return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn);
|
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,
|
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
|
||||||
body,
|
body,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -1594,7 +1601,8 @@ public class MmsDatabase extends MessageDatabase {
|
|||||||
previews,
|
previews,
|
||||||
mentions,
|
mentions,
|
||||||
networkFailures,
|
networkFailures,
|
||||||
mismatches);
|
mismatches,
|
||||||
|
giftBadge);
|
||||||
|
|
||||||
if (Types.isSecureType(outboxType)) {
|
if (Types.isSecureType(outboxType)) {
|
||||||
return new OutgoingSecureMediaMessage(message);
|
return new OutgoingSecureMediaMessage(message);
|
||||||
@@ -1791,10 +1799,20 @@ public class MmsDatabase extends MessageDatabase {
|
|||||||
type |= Types.EXPIRATION_TIMER_UPDATE_BIT;
|
type |= Types.EXPIRATION_TIMER_UPDATE_BIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean hasSpecialType = false;
|
||||||
if (retrieved.isStoryReaction()) {
|
if (retrieved.isStoryReaction()) {
|
||||||
|
hasSpecialType = true;
|
||||||
type |= Types.SPECIAL_TYPE_STORY_REACTION;
|
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);
|
return insertMessageInbox(retrieved, "", threadId, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1858,6 +1876,55 @@ public class MmsDatabase extends MessageDatabase {
|
|||||||
TrimThreadJob.enqueueAsync(threadId);
|
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
|
@Override
|
||||||
public long insertMessageOutbox(@NonNull OutgoingMediaMessage message,
|
public long insertMessageOutbox(@NonNull OutgoingMediaMessage message,
|
||||||
long threadId,
|
long threadId,
|
||||||
@@ -1899,10 +1966,20 @@ public class MmsDatabase extends MessageDatabase {
|
|||||||
type |= Types.EXPIRATION_TIMER_UPDATE_BIT;
|
type |= Types.EXPIRATION_TIMER_UPDATE_BIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean hasSpecialType = false;
|
||||||
if (message.isStoryReaction()) {
|
if (message.isStoryReaction()) {
|
||||||
|
hasSpecialType = true;
|
||||||
type |= Types.SPECIAL_TYPE_STORY_REACTION;
|
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());
|
Map<RecipientId, EarlyReceiptCache.Receipt> earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.getSentTimeMillis());
|
||||||
|
|
||||||
ContentValues contentValues = new ContentValues();
|
ContentValues contentValues = new ContentValues();
|
||||||
@@ -2421,7 +2498,8 @@ public class MmsDatabase extends MessageDatabase {
|
|||||||
-1,
|
-1,
|
||||||
null,
|
null,
|
||||||
message.getStoryType(),
|
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);
|
long receiptTimestamp = CursorUtil.requireLong(cursor, MmsSmsColumns.RECEIPT_TIMESTAMP);
|
||||||
StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE));
|
StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE));
|
||||||
ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID));
|
ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID));
|
||||||
|
String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY));
|
||||||
|
|
||||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
||||||
readReceiptCount = 0;
|
readReceiptCount = 0;
|
||||||
@@ -2492,13 +2571,21 @@ public class MmsDatabase extends MessageDatabase {
|
|||||||
|
|
||||||
SlideDeck slideDeck = new SlideDeck(context, new MmsNotificationAttachment(status, messageSize));
|
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,
|
return new NotificationMmsMessageRecord(id, recipient, recipient,
|
||||||
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId,
|
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId,
|
||||||
contentLocationBytes, messageSize, expiry, status,
|
contentLocationBytes, messageSize, expiry, status,
|
||||||
transactionIdBytes, mailbox, subscriptionId, slideDeck,
|
transactionIdBytes, mailbox, subscriptionId, slideDeck,
|
||||||
readReceiptCount, viewedReceiptCount, receiptTimestamp, storyType,
|
readReceiptCount, viewedReceiptCount, receiptTimestamp, storyType,
|
||||||
parentStoryId);
|
parentStoryId, giftBadge);
|
||||||
}
|
}
|
||||||
|
|
||||||
private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) {
|
private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) {
|
||||||
@@ -2558,13 +2645,22 @@ public class MmsDatabase extends MessageDatabase {
|
|||||||
Log.w(TAG, "Error parsing message ranges", e);
|
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,
|
return new MediaMmsMessageRecord(id, recipient, recipient,
|
||||||
addressDeviceId, dateSent, dateReceived, dateServer, deliveryReceiptCount,
|
addressDeviceId, dateSent, dateReceived, dateServer, deliveryReceiptCount,
|
||||||
threadId, body, slideDeck, partCount, box, mismatches,
|
threadId, body, slideDeck, partCount, box, mismatches,
|
||||||
networkFailures, subscriptionId, expiresIn, expireStarted,
|
networkFailures, subscriptionId, expiresIn, expireStarted,
|
||||||
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(),
|
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(),
|
||||||
remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges,
|
remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges,
|
||||||
storyType, parentStoryId);
|
storyType, parentStoryId, giftBadge);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Set<IdentityKeyMismatch> getMismatchedIdentities(String document) {
|
private Set<IdentityKeyMismatch> getMismatchedIdentities(String document) {
|
||||||
|
|||||||
@@ -138,13 +138,14 @@ public interface MmsSmsColumns {
|
|||||||
// Special message types
|
// Special message types
|
||||||
public static final long SPECIAL_TYPES_MASK = 0xF00000000L;
|
public static final long SPECIAL_TYPES_MASK = 0xF00000000L;
|
||||||
public static final long SPECIAL_TYPE_STORY_REACTION = 0x100000000L;
|
public static final long SPECIAL_TYPE_STORY_REACTION = 0x100000000L;
|
||||||
|
public static final long SPECIAL_TYPE_GIFT_BADGE = 0x200000000L;
|
||||||
public static boolean isSpecialType(long type) {
|
|
||||||
return (type & SPECIAL_TYPES_MASK) != 0L;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isStoryReaction(long type) {
|
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) {
|
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.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.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.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 {
|
val values = ContentValues(1).apply {
|
||||||
put(CAPABILITIES, value)
|
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()),
|
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()),
|
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()),
|
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)),
|
insightsBannerTier = InsightsBannerTier.fromId(cursor.requireInt(SEEN_INVITE_REMINDER)),
|
||||||
storageId = Base64.decodeNullableOrThrow(cursor.requireString(STORAGE_SERVICE_ID)),
|
storageId = Base64.decodeNullableOrThrow(cursor.requireString(STORAGE_SERVICE_ID)),
|
||||||
mentionSetting = MentionSetting.fromId(cursor.requireInt(MENTION_SETTING)),
|
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 ANNOUNCEMENT_GROUPS = 3
|
||||||
const val CHANGE_NUMBER = 4
|
const val CHANGE_NUMBER = 4
|
||||||
const val STORIES = 5
|
const val STORIES = 5
|
||||||
|
const val GIFT_BADGES = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class VibrateState(val id: Int) {
|
enum class VibrateState(val id: Int) {
|
||||||
|
|||||||
@@ -1696,6 +1696,21 @@ public class SmsDatabase extends MessageDatabase {
|
|||||||
throw new UnsupportedOperationException();
|
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
|
@Override
|
||||||
public MessageDatabase.Reader getMessages(Collection<Long> messageIds) {
|
public MessageDatabase.Reader getMessages(Collection<Long> messageIds) {
|
||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException();
|
||||||
|
|||||||
@@ -573,10 +573,10 @@ public class ThreadDatabase extends Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean hideV1Groups) {
|
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();
|
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)"
|
String query = !includeInactiveGroups ? MEANINGFUL_MESSAGES + " != 0 AND (" + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " IS NULL OR " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1)"
|
||||||
: MEANINGFUL_MESSAGES + " != 0";
|
: MEANINGFUL_MESSAGES + " != 0";
|
||||||
@@ -585,6 +585,10 @@ public class ThreadDatabase extends Database {
|
|||||||
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " NOT NULL";
|
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " NOT NULL";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (individualsOnly) {
|
||||||
|
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " IS NULL";
|
||||||
|
}
|
||||||
|
|
||||||
if (hideV1Groups) {
|
if (hideV1Groups) {
|
||||||
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_TYPE + " != " + RecipientDatabase.GroupType.SIGNAL_V1.getId();
|
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_TYPE + " != " + RecipientDatabase.GroupType.SIGNAL_V1.getId();
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -13,7 +13,8 @@ data class DonationReceiptRecord(
|
|||||||
) {
|
) {
|
||||||
enum class Type(val code: String) {
|
enum class Type(val code: String) {
|
||||||
RECURRING("recurring"),
|
RECURRING("recurring"),
|
||||||
BOOST("boost");
|
BOOST("boost"),
|
||||||
|
GIFT("gift");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromCode(code: String): Type {
|
fun fromCode(code: String): Type {
|
||||||
@@ -46,5 +47,15 @@ data class DonationReceiptRecord(
|
|||||||
type = Type.BOOST
|
type = Type.BOOST
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createForGift(amount: FiatMoney): DonationReceiptRecord {
|
||||||
|
return DonationReceiptRecord(
|
||||||
|
id = -1L,
|
||||||
|
amount = amount,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
subscriptionLevel = -1,
|
||||||
|
type = Type.GIFT
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.database.SmsDatabase.Status;
|
|||||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
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.linkpreview.LinkPreview;
|
||||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
@@ -93,13 +94,14 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
|||||||
long receiptTimestamp,
|
long receiptTimestamp,
|
||||||
@Nullable BodyRangeList messageRanges,
|
@Nullable BodyRangeList messageRanges,
|
||||||
@NonNull StoryType storyType,
|
@NonNull StoryType storyType,
|
||||||
@Nullable ParentStoryId parentStoryId)
|
@Nullable ParentStoryId parentStoryId,
|
||||||
|
@Nullable GiftBadge giftBadge)
|
||||||
{
|
{
|
||||||
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
|
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
|
||||||
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
|
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
|
||||||
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
|
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
|
||||||
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp,
|
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp,
|
||||||
storyType, parentStoryId);
|
storyType, parentStoryId, giftBadge);
|
||||||
this.partCount = partCount;
|
this.partCount = partCount;
|
||||||
this.mentionsSelf = mentionsSelf;
|
this.mentionsSelf = mentionsSelf;
|
||||||
this.messageRanges = messageRanges;
|
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(),
|
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
|
||||||
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||||
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
|
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) {
|
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,
|
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), slideDeck,
|
||||||
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||||
getReadReceiptCount(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
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) {
|
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.contactshare.Contact;
|
||||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
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.linkpreview.LinkPreview;
|
||||||
import org.thoughtcrime.securesms.mms.Slide;
|
import org.thoughtcrime.securesms.mms.Slide;
|
||||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
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 List<LinkPreview> linkPreviews = new LinkedList<>();
|
||||||
private final @NonNull StoryType storyType;
|
private final @NonNull StoryType storyType;
|
||||||
private final @Nullable ParentStoryId parentStoryId;
|
private final @Nullable ParentStoryId parentStoryId;
|
||||||
|
private final @Nullable GiftBadge giftBadge;
|
||||||
|
|
||||||
private final boolean viewOnce;
|
private final boolean viewOnce;
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
|||||||
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
|
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
|
||||||
@NonNull List<ReactionRecord> reactions, boolean remoteDelete, long notifiedTimestamp,
|
@NonNull List<ReactionRecord> reactions, boolean remoteDelete, long notifiedTimestamp,
|
||||||
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
|
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
|
||||||
@Nullable ParentStoryId parentStoryId)
|
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge)
|
||||||
{
|
{
|
||||||
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId,
|
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId,
|
||||||
dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount,
|
dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount,
|
||||||
@@ -50,6 +52,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
|||||||
this.viewOnce = viewOnce;
|
this.viewOnce = viewOnce;
|
||||||
this.storyType = storyType;
|
this.storyType = storyType;
|
||||||
this.parentStoryId = parentStoryId;
|
this.parentStoryId = parentStoryId;
|
||||||
|
this.giftBadge = giftBadge;
|
||||||
|
|
||||||
this.contacts.addAll(contacts);
|
this.contacts.addAll(contacts);
|
||||||
this.linkPreviews.addAll(linkPreviews);
|
this.linkPreviews.addAll(linkPreviews);
|
||||||
@@ -104,4 +107,8 @@ public abstract class MmsMessageRecord extends MessageRecord {
|
|||||||
public @NonNull List<LinkPreview> getLinkPreviews() {
|
public @NonNull List<LinkPreview> getLinkPreviews() {
|
||||||
return linkPreviews;
|
return linkPreviews;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public @Nullable GiftBadge getGiftBadge() {
|
||||||
|
return giftBadge;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -25,6 +25,7 @@ import androidx.annotation.Nullable;
|
|||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
|
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.mms.SlideDeck;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
|
||||||
@@ -54,13 +55,13 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
|
|||||||
long expiry, int status, byte[] transactionId, long mailbox,
|
long expiry, int status, byte[] transactionId, long mailbox,
|
||||||
int subscriptionId, SlideDeck slideDeck, int readReceiptCount,
|
int subscriptionId, SlideDeck slideDeck, int readReceiptCount,
|
||||||
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
|
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
|
||||||
@Nullable ParentStoryId parentStoryId)
|
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge)
|
||||||
{
|
{
|
||||||
super(id, "", conversationRecipient, individualRecipient, recipientDeviceId,
|
super(id, "", conversationRecipient, individualRecipient, recipientDeviceId,
|
||||||
dateSent, dateReceived, -1, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
|
dateSent, dateReceived, -1, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
|
||||||
new HashSet<>(), new HashSet<>(), subscriptionId,
|
new HashSet<>(), new HashSet<>(), subscriptionId,
|
||||||
0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false,
|
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.contentLocation = contentLocation;
|
||||||
this.messageSize = messageSize;
|
this.messageSize = messageSize;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ data class RecipientRecord(
|
|||||||
val announcementGroupCapability: Recipient.Capability,
|
val announcementGroupCapability: Recipient.Capability,
|
||||||
val changeNumberCapability: Recipient.Capability,
|
val changeNumberCapability: Recipient.Capability,
|
||||||
val storiesCapability: Recipient.Capability,
|
val storiesCapability: Recipient.Capability,
|
||||||
|
val giftBadgesCapability: Recipient.Capability,
|
||||||
val insightsBannerTier: InsightsBannerTier,
|
val insightsBannerTier: InsightsBannerTier,
|
||||||
val storageId: ByteArray?,
|
val storageId: ByteArray?,
|
||||||
val mentionSetting: MentionSetting,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-24
@@ -23,6 +23,8 @@ import org.thoughtcrime.securesms.jobmanager.Data;
|
|||||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
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 org.whispersystems.signalservice.internal.ServiceResponse;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -39,29 +41,38 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||||||
|
|
||||||
public static final String KEY = "BoostReceiptCredentialsSubmissionJob";
|
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_REQUEST_BYTES = "data.request.bytes";
|
||||||
private static final String DATA_PAYMENT_INTENT_ID = "data.payment.intent.id";
|
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 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(
|
return new BoostReceiptRequestResponseJob(
|
||||||
new Parameters
|
new Parameters
|
||||||
.Builder()
|
.Builder()
|
||||||
.addConstraint(NetworkConstraint.KEY)
|
.addConstraint(NetworkConstraint.KEY)
|
||||||
.setQueue("BoostReceiptRedemption")
|
.setQueue(donationErrorSource == DonationErrorSource.BOOST ? BOOST_QUEUE : GIFT_QUEUE)
|
||||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||||
.setMaxAttempts(Parameters.UNLIMITED)
|
.setMaxAttempts(Parameters.UNLIMITED)
|
||||||
.build(),
|
.build(),
|
||||||
null,
|
null,
|
||||||
paymentIntent.getId()
|
paymentIntent.getId(),
|
||||||
|
donationErrorSource,
|
||||||
|
badgeLevel
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static JobManager.Chain createJobChain(StripeApi.PaymentIntent paymentIntent) {
|
public static JobManager.Chain createJobChainForBoost(@NonNull StripeApi.PaymentIntent paymentIntent) {
|
||||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent);
|
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
|
||||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
|
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
|
||||||
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
|
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
|
||||||
|
|
||||||
@@ -71,18 +82,38 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||||||
.then(refreshOwnProfileJob);
|
.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,
|
private BoostReceiptRequestResponseJob(@NonNull Parameters parameters,
|
||||||
@Nullable ReceiptCredentialRequestContext requestContext,
|
@Nullable ReceiptCredentialRequestContext requestContext,
|
||||||
@NonNull String paymentIntentId)
|
@NonNull String paymentIntentId,
|
||||||
|
@NonNull DonationErrorSource donationErrorSource,
|
||||||
|
long badgeLevel)
|
||||||
{
|
{
|
||||||
super(parameters);
|
super(parameters);
|
||||||
this.requestContext = requestContext;
|
this.requestContext = requestContext;
|
||||||
this.paymentIntentId = paymentIntentId;
|
this.paymentIntentId = paymentIntentId;
|
||||||
|
this.donationErrorSource = donationErrorSource;
|
||||||
|
this.badgeLevel = badgeLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @NonNull Data serialize() {
|
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) {
|
if (requestContext != null) {
|
||||||
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
|
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
|
||||||
@@ -124,16 +155,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||||||
.blockingGet();
|
.blockingGet();
|
||||||
|
|
||||||
if (response.getApplicationError().isPresent()) {
|
if (response.getApplicationError().isPresent()) {
|
||||||
handleApplicationError(context, response);
|
handleApplicationError(context, response, donationErrorSource);
|
||||||
} else if (response.getResult().isPresent()) {
|
} else if (response.getResult().isPresent()) {
|
||||||
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
|
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
|
||||||
|
|
||||||
if (!isCredentialValid(receiptCredential)) {
|
if (!isCredentialValid(receiptCredential)) {
|
||||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST));
|
DonationError.routeDonationError(context, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
|
||||||
throw new IOException("Could not validate receipt credential");
|
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);
|
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
|
||||||
setOutputData(new Data.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
|
setOutputData(new Data.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
|
||||||
receiptCredentialPresentation.serialize())
|
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();
|
Throwable applicationException = response.getApplicationError().get();
|
||||||
switch (response.getStatus()) {
|
switch (response.getStatus()) {
|
||||||
case 204:
|
case 204:
|
||||||
@@ -152,15 +183,15 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||||||
throw new RetryableException();
|
throw new RetryableException();
|
||||||
case 400:
|
case 400:
|
||||||
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
|
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);
|
throw new Exception(applicationException);
|
||||||
case 402:
|
case 402:
|
||||||
Log.w(TAG, "User payment failed.", applicationException, true);
|
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);
|
throw new Exception(applicationException);
|
||||||
case 409:
|
case 409:
|
||||||
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
|
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);
|
throw new Exception(applicationException);
|
||||||
default:
|
default:
|
||||||
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true);
|
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
|
* - 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 should have the following characteristics:
|
||||||
* - expiration_time mod 86400 == 0
|
* - 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) {
|
private boolean isCredentialValid(@NonNull ReceiptCredential receiptCredential) {
|
||||||
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
||||||
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(60);
|
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(90);
|
||||||
boolean isCorrectLevel = receiptCredential.getReceiptLevel() == 1;
|
boolean isCorrectLevel = receiptCredential.getReceiptLevel() == badgeLevel;
|
||||||
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
|
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
|
||||||
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
|
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
|
||||||
boolean isExpirationWithinMax = receiptCredential.getReceiptExpirationTime() <= maxExpirationTime;
|
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 +
|
") isExpiration86400(" + isExpiration86400 +
|
||||||
") isExpirationInTheFuture(" + isExpirationInTheFuture +
|
") isExpirationInTheFuture(" + isExpirationInTheFuture +
|
||||||
") isExpirationWithinMax(" + isExpirationWithinMax + ")", true);
|
") isExpirationWithinMax(" + isExpirationWithinMax + ")", true);
|
||||||
@@ -226,16 +257,18 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||||||
public static class Factory implements Job.Factory<BoostReceiptRequestResponseJob> {
|
public static class Factory implements Job.Factory<BoostReceiptRequestResponseJob> {
|
||||||
@Override
|
@Override
|
||||||
public @NonNull BoostReceiptRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
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 {
|
try {
|
||||||
if (data.hasString(DATA_REQUEST_BYTES)) {
|
if (data.hasString(DATA_REQUEST_BYTES)) {
|
||||||
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
|
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
|
||||||
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
|
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
|
||||||
|
|
||||||
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId);
|
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel);
|
||||||
} else {
|
} else {
|
||||||
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId);
|
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel);
|
||||||
}
|
}
|
||||||
} catch (InvalidInputException e) {
|
} catch (InvalidInputException e) {
|
||||||
throw new IllegalStateException(e);
|
throw new IllegalStateException(e);
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ public class ConversationShortcutUpdateJob extends BaseJob {
|
|||||||
int maxShortcuts = ConversationUtil.getMaxShortcuts(context);
|
int maxShortcuts = ConversationUtil.getMaxShortcuts(context);
|
||||||
List<Recipient> ranked = new ArrayList<>(maxShortcuts);
|
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;
|
ThreadRecord record;
|
||||||
while ((record = reader.getNext()) != null) {
|
while ((record = reader.getNext()) != null) {
|
||||||
ranked.add(record.getRecipient().resolve());
|
ranked.add(record.getRecipient().resolve());
|
||||||
|
|||||||
+119
-18
@@ -1,16 +1,25 @@
|
|||||||
package org.thoughtcrime.securesms.jobs;
|
package org.thoughtcrime.securesms.jobs;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
|
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
|
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.DonationError;
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
|
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.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
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.
|
* presentation object via setOutputData. This is expected to be the byte[] blob of a ReceiptCredentialPresentation object.
|
||||||
*/
|
*/
|
||||||
public class DonationReceiptRedemptionJob extends BaseJob {
|
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 SUBSCRIPTION_QUEUE = "ReceiptRedemption";
|
||||||
public static final String KEY = "DonationReceiptRedemptionJob";
|
public static final String KEY = "DonationReceiptRedemptionJob";
|
||||||
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
|
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_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;
|
private final DonationErrorSource errorSource;
|
||||||
|
|
||||||
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource) {
|
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource) {
|
||||||
return new DonationReceiptRedemptionJob(
|
return new DonationReceiptRedemptionJob(
|
||||||
|
NO_ID,
|
||||||
|
false,
|
||||||
errorSource,
|
errorSource,
|
||||||
new Job.Parameters
|
new Job.Parameters
|
||||||
.Builder()
|
.Builder()
|
||||||
@@ -47,6 +63,8 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||||||
|
|
||||||
public static DonationReceiptRedemptionJob createJobForBoost() {
|
public static DonationReceiptRedemptionJob createJobForBoost() {
|
||||||
return new DonationReceiptRedemptionJob(
|
return new DonationReceiptRedemptionJob(
|
||||||
|
NO_ID,
|
||||||
|
false,
|
||||||
DonationErrorSource.BOOST,
|
DonationErrorSource.BOOST,
|
||||||
new Job.Parameters
|
new Job.Parameters
|
||||||
.Builder()
|
.Builder()
|
||||||
@@ -57,14 +75,40 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||||||
.build());
|
.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);
|
super(parameters);
|
||||||
this.errorSource = errorSource;
|
this.giftMessageId = giftMessageId;
|
||||||
|
this.makePrimary = primary;
|
||||||
|
this.errorSource = errorSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @NonNull Data serialize() {
|
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
|
@Override
|
||||||
@@ -78,31 +122,31 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||||||
Log.d(TAG, "Marking subscription failure", true);
|
Log.d(TAG, "Marking subscription failure", true);
|
||||||
SignalStore.donationsValues().markSubscriptionRedemptionFailed();
|
SignalStore.donationsValues().markSubscriptionRedemptionFailed();
|
||||||
MultiDeviceSubscriptionSyncRequestJob.enqueue();
|
MultiDeviceSubscriptionSyncRequestJob.enqueue();
|
||||||
|
} else if (giftMessageId != NO_ID) {
|
||||||
|
SignalDatabase.mms().markGiftRedemptionFailed(giftMessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAdded() {
|
||||||
|
if (giftMessageId != NO_ID) {
|
||||||
|
SignalDatabase.mms().markGiftRedemptionStarted(giftMessageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onRun() throws Exception {
|
protected void onRun() throws Exception {
|
||||||
Data inputData = getInputData();
|
ReceiptCredentialPresentation presentation = getPresentation();
|
||||||
|
if (presentation == null) {
|
||||||
if (inputData == null) {
|
Log.d(TAG, "No presentation available. Exiting.", true);
|
||||||
Log.w(TAG, "No input data. Exiting.", null, true);
|
|
||||||
return;
|
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);
|
Log.d(TAG, "Attempting to redeem token... isForSubscription: " + isForSubscription(), true);
|
||||||
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
|
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
|
||||||
.redeemReceipt(presentation,
|
.redeemReceipt(presentation,
|
||||||
SignalStore.donationsValues().getDisplayBadgesOnProfile(),
|
SignalStore.donationsValues().getDisplayBadgesOnProfile(),
|
||||||
false)
|
makePrimary)
|
||||||
.blockingGet();
|
.blockingGet();
|
||||||
|
|
||||||
if (response.getApplicationError().isPresent()) {
|
if (response.getApplicationError().isPresent()) {
|
||||||
@@ -124,6 +168,61 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||||||
if (isForSubscription()) {
|
if (isForSubscription()) {
|
||||||
Log.d(TAG, "Clearing subscription failure", true);
|
Log.d(TAG, "Clearing subscription failure", true);
|
||||||
SignalStore.donationsValues().clearSubscriptionRedemptionFailed();
|
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
|
@Override
|
||||||
public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||||
String serializedErrorSource = data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.UNKNOWN.serialize());
|
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);
|
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
|
||||||
|
|
||||||
return new DonationReceiptRedemptionJob(errorSource, parameters);
|
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, parameters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());
|
||||||
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
|
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
|
||||||
put(FontDownloaderJob.KEY, new FontDownloaderJob.Factory());
|
put(FontDownloaderJob.KEY, new FontDownloaderJob.Factory());
|
||||||
|
put(GiftSendJob.KEY, new GiftSendJob.Factory());
|
||||||
put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory());
|
put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory());
|
||||||
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
|
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
|
||||||
put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory());
|
put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory());
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ public final class PushGroupSendJob extends PushSendJob {
|
|||||||
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
|
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
|
||||||
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
|
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)) {
|
if (!SignalDatabase.groups().isActive(group.requireGroupId()) && !isGv2UpdateMessage(message)) {
|
||||||
throw new MmsException("Inactive group!");
|
throw new MmsException("Inactive group!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ public class PushMediaSendJob extends PushSendJob {
|
|||||||
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
|
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
|
||||||
List<SharedContact> sharedContacts = getSharedContactsFor(message);
|
List<SharedContact> sharedContacts = getSharedContactsFor(message);
|
||||||
List<SignalServicePreview> previews = getPreviewsFor(message);
|
List<SignalServicePreview> previews = getPreviewsFor(message);
|
||||||
|
SignalServiceDataMessage.GiftBadge giftBadge = getGiftBadgeFor(message);
|
||||||
SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder()
|
SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder()
|
||||||
.withBody(message.getBody())
|
.withBody(message.getBody())
|
||||||
.withAttachments(serviceAttachments)
|
.withAttachments(serviceAttachments)
|
||||||
@@ -221,6 +222,7 @@ public class PushMediaSendJob extends PushSendJob {
|
|||||||
.withSticker(sticker.orElse(null))
|
.withSticker(sticker.orElse(null))
|
||||||
.withSharedContacts(sharedContacts)
|
.withSharedContacts(sharedContacts)
|
||||||
.withPreviews(previews)
|
.withPreviews(previews)
|
||||||
|
.withGiftBadge(giftBadge)
|
||||||
.asExpirationUpdate(message.isExpirationUpdate());
|
.asExpirationUpdate(message.isExpirationUpdate());
|
||||||
|
|
||||||
if (message.getParentStoryId() != null) {
|
if (message.getParentStoryId() != null) {
|
||||||
@@ -245,6 +247,10 @@ public class PushMediaSendJob extends PushSendJob {
|
|||||||
mediaMessageBuilder.withQuote(getQuoteFor(message).orElse(null));
|
mediaMessageBuilder.withQuote(getQuoteFor(message).orElse(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.getGiftBadge() != null) {
|
||||||
|
mediaMessageBuilder.withBody(null);
|
||||||
|
}
|
||||||
|
|
||||||
SignalServiceDataMessage mediaMessage = mediaMessageBuilder.build();
|
SignalServiceDataMessage mediaMessage = mediaMessageBuilder.build();
|
||||||
|
|
||||||
if (Util.equals(SignalStore.account().getAci(), address.getServiceId())) {
|
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.core.util.logging.Log;
|
||||||
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
|
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
|
||||||
import org.signal.libsignal.metadata.certificate.SenderCertificate;
|
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.TextSecureExpiredException;
|
||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
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.SignalDatabase;
|
||||||
import org.thoughtcrime.securesms.database.model.Mention;
|
import org.thoughtcrime.securesms.database.model.Mention;
|
||||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
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.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
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.RecipientId;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||||
|
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
|
||||||
import org.thoughtcrime.securesms.util.Base64;
|
import org.thoughtcrime.securesms.util.Base64;
|
||||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||||
@@ -419,6 +423,21 @@ public abstract class PushSendJob extends SendJob {
|
|||||||
.toList();
|
.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 {
|
protected void rotateSenderCertificateIfNecessary() throws IOException {
|
||||||
try {
|
try {
|
||||||
Collection<CertificateType> requiredCertificateTypes = SignalStore.phoneNumberPrivacy()
|
Collection<CertificateType> requiredCertificateTypes = SignalStore.phoneNumberPrivacy()
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ public class RefreshAttributesJob extends BaseJob {
|
|||||||
"\n Announcement Groups? " + capabilities.isAnnouncementGroup() +
|
"\n Announcement Groups? " + capabilities.isAnnouncementGroup() +
|
||||||
"\n Change Number? " + capabilities.isChangeNumber() +
|
"\n Change Number? " + capabilities.isChangeNumber() +
|
||||||
"\n Stories? " + capabilities.isStories() +
|
"\n Stories? " + capabilities.isStories() +
|
||||||
|
"\n Gift Badges? " + capabilities.isGiftBadges() +
|
||||||
"\n UUID? " + capabilities.isUuid());
|
"\n UUID? " + capabilities.isUuid());
|
||||||
|
|
||||||
SignalServiceAccountManager signalAccountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
SignalServiceAccountManager signalAccountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||||||
boolean localHasSubscriptionBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isSubscription);
|
boolean localHasSubscriptionBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isSubscription);
|
||||||
boolean remoteHasBoostBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost);
|
boolean remoteHasBoostBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost);
|
||||||
boolean localHasBoostBadges = localDonorBadgeIds.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) {
|
if (!remoteHasSubscriptionBadges && localHasSubscriptionBadges) {
|
||||||
Badge mostRecentExpiration = Recipient.self()
|
Badge mostRecentExpiration = Recipient.self()
|
||||||
@@ -307,6 +309,19 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||||||
SignalStore.donationsValues().setExpiredBadge(mostRecentExpiration);
|
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 userHasVisibleBadges = badges.stream().anyMatch(SignalServiceProfile.Badge::isVisible);
|
||||||
boolean userHasInvisibleBadges = badges.stream().anyMatch(b -> !b.isVisible());
|
boolean userHasInvisibleBadges = badges.stream().anyMatch(b -> !b.isVisible());
|
||||||
|
|
||||||
@@ -326,13 +341,17 @@ public class RefreshOwnProfileJob extends BaseJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isSubscription(String badgeId) {
|
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) {
|
private static boolean isBoost(String badgeId) {
|
||||||
return Objects.equals(badgeId, Badge.BOOST_BADGE_ID);
|
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> {
|
public static final class Factory implements Job.Factory<RefreshOwnProfileJob> {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+2
-2
@@ -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
|
* - 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 should have the following characteristics:
|
||||||
* - expiration_time mod 86400 == 0
|
* - 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) {
|
private static boolean isCredentialValid(@NonNull ActiveSubscription.Subscription subscription, @NonNull ReceiptCredential receiptCredential) {
|
||||||
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
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 isSameLevel = subscription.getLevel() == receiptCredential.getReceiptLevel();
|
||||||
boolean isExpirationAfterSub = subscription.getEndOfCurrentPeriod() < receiptCredential.getReceiptExpirationTime();
|
boolean isExpirationAfterSub = subscription.getEndOfCurrentPeriod() < receiptCredential.getReceiptExpirationTime();
|
||||||
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
|
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 val TAG = Log.tag(DonationsValues::class.java)
|
||||||
|
|
||||||
private const val KEY_SUBSCRIPTION_CURRENCY_CODE = "donation.currency.code"
|
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_SUBSCRIBER_ID_PREFIX = "donation.subscriber.id."
|
||||||
private const val KEY_LAST_KEEP_ALIVE_LAUNCH = "donation.last.successful.ping"
|
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 KEY_LAST_END_OF_PERIOD_SECONDS = "donation.last.end.of.period"
|
||||||
private const val EXPIRED_BADGE = "donation.expired.badge"
|
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 USER_MANUALLY_CANCELLED = "donation.user.manually.cancelled"
|
||||||
private const val KEY_LEVEL_OPERATION_PREFIX = "donation.level.operation."
|
private const val KEY_LEVEL_OPERATION_PREFIX = "donation.level.operation."
|
||||||
private const val KEY_LEVEL_HISTORY = "donation.level.history"
|
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 onFirstEverAppLaunch() = Unit
|
||||||
|
|
||||||
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(
|
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(
|
||||||
KEY_CURRENCY_CODE_BOOST,
|
KEY_CURRENCY_CODE_ONE_TIME,
|
||||||
KEY_LAST_KEEP_ALIVE_LAUNCH,
|
KEY_LAST_KEEP_ALIVE_LAUNCH,
|
||||||
KEY_LAST_END_OF_PERIOD_SECONDS,
|
KEY_LAST_END_OF_PERIOD_SECONDS,
|
||||||
SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT,
|
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()) }
|
private val subscriptionCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) }
|
||||||
val observableSubscriptionCurrency: Observable<Currency> by lazy { subscriptionCurrencyPublisher }
|
val observableSubscriptionCurrency: Observable<Currency> by lazy { subscriptionCurrencyPublisher }
|
||||||
|
|
||||||
private val boostCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getBoostCurrency()) }
|
private val oneTimeCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getOneTimeCurrency()) }
|
||||||
val observableBoostCurrency: Observable<Currency> by lazy { boostCurrencyPublisher }
|
val observableOneTimeCurrency: Observable<Currency> by lazy { oneTimeCurrencyPublisher }
|
||||||
|
|
||||||
fun getSubscriptionCurrency(): Currency {
|
fun getSubscriptionCurrency(): Currency {
|
||||||
val currencyCode = getString(KEY_SUBSCRIPTION_CURRENCY_CODE, null)
|
val currencyCode = getString(KEY_SUBSCRIPTION_CURRENCY_CODE, null)
|
||||||
@@ -87,20 +88,20 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBoostCurrency(): Currency {
|
fun getOneTimeCurrency(): Currency {
|
||||||
val boostCurrencyCode = getString(KEY_CURRENCY_CODE_BOOST, null)
|
val oneTimeCurrency = getString(KEY_CURRENCY_CODE_ONE_TIME, null)
|
||||||
return if (boostCurrencyCode == null) {
|
return if (oneTimeCurrency == null) {
|
||||||
val currency = getSubscriptionCurrency()
|
val currency = getSubscriptionCurrency()
|
||||||
setBoostCurrency(currency)
|
setOneTimeCurrency(currency)
|
||||||
currency
|
currency
|
||||||
} else {
|
} else {
|
||||||
Currency.getInstance(boostCurrencyCode)
|
Currency.getInstance(oneTimeCurrency)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBoostCurrency(currency: Currency) {
|
fun setOneTimeCurrency(currency: Currency) {
|
||||||
putString(KEY_CURRENCY_CODE_BOOST, currency.currencyCode)
|
putString(KEY_CURRENCY_CODE_ONE_TIME, currency.currencyCode)
|
||||||
boostCurrencyPublisher.onNext(currency)
|
oneTimeCurrencyPublisher.onNext(currency)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSubscriber(currency: Currency): Subscriber? {
|
fun getSubscriber(currency: Currency): Subscriber? {
|
||||||
@@ -179,6 +180,20 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
|||||||
return Badges.fromDatabaseBadge(BadgeList.Badge.parseFrom(badgeBytes))
|
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 {
|
fun getLastKeepAliveLaunchTime(): Long {
|
||||||
return getLong(KEY_LAST_KEEP_ALIVE_LAUNCH, 0L)
|
return getLong(KEY_LAST_KEEP_ALIVE_LAUNCH, 0L)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ sealed class MediaSelectionDestination {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromParcel(parcelables: List<ContactSearchKey.ParcelableRecipientSearchKey>): MultipleRecipients {
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -244,7 +244,8 @@ class MediaSelectionRepository(context: Context) {
|
|||||||
emptyList(),
|
emptyList(),
|
||||||
mentions,
|
mentions,
|
||||||
mutableSetOf(),
|
mutableSetOf(),
|
||||||
mutableSetOf()
|
mutableSetOf(),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isStory && preUploadResults.size > 1) {
|
if (isStory && preUploadResults.size > 1) {
|
||||||
|
|||||||
+1
-1
@@ -140,7 +140,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
|
|||||||
|
|
||||||
setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY) { _, bundle ->
|
setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY) { _, bundle ->
|
||||||
val parcelizedKeys: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
|
val parcelizedKeys: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
|
||||||
val contactSearchKeys = parcelizedKeys.map { it.asContactSearchKey() }
|
val contactSearchKeys = parcelizedKeys.map { it.asRecipientSearchKey() }
|
||||||
performSend(contactSearchKeys)
|
performSend(contactSearchKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -64,7 +64,8 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
|
|||||||
mediator = ContactSearchMediator(
|
mediator = ContactSearchMediator(
|
||||||
this,
|
this,
|
||||||
contactRecycler,
|
contactRecycler,
|
||||||
FeatureFlags.shareSelectionLimit()
|
FeatureFlags.shareSelectionLimit(),
|
||||||
|
true
|
||||||
) { state ->
|
) { state ->
|
||||||
ContactSearchConfiguration.build {
|
ContactSearchConfiguration.build {
|
||||||
query = state.query
|
query = state.query
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
|
|||||||
}
|
}
|
||||||
|
|
||||||
val contactsRecyclerView: RecyclerView = view.findViewById(R.id.contacts_container)
|
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 {
|
ContactSearchConfiguration.build {
|
||||||
query = contactSearchState.query
|
query = contactSearchState.query
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -75,7 +75,8 @@ class TextStoryPostSendRepository {
|
|||||||
listOfNotNull(linkPreview),
|
listOfNotNull(linkPreview),
|
||||||
emptyList(),
|
emptyList(),
|
||||||
mutableSetOf(),
|
mutableSetOf(),
|
||||||
mutableSetOf()
|
mutableSetOf(),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.add(OutgoingSecureMediaMessage(message))
|
messages.add(OutgoingSecureMediaMessage(message))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user