Implement badge gifting behind feature flag.

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

View file

@ -417,6 +417,11 @@
android:windowSoftInputMode="stateAlwaysHidden">
</activity>
<activity android:name=".badges.gifts.flow.GiftFlowActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden">
</activity>
<activity android:name=".wallpaper.ChatWallpaperActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"

View file

@ -20,6 +20,6 @@ public final class AppCapabilities {
* asking if the user has set a Signal PIN or not.
*/
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories());
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories(), FeatureFlags.giftBadges());
}
}

View file

@ -103,5 +103,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
}
}

View file

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

View file

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

View file

@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.badges.gifts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* Contains shared DSL layout for expired gifts, creatable using a GiftBadge or a Badge.
*/
object ExpiredGiftSheetConfiguration {
fun register(mappingAdapter: MappingAdapter) {
BadgeDisplay112.register(mappingAdapter)
}
fun DSLConfiguration.forExpiredBadge(badge: Badge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
customPref(BadgeDisplay112.Model(badge, withDisplayText = false))
expiredSheet(onMakeAMonthlyDonation, onNotNow)
}
fun DSLConfiguration.forExpiredGiftBadge(giftBadge: GiftBadge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
customPref(BadgeDisplay112.GiftModel(giftBadge))
expiredSheet(onMakeAMonthlyDonation, onNotNow)
}
private fun DSLConfiguration.expiredSheet(onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
textPref(
title = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired,
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
)
)
textPref(
title = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired_and_is,
DSLSettingsText.CenterModifier
)
)
if (SignalStore.donationsValues().isLikelyASustainer()) {
primaryButton(
text = DSLSettingsText.from(
stringId = android.R.string.ok
),
onClick = {
onNotNow()
}
)
} else {
textPref(
title = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__to_continue,
DSLSettingsText.CenterModifier
)
)
primaryButton(
text = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__make_a_monthly_donation
),
onClick = {
onMakeAMonthlyDonation()
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__not_now
),
onClick = {
onNotNow()
}
)
}
}
}

View file

@ -0,0 +1,110 @@
package org.thoughtcrime.securesms.badges.gifts
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.button.MaterialButton
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.GlideRequests
/**
* Displays a gift badge sent to or received from a user, and allows the user to
* perform an action based off the badge's redemption state.
*/
class GiftMessageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
init {
inflate(context, R.layout.gift_message_view, this)
}
private val badgeView: BadgeImageView = findViewById(R.id.gift_message_view_badge)
private val titleView: TextView = findViewById(R.id.gift_message_view_title)
private val descriptionView: TextView = findViewById(R.id.gift_message_view_description)
private val actionView: MaterialButton = findViewById(R.id.gift_message_view_action)
init {
context.obtainStyledAttributes(attrs, R.styleable.GiftMessageView).use {
val textColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__textColor, Color.RED)
titleView.setTextColor(textColor)
descriptionView.setTextColor(textColor)
val buttonTextColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonTextColor, Color.RED)
actionView.setTextColor(buttonTextColor)
actionView.iconTint = ColorStateList.valueOf(buttonTextColor)
val buttonBackgroundTint = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonBackgroundTint, Color.RED)
actionView.backgroundTintList = ColorStateList.valueOf(buttonBackgroundTint)
}
}
fun setGiftBadge(glideRequests: GlideRequests, giftBadge: GiftBadge, isOutgoing: Boolean, callback: Callback) {
titleView.setText(R.string.GiftMessageView__gift_badge)
descriptionView.text = resources.getQuantityString(R.plurals.GiftMessageView__lasts_for_d_months, 1, 1)
actionView.icon = null
actionView.setOnClickListener { callback.onViewGiftBadgeClicked() }
actionView.isEnabled = true
if (isOutgoing) {
actionView.setText(R.string.GiftMessageView__view)
} else {
when (giftBadge.redemptionState) {
GiftBadge.RedemptionState.REDEEMED -> {
stopAnimationIfNeeded()
actionView.setIconResource(R.drawable.ic_check_circle_24)
}
GiftBadge.RedemptionState.STARTED -> actionView.icon = CircularProgressDrawable(context).apply {
actionView.isEnabled = false
setColorSchemeColors(ContextCompat.getColor(context, R.color.core_ultramarine))
strokeWidth = DimensionUnit.DP.toPixels(2f)
start()
}
else -> {
stopAnimationIfNeeded()
actionView.icon = null
}
}
actionView.setText(
when (giftBadge.redemptionState ?: GiftBadge.RedemptionState.UNRECOGNIZED) {
GiftBadge.RedemptionState.PENDING -> R.string.GiftMessageView__redeem
GiftBadge.RedemptionState.STARTED -> R.string.GiftMessageView__redeeming
GiftBadge.RedemptionState.REDEEMED -> R.string.GiftMessageView__redeemed
GiftBadge.RedemptionState.FAILED -> R.string.GiftMessageView__redeem
GiftBadge.RedemptionState.UNRECOGNIZED -> R.string.GiftMessageView__redeem
}
)
}
badgeView.setGiftBadge(giftBadge, glideRequests)
}
fun onGiftNotOpened() {
actionView.isClickable = false
}
fun onGiftOpened() {
actionView.isClickable = true
}
private fun stopAnimationIfNeeded() {
val icon = actionView.icon
if (icon is CircularProgressDrawable) {
icon.stop()
}
}
interface Callback {
fun onViewGiftBadgeClicked()
}
}

View file

@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.badges.gifts
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
/**
* Helper object for Gift badges
*/
object Gifts {
/**
* Request Code for getting token from Google Pay
*/
const val GOOGLE_PAY_REQUEST_CODE = 3000
/**
* Creates an OutgoingSecureMediaMessage which contains the given gift badge.
*/
fun createOutgoingGiftMessage(
recipient: Recipient,
giftBadge: GiftBadge,
sentTimestamp: Long,
expiresIn: Long
): OutgoingMediaMessage {
return OutgoingSecureMediaMessage(
recipient,
Base64.encodeBytes(giftBadge.toByteArray()),
listOf(),
sentTimestamp,
ThreadDatabase.DistributionTypes.CONVERSATION,
expiresIn,
false,
StoryType.NONE,
null,
false,
null,
listOf(),
listOf(),
listOf(),
giftBadge
)
}
}

View file

@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.badges.gifts
import org.thoughtcrime.securesms.util.Projection
/**
* Notes that a given item can have a gift box drawn over it.
*/
interface OpenableGift {
/**
* Returns a projection to draw a top, or null to not do so.
*/
fun getOpenableGiftProjection(): Projection?
/**
* Returns a unique id assosicated with this gift.
*/
fun getGiftId(): Long
/**
* Registers a callback to start the open animation
*/
fun setOpenGiftCallback(openGift: (OpenableGift) -> Unit)
/**
* Clears any callback created to start the open animation
*/
fun clearOpenGiftCallback()
}

View file

@ -0,0 +1,237 @@
package org.thoughtcrime.securesms.badges.gifts
import android.animation.FloatEvaluator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.provider.Settings
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.content.ContextCompat
import androidx.core.graphics.withSave
import androidx.core.graphics.withTranslation
import androidx.core.view.children
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.Projection
import kotlin.math.PI
import kotlin.math.max
import kotlin.math.sin
/**
* Controls the gift box top and related animations for Gift bubbles.
*/
class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver {
private val animatorDurationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f)
private val messageIdsShakenThisSession = mutableSetOf<Long>()
private val messageIdsOpenedThisSession = mutableSetOf<Long>()
private val animationState = mutableMapOf<Long, GiftAnimationState>()
private val rect = RectF()
private val lineWidth = DimensionUnit.DP.toPixels(24f).toInt()
private val boxPaint = Paint().apply {
isAntiAlias = true
color = ContextCompat.getColor(context, R.color.core_ultramarine)
}
private val ribbonPaint = Paint().apply {
isAntiAlias = true
color = Color.WHITE
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
animationState.clear()
}
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
var needsInvalidation = false
val openableChildren = parent.children.filterIsInstance(OpenableGift::class.java)
val deadKeys = animationState.keys.filterNot { giftId -> openableChildren.any { it.getGiftId() == giftId } }
deadKeys.forEach {
animationState.remove(it)
}
val notAnimated = openableChildren.filterNot { animationState.containsKey(it.getGiftId()) }
notAnimated.filterNot { messageIdsOpenedThisSession.contains(it.getGiftId()) }.forEach { child ->
val projection = child.getOpenableGiftProjection()
if (projection != null) {
if (messageIdsShakenThisSession.contains(child.getGiftId())) {
child.setOpenGiftCallback {
child.clearOpenGiftCallback()
val proj = it.getOpenableGiftProjection()
if (proj != null) {
messageIdsOpenedThisSession.add(it.getGiftId())
startOpenAnimation(it)
parent.invalidate()
}
}
drawGiftBox(c, projection)
drawGiftBow(c, projection)
} else {
messageIdsShakenThisSession.add(child.getGiftId())
startShakeAnimation(child)
drawGiftBox(c, projection)
drawGiftBow(c, projection)
needsInvalidation = true
}
projection.release()
}
}
openableChildren.filter { animationState.containsKey(it.getGiftId()) }.forEach { child ->
val runningAnimation = animationState[child.getGiftId()]!!
c.withSave {
val isThisAnimationRunning = runningAnimation.update(
animatorDurationScale = animatorDurationScale,
canvas = c,
drawBox = this@OpenableGiftItemDecoration::drawGiftBox,
drawBow = this@OpenableGiftItemDecoration::drawGiftBow
)
if (!isThisAnimationRunning) {
animationState.remove(child.getGiftId())
}
needsInvalidation = true
}
}
if (needsInvalidation) {
parent.invalidate()
}
}
private fun drawGiftBox(canvas: Canvas, projection: Projection) {
canvas.drawPath(projection.path, boxPaint)
rect.set(
projection.x + (projection.width / 2) - lineWidth / 2,
projection.y,
projection.x + (projection.width / 2) + lineWidth / 2,
projection.y + projection.height
)
canvas.drawRect(rect, ribbonPaint)
rect.set(
projection.x,
projection.y + (projection.height / 2) - lineWidth / 2,
projection.x + projection.width,
projection.y + (projection.height / 2) + lineWidth / 2
)
canvas.drawRect(rect, ribbonPaint)
}
private fun drawGiftBow(canvas: Canvas, projection: Projection) {
rect.set(
projection.x + (projection.width / 2) - lineWidth,
projection.y + (projection.height / 2) - lineWidth,
projection.x + (projection.width / 2) + lineWidth,
projection.y + (projection.height / 2) + lineWidth
)
canvas.drawRect(rect, ribbonPaint)
}
private fun startShakeAnimation(child: OpenableGift) {
animationState[child.getGiftId()] = GiftAnimationState.ShakeAnimationState(child, System.currentTimeMillis())
}
private fun startOpenAnimation(child: OpenableGift) {
animationState[child.getGiftId()] = GiftAnimationState.OpenAnimationState(child, System.currentTimeMillis())
}
sealed class GiftAnimationState(val openableGift: OpenableGift, val startTime: Long) {
/**
* Shakes the gift box to the left and right, slightly revealing the contents underneath.
* Uses a lag value to keep the bow one "frame" behind the box, to give it the effect of
* following behind.
*/
class ShakeAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime) {
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
canvas.withTranslation(x = getTranslation(progress).toFloat()) {
drawBox(canvas, projection)
}
canvas.withTranslation(x = getTranslation(lastFrameProgress).toFloat()) {
drawBow(canvas, projection)
}
}
private fun getTranslation(progress: Float): Double {
val interpolated = INTERPOLATOR.getInterpolation(progress)
val evaluated = EVALUATOR.evaluate(interpolated, 0f, 360f)
return 0.25f * sin(4 * evaluated * PI / 180f) * 180f / PI
}
}
class OpenAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime) {
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
val interpolatedProgress = INTERPOLATOR.getInterpolation(progress)
val evaluatedValue = EVALUATOR.evaluate(interpolatedProgress, 0f, DimensionUnit.DP.toPixels(300f))
canvas.translate(evaluatedValue, -evaluatedValue)
drawBox(canvas, projection)
drawBow(canvas, projection)
}
}
fun update(animatorDurationScale: Float, canvas: Canvas, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit): Boolean {
val projection = openableGift.getOpenableGiftProjection() ?: return false
if (animatorDurationScale <= 0f) {
update(canvas, projection, 0f, 0f, drawBox, drawBow)
projection.release()
return false
}
val currentFrameTime = System.currentTimeMillis()
val lastFrameProgress = max(0f, (currentFrameTime - startTime - ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS) / (DURATION_MILLIS.toFloat() * animatorDurationScale))
val progress = (currentFrameTime - startTime) / (DURATION_MILLIS.toFloat() * animatorDurationScale)
if (progress > 1f) {
update(canvas, projection, 1f, 1f, drawBox, drawBow)
projection.release()
return false
}
update(canvas, projection, progress, lastFrameProgress, drawBox, drawBow)
projection.release()
return true
}
protected abstract fun update(
canvas: Canvas,
projection: Projection,
progress: Float,
lastFrameProgress: Float,
drawBox: (Canvas, Projection) -> Unit,
drawBow: (Canvas, Projection) -> Unit
)
}
companion object {
private val INTERPOLATOR = AccelerateDecelerateInterpolator()
private val EVALUATOR = FloatEvaluator()
private const val DURATION_MILLIS = 1000
private const val ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS = 33
}
}

View file

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import org.signal.core.util.money.FiatMoney
/**
* Convenience wrapper for a gift at a particular price point.
*/
data class Gift(val level: Long, val price: FiatMoney)

View file

@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
/**
* Activity which houses the gift flow.
*/
class GiftFlowActivity : FragmentWrapperActivity(), DonationPaymentComponent {
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
onBackPressedDispatcher.addCallback(this, OnBackPressed())
}
override fun getFragment(): Fragment {
return NavHostFragment.create(R.navigation.gift_flow)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
googlePayResultPublisher.onNext(DonationPaymentComponent.GooglePayResult(requestCode, resultCode, data))
}
private inner class OnBackPressed : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!findNavController(R.id.fragment_container).popBackStack()) {
finish()
}
}
}
}

View file

@ -0,0 +1,254 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.DialogInterface
import android.view.KeyEvent
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.InputAwareLayout
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.components.settings.models.TextInput
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.requireListener
/**
* Allows the user to confirm details about a gift, add a message, and finally make a payment.
*/
class GiftFlowConfirmationFragment :
DSLSettingsFragment(
titleId = R.string.GiftFlowConfirmationFragment__confirm_gift,
layoutId = R.layout.gift_flow_confirmation_fragment
),
EmojiKeyboardPageFragment.Callback,
EmojiEventListener,
EmojiSearchFragment.Callback {
companion object {
private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
}
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private lateinit var inputAwareLayout: InputAwareLayout
private lateinit var emojiKeyboard: MediaKeyboard
private val lifecycleDisposable = LifecycleDisposable()
private var errorDialog: DialogInterface? = null
private lateinit var processingDonationPaymentDialog: AlertDialog
private lateinit var donationPaymentComponent: DonationPaymentComponent
private lateinit var textInputViewHolder: TextInput.MultilineViewHolder
private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
RecipientPreference.register(adapter)
GiftRowItem.register(adapter)
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
donationPaymentComponent = requireListener()
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
.setCancelable(false)
.create()
inputAwareLayout = requireView().findViewById(R.id.input_aware_layout)
emojiKeyboard = requireView().findViewById(R.id.emoji_drawer)
emojiKeyboard.setFragmentManager(childFragmentManager)
val googlePayButton = requireView().findViewById<GooglePayButton>(R.id.google_pay_button)
googlePayButton.setOnGooglePayClickListener {
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time))
}
val textInput = requireView().findViewById<FrameLayout>(R.id.text_input)
val emojiToggle = textInput.findViewById<ImageView>(R.id.emoji_toggle)
textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher)
textInputViewHolder.onAttachedToWindow()
inputAwareLayout.addOnKeyboardShownListener {
inputAwareLayout.hideAttachedInput(true)
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (inputAwareLayout.isInputOpen) {
inputAwareLayout.hideAttachedInput(true)
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
} else {
findNavController().popBackStack()
}
}
}
)
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
if (state.stage == GiftFlowState.Stage.PAYMENT_PIPELINE) {
processingDonationPaymentDialog.show()
} else {
processingDonationPaymentDialog.hide()
}
textInputViewHolder.bind(
TextInput.MultilineModel(
text = state.additionalMessage,
hint = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__add_a_message),
onTextChanged = {
viewModel.setAdditionalMessage(it)
},
onEmojiToggleClicked = {
if (inputAwareLayout.isKeyboardOpen || (!inputAwareLayout.isKeyboardOpen && !inputAwareLayout.isInputOpen)) {
inputAwareLayout.show(it, emojiKeyboard)
emojiToggle.setImageResource(R.drawable.ic_keyboard_24)
} else {
inputAwareLayout.showSoftkey(it)
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
}
}
)
)
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += DonationError
.getErrorsForSource(DonationErrorSource.GIFT)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { donationError ->
onPaymentError(donationError)
}
lifecycleDisposable += viewModel.events.observeOn(AndroidSchedulers.mainThread()).subscribe { donationEvent ->
when (donationEvent) {
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed()
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> Unit
is DonationEvent.SubscriptionCancellationFailed -> Unit
}
}
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
}
}
override fun onDestroyView() {
super.onDestroyView()
textInputViewHolder.onDetachedFromWindow()
processingDonationPaymentDialog.dismiss()
}
private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration {
return configure {
if (giftFlowState.giftBadge != null) {
giftFlowState.giftPrices[giftFlowState.currency]?.let {
customPref(
GiftRowItem.Model(
giftBadge = giftFlowState.giftBadge,
price = it
)
)
}
}
sectionHeaderPref(R.string.GiftFlowConfirmationFragment__send_to)
customPref(
RecipientPreference.Model(
recipient = giftFlowState.recipient!!
)
)
textPref(
summary = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__your_gift_will_be_sent_in)
)
}
}
private fun onPaymentConfirmed() {
val mainActivityIntent = MainActivity.clearTop(requireContext())
val conversationIntent = ConversationIntents
.createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L)
.withGiftBadge(viewModel.snapshot.giftBadge!!)
.build()
requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent))
}
private fun onPaymentError(throwable: Throwable?) {
Log.w(TAG, "onPaymentError", throwable, true)
if (errorDialog != null) {
Log.i(TAG, "Already displaying an error dialog. Skipping.")
return
}
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
requireActivity().finish()
}
}
)
}
override fun openEmojiSearch() {
emojiKeyboard.onOpenEmojiSearch()
}
override fun closeEmojiSearch() {
emojiKeyboard.onCloseEmojiSearch()
}
override fun onEmojiSelected(emoji: String?) {
if (emoji?.isNotEmpty() == true) {
eventPublisher.onNext(TextInput.TextInputEvent.OnEmojiEvent(emoji))
}
}
override fun onKeyEvent(keyEvent: KeyEvent?) {
if (keyEvent != null) {
eventPublisher.onNext(TextInput.TextInputEvent.OnKeyEvent(keyEvent))
}
}
}

View file

@ -0,0 +1,93 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Allows the user to select a recipient to send a gift to.
*/
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() }
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(
R.id.multiselect_container,
MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = emptyList(),
forceDisableAddMessage = true,
selectSingleRecipient = true
)
)
)
.commit()
}
}
override fun getSearchConfiguration(fragmentManager: FragmentManager, contactSearchState: ContactSearchState): ContactSearchConfiguration {
return ContactSearchConfiguration.build {
query = contactSearchState.query
if (query.isNullOrEmpty()) {
addSection(
ContactSearchConfiguration.Section.Recents(
includeHeader = true,
mode = ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS
)
)
}
addSection(
ContactSearchConfiguration.Section.Individuals(
includeSelf = false,
transportType = ContactSearchConfiguration.TransportType.PUSH,
includeHeader = true
)
)
}
}
override fun onFinishForwardAction() = Unit
override fun exitFlow() = Unit
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
val parcelableContacts: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
val contacts = parcelableContacts.map { it.asRecipientSearchKey() }
if (contacts.isNotEmpty()) {
viewModel.setSelectedContact(contacts.first())
findNavController().safeNavigate(R.id.action_giftFlowRecipientSelectionFragment_to_giftFlowConfirmationFragment)
}
}
override fun getContainer(): ViewGroup = requireView() as ViewGroup
override fun getDialogBackgroundColor(): Int = Color.TRANSPARENT
}

View file

@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Currency
import java.util.Locale
/**
* Repository for grabbing gift badges and supported currency information.
*/
class GiftFlowRepository {
fun getGiftBadge(): Single<Pair<Long, Badge>> {
return ApplicationDependencies.getDonationsService()
.getGiftBadges(Locale.getDefault())
.flatMap(ServiceResponse<Map<Long, SignalServiceProfile.Badge>>::flattenResult)
.map { gifts -> gifts.map { it.key to Badges.fromServiceBadge(it.value) } }
.map { it.first() }
.subscribeOn(Schedulers.io())
}
fun getGiftPricing(): Single<Map<Currency, FiatMoney>> {
return ApplicationDependencies.getDonationsService()
.giftAmount
.subscribeOn(Schedulers.io())
.flatMap { it.flattenResult() }
.map { result ->
result
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
.mapKeys { (code, _) -> Currency.getInstance(code) }
.mapValues { (currency, price) -> FiatMoney(price, currency) }
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,6 +45,7 @@ class DSLSettingsAdapter : MappingAdapter() {
abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
protected val iconView: ImageView = itemView.findViewById(R.id.icon)
private val iconEndView: ImageView? = itemView.findViewById(R.id.icon_end)
protected val titleView: TextView = itemView.findViewById(R.id.title)
protected val summaryView: TextView = itemView.findViewById(R.id.summary)
@ -58,6 +59,10 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
iconView.setImageDrawable(icon)
iconView.visible = icon != null
val iconEnd = model.iconEnd?.resolve(context)
iconEndView?.setImageDrawable(iconEnd)
iconEndView?.visible = iconEnd != null
val title = model.title?.resolve(context)
if (title != null) {
titleView.text = model.title?.resolve(context)

View file

@ -15,9 +15,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
@ -30,11 +28,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
private val viewModel: AppSettingsViewModel by viewModels(
factoryProducer = {
AppSettingsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
}
)
private val viewModel: AppSettingsViewModel by viewModels()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
@ -48,7 +42,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
override fun onResume() {
super.onResume()
viewModel.refreshActiveSubscription()
viewModel.refreshExpiredGiftBadge()
}
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
@ -76,13 +70,22 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
)
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
customPref(
PaymentsPreference(
unreadCount = state.unreadPaymentsCount
) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
}
if (FeatureFlags.donorBadges() && PlayServicesUtil.getPlayServicesStatus(requireContext()) == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
iconEnd = if (state.hasExpiredGiftBadge) DSLSettingsIcon.from(R.drawable.ic_info_solid_24, R.color.signal_accent_primary) else null,
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
},
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
)
} else {
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)
}
@ -130,6 +133,18 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
dividerPref()
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
customPref(
PaymentsPreference(
unreadCount = state.unreadPaymentsCount
) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
}
)
}
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.preferences__help),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
@ -146,44 +161,6 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
)
if (FeatureFlags.donorBadges() && PlayServicesUtil.getPlayServicesStatus(requireContext()) == PlayServicesUtil.PlayServicesStatus.SUCCESS) {
customPref(
SubscriptionPreference(
title = DSLSettingsText.from(
if (state.hasActiveSubscription) {
R.string.preferences__subscription
} else {
R.string.preferences__monthly_donation
}
),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
isActive = state.hasActiveSubscription,
onClick = { isActive ->
if (isActive) {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
} else {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscribeFragment())
}
},
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
)
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__one_time_donation),
icon = DSLSettingsIcon.from(R.drawable.ic_boost_24),
onClick = {
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment())
},
onLongClick = this@AppSettingsFragment::copySubscriberIdToClipboard
)
} else {
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)
}
if (FeatureFlags.internalUser()) {
dividerPref()

View file

@ -5,5 +5,5 @@ import org.thoughtcrime.securesms.recipients.Recipient
data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int,
val hasActiveSubscription: Boolean
val hasExpiredGiftBadge: Boolean
)

View file

@ -2,22 +2,14 @@ package org.thoughtcrime.securesms.components.settings.app
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import java.util.concurrent.TimeUnit
class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
class AppSettingsViewModel : ViewModel() {
private val store = Store(AppSettingsState(Recipient.self(), 0, false))
private val store = Store(AppSettingsState(Recipient.self(), 0, SignalStore.donationsValues().getExpiredGiftBadge() != null))
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
@ -29,38 +21,7 @@ class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRep
store.update(selfLiveData) { self, state -> state.copy(self = self) }
}
fun refreshActiveSubscription() {
if (!FeatureFlags.donorBadges()) {
return
}
store.update {
it.copy(hasActiveSubscription = TimeUnit.SECONDS.toMillis(SignalStore.donationsValues().getLastEndOfPeriod()) > System.currentTimeMillis())
}
subscriptionsRepository.getActiveSubscription().subscribeBy(
onSuccess = { subscription -> store.update { it.copy(hasActiveSubscription = subscription.activeSubscription != null) } },
onError = { throwable ->
if (throwable.isNotFoundException()) {
Log.w(TAG, "Could not load active subscription due to unset SubscriberId (404).")
}
Log.w(TAG, "Could not load active subscription", throwable)
}
)
}
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T
}
}
companion object {
private val TAG = Log.tag(AppSettingsViewModel::class.java)
}
private fun Throwable.isNotFoundException(): Boolean {
return this is PushNetworkException && this.cause is NotFoundException || this is NotFoundException
fun refreshExpiredGiftBadge() {
store.update { it.copy(hasExpiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge() != null) }
}
}

View file

@ -12,9 +12,9 @@ import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@ -23,16 +23,21 @@ import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.ProfileUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
import java.io.IOException
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@ -47,7 +52,7 @@ import java.util.concurrent.TimeUnit
* 1. Confirm the SetupIntent via the Stripe API
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
*
* For Boosts:
* For Boosts and Gifts:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Create a PaymentIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
@ -86,19 +91,65 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
}
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
Log.d(TAG, "Creating payment intent for $price...", true)
return stripeApi.createPaymentIntent(price, application.getString(R.string.Boost__thank_you_for_your_donation))
.onErrorResumeNext { Single.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it)) }
.flatMapCompletable { result ->
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall())
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge())
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost())
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent)
/**
* @param price The amount to charce the local user
* @param paymentData PaymentData from Google Pay that describes the payment method
* @param badgeRecipient Who will be getting the badge
* @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self)
*/
fun continuePayment(price: FiatMoney, paymentData: PaymentData, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
val verifyRecipient = Completable.fromAction {
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
val recipient = Recipient.resolved(badgeRecipient)
if (recipient.isSelf) {
Log.d(TAG, "Badge recipient is self, so this is a boost. Skipping verification.", true)
return@fromAction
}
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
try {
val profile = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)
if (!profile.profile.capabilities.isGiftBadges) {
Log.w(TAG, "Badge recipient does not support gifting. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
} else {
Log.d(TAG, "Badge recipient supports gifting. Verification successful.", true)
}
} catch (e: IOException) {
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
}
}
return verifyRecipient.doOnComplete {
Log.d(TAG, "Creating payment intent for $price...", true)
}.andThen(stripeApi.createPaymentIntent(price, badgeLevel))
.onErrorResumeNext {
if (it is DonationError) {
Single.error(it)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, it))
}
}
.flatMapCompletable { result ->
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
}
}.subscribeOn(Schedulers.io())
}
fun continueSubscriptionSetup(paymentData: PaymentData): Completable {
@ -140,20 +191,36 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
}
private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Log.d(TAG, "Confirming payment intent...", true)
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext {
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it))
Completable.error(DonationError.getPaymentSetupError(donationErrorSource, it))
}
val waitOnRedemption = Completable.create {
Log.d(TAG, "Confirmed payment intent. Recording boost receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(DonationReceiptRecord.createForBoost(price))
val donationReceiptRecord = if (isBoost) {
DonationReceiptRecord.createForBoost(price)
} else {
DonationReceiptRecord.createForGift(price)
}
val donationTypeLabel = donationReceiptRecord.type.code.capitalize(Locale.US)
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntent)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
}
BoostReceiptRequestResponseJob.createJobChain(paymentIntent).enqueue { _, jobState ->
chain.enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
@ -164,25 +231,25 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Boost request response job chain succeeded.", true)
Log.d(TAG, "$donationTypeLabel request response job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Boost request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST))
Log.d(TAG, "$donationTypeLabel request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(donationErrorSource))
}
else -> {
Log.d(TAG, "Boost request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
}
} else {
Log.d(TAG, "Boost redemption timed out waiting for job completion.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
} catch (e: InterruptedException) {
Log.d(TAG, "Boost redemption job interrupted", e, true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
}
}
@ -282,11 +349,11 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
}
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeApi.PaymentIntent> {
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
return ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, description)
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
.map {
StripeApi.PaymentIntent(it.id, it.clientSecret)

View file

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -23,6 +24,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
val localSubscription = SignalStore.donationsValues().getSubscriber()
return if (localSubscription != null) {
donationsService.getSubscription(localSubscription.subscriberId)
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
} else {
Single.just(ActiveSubscription.EMPTY)
@ -30,6 +32,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) {
}
fun getSubscriptions(): Single<List<Subscription>> = donationsService.getSubscriptionLevels(Locale.getDefault())
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<SubscriptionLevels>::flattenResult)
.map { subscriptionLevels ->
subscriptionLevels.levels.map { (code, level) ->

View file

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
@ -16,6 +17,7 @@ class BoostRepository(private val donationsService: DonationsService) {
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
return donationsService.boostAmounts
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<Map<String, List<BigDecimal>>>::flattenResult)
.map { result ->
result
@ -27,6 +29,7 @@ class BoostRepository(private val donationsService: DonationsService) {
fun getBoostBadge(): Single<Badge> {
return donationsService.getBoostBadge(Locale.getDefault())
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<SignalServiceProfile.Badge>::flattenResult)
.map(Badges::fromServiceBadge)
}

View file

@ -23,9 +23,11 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
@ -37,7 +39,7 @@ class BoostViewModel(
private val fetchTokenRequestCode: Int
) : ViewModel() {
private val store = Store(BoostState(currencySelection = SignalStore.donationsValues().getBoostCurrency()))
private val store = Store(BoostState(currencySelection = SignalStore.donationsValues().getOneTimeCurrency()))
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
private val disposables = CompositeDisposable()
private val networkDisposable: Disposable
@ -77,7 +79,7 @@ class BoostViewModel(
fun refresh() {
disposables.clear()
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
val currencyObservable = SignalStore.donationsValues().observableOneTimeCurrency
val allBoosts = boostRepository.getBoosts()
val boostBadge = boostRepository.getBoostBadge()
@ -85,7 +87,7 @@ class BoostViewModel(
val boostList = if (currency in boostMap) {
boostMap[currency]!!
} else {
SignalStore.donationsValues().setBoostCurrency(PlatformCurrencyUtil.USD)
SignalStore.donationsValues().setOneTimeCurrency(PlatformCurrencyUtil.USD)
listOf()
}
@ -140,7 +142,7 @@ class BoostViewModel(
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
donationPaymentRepository.continuePayment(boost.price, paymentData, Recipient.self().id, null, SubscriptionLevels.BOOST_LEVEL.toLong()).subscribeBy(
onError = { throwable ->
store.update { it.copy(stage = BoostState.Stage.READY) }
val donationError: DonationError = if (throwable is DonationError) {

View file

@ -13,14 +13,14 @@ import java.util.Currency
import java.util.Locale
class SetCurrencyViewModel(
private val isBoost: Boolean,
private val isOneTime: Boolean,
supportedCurrencyCodes: List<String>
) : ViewModel() {
private val store = Store(
SetCurrencyState(
selectedCurrencyCode = if (isBoost) {
SignalStore.donationsValues().getBoostCurrency().currencyCode
selectedCurrencyCode = if (isOneTime) {
SignalStore.donationsValues().getOneTimeCurrency().currencyCode
} else {
SignalStore.donationsValues().getSubscriptionCurrency().currencyCode
},
@ -35,8 +35,8 @@ class SetCurrencyViewModel(
fun setSelectedCurrency(selectedCurrencyCode: String) {
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
if (isBoost) {
SignalStore.donationsValues().setBoostCurrency(Currency.getInstance(selectedCurrencyCode))
if (isOneTime) {
SignalStore.donationsValues().setOneTimeCurrency(Currency.getInstance(selectedCurrencyCode))
} else {
val currency = Currency.getInstance(selectedCurrencyCode)
val subscriber = SignalStore.donationsValues().getSubscriber(currency)
@ -83,9 +83,9 @@ class SetCurrencyViewModel(
}
}
class Factory(private val isBoost: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
class Factory(private val isOneTime: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(SetCurrencyViewModel(isBoost, supportedCurrencyCodes))!!
return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!!
}
}
}

View file

@ -19,12 +19,21 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
}
/**
* Boost validation errors, which occur before the user could be charged.
* Gifting recipient validation errors, which occur before the user could be charged for a gift.
*/
sealed class BoostError(message: String) : DonationError(DonationErrorSource.BOOST, Exception(message)) {
object AmountTooSmallError : BoostError("Amount is too small")
object AmountTooLargeError : BoostError("Amount is too large")
object InvalidCurrencyError : BoostError("Currency is not supported")
sealed class GiftRecipientVerificationError(cause: Throwable) : DonationError(DonationErrorSource.GIFT, cause) {
object SelectedRecipientIsInvalid : GiftRecipientVerificationError(Exception("Selected recipient is invalid."))
object SelectedRecipientDoesNotSupportGifts : GiftRecipientVerificationError(Exception("Selected recipient does not support gifts."))
class FailedToFetchProfile(cause: Throwable) : GiftRecipientVerificationError(Exception("Failed to fetch recipient profile.", cause))
}
/**
* One-time donation validation errors, which occur before the user could be charged.
*/
sealed class OneTimeDonationError(source: DonationErrorSource, message: String) : DonationError(source, Exception(message)) {
class AmountTooSmallError(source: DonationErrorSource) : OneTimeDonationError(source, "Amount is too small")
class AmountTooLargeError(source: DonationErrorSource) : OneTimeDonationError(source, "Amount is too large")
class InvalidCurrencyError(source: DonationErrorSource) : OneTimeDonationError(source, "Currency is not supported")
}
/**
@ -69,6 +78,11 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
*/
class TimeoutWaitingForTokenError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Timed out waiting for badge redemption to complete."))
/**
* Verification of request credentials object failed
*/
class FailedToValidateCredentialError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Failed to validate credential from server."))
/**
* Some generic error not otherwise accounted for occurred during the redemption process.
*/
@ -134,13 +148,13 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
}
@JvmStatic
fun boostAmountTooSmall(): DonationError = BoostError.AmountTooSmallError
fun oneTimeDonationAmountTooSmall(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooSmallError(source)
@JvmStatic
fun boostAmountTooLarge(): DonationError = BoostError.AmountTooLargeError
fun oneTimeDonationAmountTooLarge(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooLargeError(source)
@JvmStatic
fun invalidCurrencyForBoost(): DonationError = BoostError.InvalidCurrencyError
fun invalidCurrencyForOneTimeDonation(source: DonationErrorSource): DonationError = OneTimeDonationError.InvalidCurrencyError(source)
@JvmStatic
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
@ -148,6 +162,9 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
@JvmStatic
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)
@JvmStatic
fun badgeCredentialVerificationFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.FailedToValidateCredentialError(source)
@JvmStatic
fun genericPaymentFailure(source: DonationErrorSource): DonationError = PaymentProcessingError.GenericError(source)
}

View file

@ -23,6 +23,7 @@ class DonationErrorParams<V> private constructor(
callback: Callback<V>
): DonationErrorParams<V> {
return when (throwable) {
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError -> DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
@ -36,6 +37,13 @@ class DonationErrorParams<V> private constructor(
positiveAction = callback.onOk(context),
negativeAction = null
)
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> DonationErrorParams(
title = R.string.DonationsErrors__failed_to_validate_badge,
message = R.string.DonationsErrors__could_not_validate,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable, callback)
else -> DonationErrorParams(
title = R.string.DonationsErrors__couldnt_add_badge,
message = R.string.DonationsErrors__your_badge_could_not,
@ -45,6 +53,32 @@ class DonationErrorParams<V> private constructor(
}
}
private fun <V> getGenericRedemptionError(context: Context, genericError: DonationError.BadgeRedemptionError.GenericError, callback: Callback<V>): DonationErrorParams<V> {
return when (genericError.source) {
DonationErrorSource.GIFT -> DonationErrorParams(
title = R.string.DonationsErrors__failed_to_send_gift_badge,
message = R.string.DonationsErrors__could_not_send_gift_badge,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
else -> DonationErrorParams(
title = R.string.DonationsErrors__couldnt_add_badge,
message = R.string.DonationsErrors__your_badge_could_not,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
}
}
private fun <V> getVerificationErrorParams(context: Context, verificationError: DonationError.GiftRecipientVerificationError, callback: Callback<V>): DonationErrorParams<V> {
return DonationErrorParams(
title = R.string.DonationsErrors__recipient_verification_failed,
message = R.string.DonationsErrors__target_does_not_support_gifting,
positiveAction = callback.onContactSupport(context),
negativeAction = null
)
}
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {
return when (declinedError.declineCode) {
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {

View file

@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
enum class DonationErrorSource(private val code: String) {
BOOST("boost"),
SUBSCRIPTION("subscription"),
GIFT("gift"),
GIFT_REDEMPTION("gift-redemption"),
KEEP_ALIVE("keep-alive"),
UNKNOWN("unknown");

View file

@ -5,7 +5,6 @@ import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@ -31,7 +30,6 @@ object ActiveSubscriptionPreference {
class Model(
val price: FiatMoney,
val subscription: Subscription,
val onAddBoostClick: () -> Unit,
val renewalTimestamp: Long = -1L,
val redemptionState: ManageDonationsState.SubscriptionRedemptionState,
val activeSubscription: ActiveSubscription.Subscription,
@ -57,7 +55,6 @@ object ActiveSubscriptionPreference {
val title: TextView = itemView.findViewById(R.id.my_support_title)
val price: TextView = itemView.findViewById(R.id.my_support_price)
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
val boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
val progress: ProgressBar = itemView.findViewById(R.id.my_support_progress)
override fun bind(model: Model) {
@ -79,10 +76,6 @@ object ActiveSubscriptionPreference {
ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS -> presentInProgressState()
ManageDonationsState.SubscriptionRedemptionState.FAILED -> presentFailureState(model)
}
boost.setOnClickListener {
model.onAddBoostClick()
}
}
private fun presentRenewalState(model: Model) {

View file

@ -1,6 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
enum class ManageDonationsEvent {
NOT_SUBSCRIBED,
ERROR_GETTING_SUBSCRIPTION
}

View file

@ -1,11 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import android.widget.Toast
import android.content.Intent
import android.text.SpannableStringBuilder
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheet
import org.thoughtcrime.securesms.badges.gifts.flow.GiftFlowActivity
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@ -14,13 +18,17 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
import java.util.concurrent.TimeUnit
@ -28,7 +36,17 @@ import java.util.concurrent.TimeUnit
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
* a subscriber. Used to manage their current subscription, view badges, and boost.
*/
class ManageDonationsFragment : DSLSettingsFragment() {
class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback {
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(requireContext().getString(R.string.SubscribeFragment__make_a_recurring_monthly_donation))
.append(" ")
.append(
SpanUtil.readMore(requireContext(), ContextCompat.getColor(requireContext(), R.color.signal_button_secondary_text)) {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeLearnMoreBottomSheetDialog())
}
)
}
private val viewModel: ManageDonationsViewModel by viewModels(
factoryProducer = {
@ -36,8 +54,6 @@ class ManageDonationsFragment : DSLSettingsFragment() {
}
)
private val lifecycleDisposable = LifecycleDisposable()
override fun onResume() {
super.onResume()
viewModel.refresh()
@ -47,24 +63,23 @@ class ManageDonationsFragment : DSLSettingsFragment() {
ActiveSubscriptionPreference.register(adapter)
IndeterminateLoadingCircle.register(adapter)
BadgePreview.register(adapter)
NetworkFailure.register(adapter)
val expiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge()
if (expiredGiftBadge != null) {
SignalStore.donationsValues().setExpiredBadge(null)
ExpiredGiftSheet.show(childFragmentManager, expiredGiftBadge)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: ManageDonationsEvent ->
when (event) {
ManageDonationsEvent.NOT_SUBSCRIBED -> handleUserIsNotSubscribed()
ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION -> handleErrorGettingSubscription()
}
}
}
private fun getConfiguration(state: ManageDonationsState): DSLConfiguration {
return configure {
customPref(
BadgePreview.Model(
BadgePreview.BadgeModel.FeaturedModel(
badge = state.featuredBadge
)
)
@ -78,91 +93,176 @@ class ManageDonationsFragment : DSLSettingsFragment() {
)
)
space(DimensionUnit.DP.toPixels(32f).toInt())
noPadTextPref(
title = DSLSettingsText.from(
R.string.ManageDonationsFragment__my_support,
DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier
)
)
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
val activeSubscription = state.transactionState.activeSubscription.activeSubscription
if (activeSubscription != null) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.level == it.level }
if (subscription != null) {
space(DimensionUnit.DP.toPixels(12f).toInt())
val activeCurrency = Currency.getInstance(activeSubscription.currency)
val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
customPref(
ActiveSubscriptionPreference.Model(
price = FiatMoney(activeAmount, activeCurrency),
subscription = subscription,
onAddBoostClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
},
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod),
redemptionState = state.getRedemptionState(),
onContactSupport = {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
},
activeSubscription = activeSubscription
)
)
dividerPref()
presentSubscriptionSettings(activeSubscription, subscription, state.getRedemptionState())
} else {
customPref(IndeterminateLoadingCircle)
}
} else {
customPref(IndeterminateLoadingCircle)
presentNoSubscriptionSettings()
}
} else if (state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
presentNetworkFailureSettings(state.getRedemptionState())
} else {
customPref(IndeterminateLoadingCircle)
}
}
}
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
isEnabled = state.getRedemptionState() != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}
private fun DSLConfiguration.presentNetworkFailureSettings(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
if (SignalStore.donationsValues().isLikelyASustainer()) {
presentSubscriptionSettingsWithNetworkError(redemptionState)
} else {
presentNoSubscriptionSettings()
}
}
private fun DSLConfiguration.presentSubscriptionSettingsWithNetworkError(redemptionState: ManageDonationsState.SubscriptionRedemptionState) {
presentSubscriptionSettingsWithState(redemptionState) {
customPref(
NetworkFailure.Model(
onRetryClick = {
viewModel.retry()
}
)
)
}
}
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges())
}
private fun DSLConfiguration.presentSubscriptionSettings(
activeSubscription: ActiveSubscription.Subscription,
subscription: Subscription,
redemptionState: ManageDonationsState.SubscriptionRedemptionState
) {
presentSubscriptionSettingsWithState(redemptionState) {
val activeCurrency = Currency.getInstance(activeSubscription.currency)
val activeAmount = activeSubscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
customPref(
ActiveSubscriptionPreference.Model(
price = FiatMoney(activeAmount, activeCurrency),
subscription = subscription,
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod),
redemptionState = redemptionState,
onContactSupport = {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
},
activeSubscription = activeSubscription
)
)
}
}
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
linkId = R.string.donate_url
private fun DSLConfiguration.presentSubscriptionSettingsWithState(
redemptionState: ManageDonationsState.SubscriptionRedemptionState,
subscriptionBlock: DSLConfiguration.() -> Unit
) {
space(DimensionUnit.DP.toPixels(32f).toInt())
noPadTextPref(
title = DSLSettingsText.from(
R.string.ManageDonationsFragment__my_subscription,
DSLSettingsText.Body1BoldModifier, DSLSettingsText.BoldModifier
)
)
space(DimensionUnit.DP.toPixels(12f).toInt())
subscriptionBlock()
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
isEnabled = redemptionState != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}
)
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__badges),
icon = DSLSettingsIcon.from(R.drawable.ic_badge_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToManageBadges())
}
)
presentOtherWaysToGive()
sectionHeaderPref(R.string.ManageDonationsFragment__more)
presentDonationReceipts()
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
linkId = R.string.donate_url
)
}
private fun DSLConfiguration.presentNoSubscriptionSettings() {
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
title = DSLSettingsText.from(supportTechSummary, DSLSettingsText.CenterModifier)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
primaryButton(
text = DSLSettingsText.from(R.string.ManageDonationsFragment__make_a_monthly_donation),
onClick = {
findNavController().safeNavigate(R.id.action_manageDonationsFragment_to_subscribeFragment)
}
)
presentOtherWaysToGive()
sectionHeaderPref(R.string.ManageDonationsFragment__receipts)
presentDonationReceipts()
}
private fun DSLConfiguration.presentOtherWaysToGive() {
dividerPref()
sectionHeaderPref(R.string.ManageDonationsFragment__other_ways_to_give)
clickPref(
title = DSLSettingsText.from(R.string.preferences__one_time_donation),
icon = DSLSettingsIcon.from(R.drawable.ic_boost_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
}
)
if (FeatureFlags.giftBadges()) {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts),
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
title = DSLSettingsText.from(R.string.ManageDonationsFragment__gift_a_badge),
icon = DSLSettingsIcon.from(R.drawable.ic_gift_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
}
)
}
}
private fun handleUserIsNotSubscribed() {
findNavController().popBackStack()
private fun DSLConfiguration.presentDonationReceipts() {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donation_receipts),
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
}
)
}
private fun handleErrorGettingSubscription() {
Toast.makeText(requireContext(), R.string.ManageDonationsFragment__error_getting_subscription, Toast.LENGTH_LONG).show()
override fun onMakeAMonthlyDonation() {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}
}

View file

@ -14,6 +14,7 @@ data class ManageDonationsState(
fun getRedemptionState(): SubscriptionRedemptionState {
return when (transactionState) {
TransactionState.Init -> subscriptionRedemptionState
TransactionState.NetworkFailure -> subscriptionRedemptionState
TransactionState.InTransaction -> SubscriptionRedemptionState.IN_PROGRESS
is TransactionState.NotInTransaction -> getStateFromActiveSubscription(transactionState.activeSubscription) ?: subscriptionRedemptionState
}
@ -29,6 +30,7 @@ data class ManageDonationsState(
sealed class TransactionState {
object Init : TransactionState()
object NetworkFailure : TransactionState()
object InTransaction : TransactionState()
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
}

View file

@ -3,18 +3,18 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
@ -23,22 +23,37 @@ class ManageDonationsViewModel(
) : ViewModel() {
private val store = Store(ManageDonationsState())
private val eventPublisher = PublishSubject.create<ManageDonationsEvent>()
private val disposables = CompositeDisposable()
private val networkDisposable: Disposable
val state: LiveData<ManageDonationsState> = store.stateLiveData
val events: Observable<ManageDonationsEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
init {
store.update(Recipient.self().live().liveDataResolved) { self, state ->
state.copy(featuredBadge = self.featuredBadge)
}
networkDisposable = InternetConnectionObserver
.observe()
.distinctUntilChanged()
.subscribe { isConnected ->
if (isConnected) {
retry()
}
}
}
override fun onCleared() {
disposables.clear()
}
fun retry() {
if (!disposables.isDisposed && store.state.transactionState == ManageDonationsState.TransactionState.NetworkFailure) {
store.update { it.copy(transactionState = ManageDonationsState.TransactionState.Init) }
refresh()
}
}
fun refresh() {
disposables.clear()
@ -72,13 +87,13 @@ class ManageDonationsViewModel(
store.update {
it.copy(transactionState = transactionState)
}
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && transactionState.activeSubscription.activeSubscription == null) {
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
}
},
onError = {
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
onError = { throwable ->
Log.w(TAG, "Error retrieving subscription transaction state", throwable)
store.update {
it.copy(transactionState = ManageDonationsState.TransactionState.NetworkFailure)
}
}
)

View file

@ -71,6 +71,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
val type: String = when (record.type) {
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
DonationReceiptRecord.Type.GIFT -> getString(R.string.DonationReceiptListFragment__gift)
}
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
@ -142,6 +143,7 @@ class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.do
when (record.type) {
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
DonationReceiptRecord.Type.GIFT -> getString(R.string.DonationReceiptListFragment__gift)
}
)
)

View file

@ -44,6 +44,7 @@ object DonationReceiptListItem {
when (model.record.type) {
DonationReceiptRecord.Type.RECURRING -> R.string.DonationReceiptListFragment__recurring
DonationReceiptRecord.Type.BOOST -> R.string.DonationReceiptListFragment__one_time
DonationReceiptRecord.Type.GIFT -> R.string.DonationReceiptListFragment__gift
}
)
moneyView.text = FiatMoneyUtil.format(context.resources, model.record.amount)

View file

@ -120,7 +120,7 @@ class SubscribeFragment : DSLSettingsFragment(
override fun onDestroyView() {
super.onDestroyView()
processingDonationPaymentDialog.hide()
processingDonationPaymentDialog.dismiss()
}
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
@ -133,7 +133,7 @@ class SubscribeFragment : DSLSettingsFragment(
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
return configure {
customPref(BadgePreview.SubscriptionModel(state.selectedSubscription?.badge))
customPref(BadgePreview.BadgeModel.SubscriptionModel(state.selectedSubscription?.badge))
sectionHeaderPref(
title = DSLSettingsText.from(

View file

@ -95,11 +95,12 @@ class DSLConfiguration {
title: DSLSettingsText,
summary: DSLSettingsText? = null,
icon: DSLSettingsIcon? = null,
iconEnd: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
onClick: () -> Unit,
onLongClick: (() -> Boolean)? = null
) {
val preference = ClickPreference(title, summary, icon, isEnabled, onClick, onLongClick)
val preference = ClickPreference(title, summary, icon, iconEnd, isEnabled, onClick, onLongClick)
children.add(preference)
}
@ -182,6 +183,7 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
open val title: DSLSettingsText? = null,
open val summary: DSLSettingsText? = null,
open val icon: DSLSettingsIcon? = null,
open val iconEnd: DSLSettingsIcon? = null,
open val isEnabled: Boolean = true,
) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean {
@ -197,7 +199,8 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
return areItemsTheSame(newItem) &&
newItem.summary == summary &&
newItem.icon == icon &&
newItem.isEnabled == isEnabled
newItem.isEnabled == isEnabled &&
newItem.iconEnd == iconEnd
}
}
@ -269,6 +272,7 @@ class ClickPreference(
override val title: DSLSettingsText,
override val summary: DSLSettingsText? = null,
override val icon: DSLSettingsIcon? = null,
override val iconEnd: DSLSettingsIcon? = null,
override val isEnabled: Boolean = true,
val onClick: () -> Unit,
val onLongClick: (() -> Boolean)? = null

View file

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

View file

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

View file

@ -205,7 +205,7 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
ThreadDatabase threadDatabase = SignalDatabase.threads();
MatrixCursor recentConversations = ContactsCursorRows.createMatrixCursor(RECENT_CONVERSATION_MAX);
try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), groupsOnly, hideGroupsV1(mode), !smsEnabled(mode))) {
try (Cursor rawConversations = threadDatabase.getRecentConversationList(RECENT_CONVERSATION_MAX, flagSet(mode, DisplayMode.FLAG_INACTIVE_GROUPS), false, groupsOnly, hideGroupsV1(mode), !smsEnabled(mode))) {
ThreadDatabase.Reader reader = threadDatabase.readerFor(rawConversations);
ThreadRecord threadRecord;
while ((threadRecord = reader.getNext()) != null) {

View file

@ -30,13 +30,19 @@ class ContactSearchConfiguration private constructor(
*/
data class Recents(
val limit: Int = 25,
val groupsOnly: Boolean = false,
val mode: Mode = Mode.ALL,
val includeInactiveGroups: Boolean = false,
val includeGroupsV1: Boolean = false,
val includeSms: Boolean = false,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.RECENTS)
) : Section(SectionKey.RECENTS) {
enum class Mode {
INDIVIDUALS,
GROUPS,
ALL
}
}
/**
* 1:1 Recipients

View file

@ -21,17 +21,18 @@ import org.thoughtcrime.securesms.util.visible
object ContactSearchItems {
fun register(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean,
recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (ContactSearchData.Story, Boolean) -> Unit,
expandListener: (ContactSearchData.Expand) -> Unit
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, storyListener) }, R.layout.contact_search_item)
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener) }, R.layout.contact_search_item)
)
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({ KnownRecipientViewHolder(it, recipientListener) }, R.layout.contact_search_item)
LayoutFactory({ KnownRecipientViewHolder(it, displayCheckBox, recipientListener) }, R.layout.contact_search_item)
)
mappingAdapter.registerFactory(
HeaderModel::class.java,
@ -78,7 +79,7 @@ object ContactSearchItems {
}
}
private class StoryViewHolder(itemView: View, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, onClick) {
private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, displayCheckBox, onClick) {
override fun isSelected(model: StoryModel): Boolean = model.isSelected
override fun getData(model: StoryModel): ContactSearchData.Story = model.story
override fun getRecipient(model: StoryModel): Recipient = model.story.recipient
@ -118,7 +119,7 @@ object ContactSearchItems {
}
}
private class KnownRecipientViewHolder(itemView: View, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, onClick) {
private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayCheckBox, onClick) {
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
@ -127,7 +128,7 @@ object ContactSearchItems {
/**
* Base Recipient View Holder
*/
private abstract class BaseRecipientViewHolder<T, D : ContactSearchData>(itemView: View, val onClick: (D, Boolean) -> Unit) : MappingViewHolder<T>(itemView) {
private abstract class BaseRecipientViewHolder<T, D : ContactSearchData>(itemView: View, private val displayCheckBox: Boolean, val onClick: (D, Boolean) -> Unit) : MappingViewHolder<T>(itemView) {
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
@ -138,6 +139,7 @@ object ContactSearchItems {
protected val smsTag: View = itemView.findViewById(R.id.sms_tag)
override fun bind(model: T) {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
itemView.setOnClickListener { onClick(getData(model), isSelected(model)) }

View file

@ -64,7 +64,7 @@ sealed class ContactSearchKey {
@Parcelize
data class ParcelableRecipientSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
fun asContactSearchKey(): ContactSearchKey {
fun asRecipientSearchKey(): RecipientSearchKey {
return when (type) {
ParcelableType.STORY -> RecipientSearchKey.Story(recipientId)
ParcelableType.KNOWN_RECIPIENT -> RecipientSearchKey.KnownRecipient(recipientId)

View file

@ -12,6 +12,7 @@ class ContactSearchMediator(
fragment: Fragment,
recyclerView: RecyclerView,
selectionLimits: SelectionLimits,
displayCheckBox: Boolean,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
) {
@ -23,6 +24,7 @@ class ContactSearchMediator(
ContactSearchItems.register(
mappingAdapter = adapter,
displayCheckBox = displayCheckBox,
recipientListener = this::toggleSelection,
storyListener = this::toggleSelection,
expandListener = { viewModel.expandSection(it.sectionKey) }

View file

@ -42,7 +42,8 @@ open class ContactSearchPagedDataSourceRepository(
return SignalDatabase.threads.getRecentConversationList(
section.limit,
section.includeInactiveGroups,
section.groupsOnly,
section.mode == ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS,
section.mode == ContactSearchConfiguration.Section.Recents.Mode.GROUPS,
!section.includeGroupsV1,
!section.includeSms
)

View file

@ -77,6 +77,9 @@ import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration;
import org.thoughtcrime.securesms.badges.gifts.viewgift.received.ViewReceivedGiftBottomSheet;
import org.thoughtcrime.securesms.badges.gifts.viewgift.sent.ViewSentGiftBottomSheet;
import org.thoughtcrime.securesms.components.ConversationScrollToView;
import org.thoughtcrime.securesms.components.ConversationTypingView;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
@ -308,6 +311,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
RecyclerViewColorizer recyclerViewColorizer = new RecyclerViewColorizer(list);
OpenableGiftItemDecoration openableGiftItemDecoration = new OpenableGiftItemDecoration(requireContext());
getViewLifecycleOwner().getLifecycle().addObserver(openableGiftItemDecoration);
list.addItemDecoration(openableGiftItemDecoration);
list.addItemDecoration(multiselectItemDecoration);
list.setItemAnimator(conversationItemAnimator);
@ -413,6 +420,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
return view;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
getChildFragmentManager().setFragmentResultListener(ViewReceivedGiftBottomSheet.REQUEST_KEY, getViewLifecycleOwner(), (key, bundle) -> {
if (bundle.getBoolean(ViewReceivedGiftBottomSheet.RESULT_NOT_NOW, false)) {
Snackbar.make(view.getRootView(), R.string.ConversationFragment__you_can_redeem_your_badge_later, Snackbar.LENGTH_SHORT)
.setTextColor(Color.WHITE)
.show();
}
});
}
private @NonNull GiphyMp4ProjectionRecycler initializeGiphyMp4() {
int maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation();
List<GiphyMp4ProjectionPlayerHolder> holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(),
@ -1950,6 +1968,19 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
RecipientBottomSheetDialogFragment.create(target, recipient.get().getGroupId().orElse(null)).show(getParentFragmentManager(), "BOTTOM");
}
@Override
public void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord) {
if (!MessageRecordUtil.hasGiftBadge(messageRecord)) {
return;
}
if (messageRecord.isOutgoing()) {
ViewSentGiftBottomSheet.show(getChildFragmentManager(), (MmsMessageRecord) messageRecord);
} else {
ViewReceivedGiftBottomSheet.show(getChildFragmentManager(), (MmsMessageRecord) messageRecord);
}
}
}
public void refreshList() {

View file

@ -7,6 +7,7 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.mediasend.Media;
@ -33,6 +34,7 @@ public class ConversationIntents {
private static final String EXTRA_STARTING_POSITION = "starting_position";
private static final String EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP = "first_time_in_group";
private static final String EXTRA_WITH_SEARCH_OPEN = "with_search_open";
private static final String EXTRA_GIFT_BADGE = "gift_badge";
private ConversationIntents() {
}
@ -72,6 +74,7 @@ public class ConversationIntents {
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
private final boolean withSearchOpen;
private final Badge giftBadge;
static Args from(@NonNull Intent intent) {
if (isBubbleIntent(intent)) {
@ -84,7 +87,8 @@ public class ConversationIntents {
ThreadDatabase.DistributionTypes.DEFAULT,
-1,
false,
false);
false,
null);
}
return new Args(RecipientId.from(Objects.requireNonNull(intent.getStringExtra(EXTRA_RECIPIENT))),
@ -96,7 +100,8 @@ public class ConversationIntents {
intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, ThreadDatabase.DistributionTypes.DEFAULT),
intent.getIntExtra(EXTRA_STARTING_POSITION, -1),
intent.getBooleanExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
intent.getBooleanExtra(EXTRA_WITH_SEARCH_OPEN, false));
intent.getBooleanExtra(EXTRA_WITH_SEARCH_OPEN, false),
intent.getParcelableExtra(EXTRA_GIFT_BADGE));
}
private Args(@NonNull RecipientId recipientId,
@ -108,7 +113,8 @@ public class ConversationIntents {
int distributionType,
int startingPosition,
boolean firstTimeInSelfCreatedGroup,
boolean withSearchOpen)
boolean withSearchOpen,
@Nullable Badge giftBadge)
{
this.recipientId = recipientId;
this.threadId = threadId;
@ -120,6 +126,7 @@ public class ConversationIntents {
this.startingPosition = startingPosition;
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
this.withSearchOpen = withSearchOpen;
this.giftBadge = giftBadge;
}
public @NonNull RecipientId getRecipientId() {
@ -169,6 +176,10 @@ public class ConversationIntents {
public boolean isWithSearchOpen() {
return withSearchOpen;
}
public @Nullable Badge getGiftBadge() {
return giftBadge;
}
}
public final static class Builder {
@ -187,6 +198,7 @@ public class ConversationIntents {
private String dataType;
private boolean firstTimeInSelfCreatedGroup;
private boolean withSearchOpen;
private Badge giftBadge;
private Builder(@NonNull Context context,
@NonNull RecipientId recipientId,
@ -255,7 +267,12 @@ public class ConversationIntents {
this.firstTimeInSelfCreatedGroup = true;
return this;
}
public Builder withGiftBadge(@NonNull Badge badge) {
this.giftBadge = badge;
return this;
}
public @NonNull Intent build() {
if (stickerLocator != null && media != null) {
throw new IllegalStateException("Cannot have both sticker and media array");
@ -281,6 +298,7 @@ public class ConversationIntents {
intent.putExtra(EXTRA_BORDERLESS, isBorderless);
intent.putExtra(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, firstTimeInSelfCreatedGroup);
intent.putExtra(EXTRA_WITH_SEARCH_OPEN, withSearchOpen);
intent.putExtra(EXTRA_GIFT_BADGE, giftBadge);
if (draftText != null) {
intent.putExtra(EXTRA_TEXT, draftText);

View file

@ -71,6 +71,8 @@ import org.thoughtcrime.securesms.MediaPreviewActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.badges.gifts.GiftMessageView;
import org.thoughtcrime.securesms.badges.gifts.OpenableGift;
import org.thoughtcrime.securesms.components.AlertView;
import org.thoughtcrime.securesms.components.AudioView;
import org.thoughtcrime.securesms.components.AvatarImageView;
@ -147,6 +149,9 @@ import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import kotlin.Unit;
import kotlin.jvm.functions.Function1;
/**
* A view that displays an individual conversation item within a conversation
* thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter.
@ -156,7 +161,8 @@ import java.util.concurrent.TimeUnit;
*/
public final class ConversationItem extends RelativeLayout implements BindableConversationItem,
RecipientForeverObserver
RecipientForeverObserver,
OpenableGift
{
private static final String TAG = Log.tag(ConversationItem.class);
@ -207,6 +213,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private Stub<BorderlessImageView> stickerStub;
private Stub<ViewOnceMessageView> revealableStub;
private Stub<Button> callToActionStub;
private Stub<GiftMessageView> giftViewStub;
private @Nullable EventListener eventListener;
private int defaultBubbleColor;
@ -224,6 +231,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final UrlClickListener urlClickListener = new UrlClickListener();
private final Rect thumbnailMaskingRect = new Rect();
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
private final Context context;
@ -298,6 +306,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.badgeImageView = findViewById(R.id.badge);
this.storyReactionLabelWrapper = findViewById(R.id.story_reacted_label_holder);
this.storyReactionLabel = findViewById(R.id.story_reacted_label);
this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub));
setOnClickListener(new ClickListener(null));
@ -914,6 +923,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return MessageRecordUtil.isViewOnceMessage(messageRecord);
}
private boolean isGiftMessage(MessageRecord messageRecord) {
return MessageRecordUtil.hasGiftBadge(messageRecord);
}
private void setBodyText(@NonNull MessageRecord messageRecord,
@Nullable String searchQuery,
boolean messageRequestAccepted)
@ -935,7 +948,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyText.setText(italics);
bodyText.setVisibility(View.VISIBLE);
bodyText.setOverflowText(null);
} else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord)) {
} else if (isCaptionlessMms(messageRecord) || isStoryReaction(messageRecord) || isGiftMessage(messageRecord)) {
bodyText.setVisibility(View.GONE);
} else {
Spannable styledText = conversationMessage.getDisplayBody(getContext());
@ -1004,6 +1017,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
revealableStub.get().setMessage((MmsMessageRecord) messageRecord, hasWallpaper);
revealableStub.get().setOnClickListener(revealableClickListener);
@ -1020,6 +1034,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
sharedContactStub.get().setEventListener(sharedContactEventListener);
@ -1039,6 +1054,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
//noinspection ConstantConditions
LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
@ -1083,6 +1099,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
audioViewStub.get().setDownloadClickListener(singleDownloadClickListener);
@ -1108,6 +1125,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
//noinspection ConstantConditions
documentViewStub.get().setDocument(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(), showControls);
@ -1130,6 +1148,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (hasSticker(messageRecord)) {
//noinspection ConstantConditions
@ -1159,6 +1178,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(isCaptionlessMms(messageRecord) ? R.dimen.media_bubble_min_width_solo
@ -1202,6 +1222,20 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
canPlayContent = (GiphyMp4PlaybackPolicy.autoplay() || allowedToPlayInline) && mediaItem != null;
}
} else if (isGiftMessage(messageRecord)) {
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(GONE);
if (documentViewStub.resolved()) documentViewStub.get().setVisibility(GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().setVisibility(GONE);
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(GONE);
MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
giftViewStub.get().setGiftBadge(glideRequests, Objects.requireNonNull(mmsMessageRecord.getGiftBadge()), messageRecord.isOutgoing(), giftMessageViewCallback);
giftViewStub.get().setVisibility(VISIBLE);
footer.setVisibility(VISIBLE);
} else {
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().setVisibility(View.GONE);
if (audioViewStub.resolved()) audioViewStub.get().setVisibility(View.GONE);
@ -1210,6 +1244,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
@ -1699,7 +1734,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
background = R.drawable.message_bubble_background_received_alone;
outliner.setRadius(bigRadius);
pulseOutliner.setRadius(bigRadius);
bodyBubbleCorners = null;
bodyBubbleCorners = new Projection.Corners(bigRadius);
}
} else if (isStartOfMessageCluster(current, previous, isGroupThread)) {
if (current.isOutgoing()) {
@ -1711,7 +1746,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
background = R.drawable.message_bubble_background_received_start;
setOutlinerRadii(outliner, bigRadius, bigRadius, bigRadius, smallRadius);
setOutlinerRadii(pulseOutliner, bigRadius, bigRadius, bigRadius, smallRadius);
bodyBubbleCorners = null;
bodyBubbleCorners = getBodyBubbleCorners(bigRadius, bigRadius, bigRadius, smallRadius);
}
} else if (isEndOfMessageCluster(current, next, isGroupThread)) {
if (current.isOutgoing()) {
@ -1723,7 +1758,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
background = R.drawable.message_bubble_background_received_end;
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, bigRadius);
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, bigRadius);
bodyBubbleCorners = null;
bodyBubbleCorners = getBodyBubbleCorners(smallRadius, bigRadius, bigRadius, bigRadius);
}
} else {
if (current.isOutgoing()) {
@ -1735,7 +1770,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
background = R.drawable.message_bubble_background_received_middle;
setOutlinerRadii(outliner, smallRadius, bigRadius, bigRadius, smallRadius);
setOutlinerRadii(pulseOutliner, smallRadius, bigRadius, bigRadius, smallRadius);
bodyBubbleCorners = null;
bodyBubbleCorners = getBodyBubbleCorners(smallRadius, bigRadius, bigRadius, smallRadius);
}
}
@ -2004,6 +2039,41 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
@Override
public @Nullable Projection getOpenableGiftProjection() {
boolean isViewedAndIncoming = !messageRecord.isOutgoing() && messageRecord.getViewedReceiptCount() > 0;
if (!isGiftMessage(messageRecord) || messageRecord.isRemoteDelete() || isViewedAndIncoming) {
return null;
}
return Projection.relativeToViewRoot(bodyBubble, bodyBubbleCorners)
.translateX(bodyBubble.getTranslationX())
.translateX(getTranslationX())
.scale(bodyBubble.getScaleX());
}
@Override
public long getGiftId() {
return messageRecord.getId();
}
@Override
public void setOpenGiftCallback(@NonNull Function1<? super OpenableGift, Unit> openGift) {
if (giftViewStub.resolved()) {
bodyBubble.setOnClickListener(unused -> openGift.invoke(this));
giftViewStub.get().onGiftNotOpened();
}
}
@Override
public void clearOpenGiftCallback() {
if (giftViewStub.resolved()) {
bodyBubble.setOnClickListener(null);
bodyBubble.setClickable(false);
giftViewStub.get().onGiftOpened();
}
}
private class SharedContactEventListener implements SharedContactView.EventListener {
@Override
public void onAddToContactsClicked(@NonNull Contact contact) {
@ -2179,6 +2249,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private class GiftMessageViewCallback implements GiftMessageView.Callback {
@Override
public void onViewGiftBadgeClicked() {
eventListener.onViewGiftBadgeClicked(messageRecord);
}
}
private class ClickListener implements View.OnClickListener {
private OnClickListener parent;

View file

@ -114,6 +114,7 @@ import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
import org.thoughtcrime.securesms.audio.AudioRecorder;
import org.thoughtcrime.securesms.badges.gifts.thanks.GiftThanksSheet;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.components.ComposeText;
import org.thoughtcrime.securesms.components.ConversationSearchBottomBar;
@ -485,6 +486,9 @@ public class ConversationParentFragment extends Fragment
// TODO [alex] LargeScreenSupport -- This will need to be built from requireArguments()
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
if (savedInstanceState == null && args.getGiftBadge() != null) {
GiftThanksSheet.show(getChildFragmentManager(), args.getRecipientId(), args.getGiftBadge());
}
isSearchRequested = args.isWithSearchOpen();
@ -2979,7 +2983,7 @@ public class ConversationParentFragment extends Fragment
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null);
List<Mention> mentions = new ArrayList<>(result.getMentions());
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, result.getStoryType(), null, false, quote, Collections.emptyList(), Collections.emptyList(), mentions);
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, result.getStoryType(), null, false, quote, Collections.emptyList(), Collections.emptyList(), mentions, null);
OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message);
final Context context = requireContext().getApplicationContext();
@ -3055,7 +3059,7 @@ public class ConversationParentFragment extends Fragment
}
}
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, StoryType.NONE, null, false, quote, contacts, previews, mentions);
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, StoryType.NONE, null, false, quote, contacts, previews, mentions, null);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();

View file

@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import java.util.Set;
import java.util.stream.Collectors;
@ -82,6 +83,7 @@ final class MenuState {
boolean hasInMemory = false;
boolean hasPendingMedia = false;
boolean mediaIsSelected = false;
boolean hasGift = false;
for (MultiselectPart part : selectedParts) {
MessageRecord messageRecord = part.getMessageRecord();
@ -115,6 +117,10 @@ final class MenuState {
if (messageRecord.isRemoteDelete()) {
remoteDelete = true;
}
if (MessageRecordUtil.hasGiftBadge(messageRecord)) {
hasGift = true;
}
}
boolean shouldShowForwardAction = !actionMessage &&
@ -122,6 +128,7 @@ final class MenuState {
!viewOnce &&
!remoteDelete &&
!hasPendingMedia &&
!hasGift &&
selectedParts.size() <= MAX_FORWARDABLE_COUNT;
int uniqueRecords = selectedParts.stream()
@ -144,6 +151,7 @@ final class MenuState {
!viewOnce &&
messageRecord.isMms() &&
!hasPendingMedia &&
!hasGift &&
!messageRecord.isMmsNotification() &&
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
@ -152,7 +160,7 @@ final class MenuState {
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText)
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift)
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
.build();
@ -180,6 +188,7 @@ final class MenuState {
messageRecord.isSecure() &&
(!conversationRecipient.isGroup() || conversationRecipient.isActiveGroup()) &&
!messageRecord.getRecipient().isBlocked() &&
!MessageRecordUtil.hasGiftBadge(messageRecord) &&
!conversationRecipient.isReleaseNotes();
}

View file

@ -107,7 +107,7 @@ class MultiselectForwardFragment :
view.minimumHeight = resources.displayMetrics.heightPixels
val contactSearchRecycler: RecyclerView = view.findViewById(R.id.contact_selection_list)
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), this::getConfiguration)
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), !isSingleRecipientSelection(), this::getConfiguration)
callback = findListener()!!
disposables.bindTo(viewLifecycleOwner.lifecycle)
@ -155,24 +155,11 @@ class MultiselectForwardFragment :
}
sendButton.setOnClickListener {
sendButton.isEnabled = false
StoryDialogs.guardWithAddToYourStoryDialog(
requireContext(),
contactSearchMediator.getSelectedContacts(),
onAddToStory = {
performSend()
},
onEditViewers = {
sendButton.isEnabled = true
HideStoryFromDialogFragment().show(childFragmentManager, null)
},
onCancel = {
sendButton.isEnabled = true
}
)
onSend(it)
}
sendButton.visible = !isSingleRecipientSelection()
shareSelectionRecycler.adapter = shareSelectionAdapter
bottomBar.visible = false
@ -180,6 +167,11 @@ class MultiselectForwardFragment :
container.addView(bottomBarAndSpacer)
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { contactSelection ->
if (contactSelection.isNotEmpty() && isSingleRecipientSelection()) {
onSend(sendButton)
return@observe
}
shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
addMessage.visible = !forceDisableAddMessage && contactSelection.any { key -> key !is ContactSearchKey.RecipientSearchKey.Story } && getMultiShareArgs().isNotEmpty()
@ -276,6 +268,25 @@ class MultiselectForwardFragment :
.show()
}
private fun onSend(sendButton: View) {
sendButton.isEnabled = false
StoryDialogs.guardWithAddToYourStoryDialog(
requireContext(),
contactSearchMediator.getSelectedContacts(),
onAddToStory = {
performSend()
},
onEditViewers = {
sendButton.isEnabled = true
HideStoryFromDialogFragment().show(childFragmentManager, null)
},
onCancel = {
sendButton.isEnabled = true
}
)
}
private fun performSend() {
viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
@ -390,6 +401,10 @@ class MultiselectForwardFragment :
return Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)
}
private fun isSingleRecipientSelection(): Boolean {
return requireArguments().getBoolean(ARG_SELECT_SINGLE_RECIPIENT, false)
}
private fun isSelectedMediaValidForStories(): Boolean {
return requireListener<Callback>().canSendMediaToStories() && getMultiShareArgs().all { it.isValidForStories }
}
@ -422,6 +437,7 @@ class MultiselectForwardFragment :
const val ARG_TITLE = "multiselect.forward.fragment.title"
const val ARG_FORCE_DISABLE_ADD_MESSAGE = "multiselect.forward.fragment.force.disable.add.message"
const val ARG_FORCE_SELECTION_ONLY = "multiselect.forward.fragment.force.disable.add.message"
const val ARG_SELECT_SINGLE_RECIPIENT = "multiselect.forward.framgent.select.single.recipient"
const val RESULT_KEY = "result_key"
const val RESULT_SELECTION = "result_selection_recipients"
const val RESULT_SENT = "result_sent"
@ -460,6 +476,7 @@ class MultiselectForwardFragment :
putInt(ARG_TITLE, multiselectForwardFragmentArgs.title)
putBoolean(ARG_FORCE_DISABLE_ADD_MESSAGE, multiselectForwardFragmentArgs.forceDisableAddMessage)
putBoolean(ARG_FORCE_SELECTION_ONLY, multiselectForwardFragmentArgs.forceSelectionOnly)
putBoolean(ARG_SELECT_SINGLE_RECIPIENT, multiselectForwardFragmentArgs.selectSingleRecipient)
}
}
}

View file

@ -28,13 +28,15 @@ import java.util.function.Consumer
* @param title The title to display at the top of the sheet
* @param forceDisableAddMessage Hide the add message field even if it would normally be available.
* @param forceSelectionOnly Force the fragment to only select recipients, never actually performing the send.
* @param selectSingleRecipient Only allow the selection of a single recipient.
*/
class MultiselectForwardFragmentArgs @JvmOverloads constructor(
val canSendToNonPush: Boolean,
val multiShareArgs: List<MultiShareArgs> = listOf(),
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to,
val forceDisableAddMessage: Boolean = false,
val forceSelectionOnly: Boolean = false
val forceSelectionOnly: Boolean = false,
val selectSingleRecipient: Boolean = false
) {
companion object {

View file

@ -121,6 +121,9 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
public abstract void markSmsStatus(long id, int status);
public abstract void markDownloadState(long messageId, long state);
public abstract void markIncomingNotificationReceived(long threadId);
public abstract void markGiftRedemptionCompleted(long messageId);
public abstract void markGiftRedemptionStarted(long messageId);
public abstract void markGiftRedemptionFailed(long messageId);
public abstract Set<MessageUpdate> incrementReceiptCount(SyncMessageId messageId, long timestamp, @NonNull ReceiptType receiptType, boolean storiesOnly);
abstract @NonNull MmsSmsDatabase.TimestampReadResult setTimestampRead(SyncMessageId messageId, long proposedExpireStarted, @NonNull Map<Long, Long> threadToLatestRead);

View file

@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.database.model.StoryResult;
import org.thoughtcrime.securesms.database.model.StoryType;
import org.thoughtcrime.securesms.database.model.StoryViewState;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
@ -83,6 +84,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
import org.thoughtcrime.securesms.sms.IncomingTextMessage;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@ -1578,6 +1580,11 @@ public class MmsDatabase extends MessageDatabase {
return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn);
}
GiftBadge giftBadge = null;
if (body != null && Types.isGiftBadge(outboxType)) {
giftBadge = GiftBadge.parseFrom(Base64.decode(body));
}
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
body,
attachments,
@ -1594,7 +1601,8 @@ public class MmsDatabase extends MessageDatabase {
previews,
mentions,
networkFailures,
mismatches);
mismatches,
giftBadge);
if (Types.isSecureType(outboxType)) {
return new OutgoingSecureMediaMessage(message);
@ -1791,10 +1799,20 @@ public class MmsDatabase extends MessageDatabase {
type |= Types.EXPIRATION_TIMER_UPDATE_BIT;
}
boolean hasSpecialType = false;
if (retrieved.isStoryReaction()) {
hasSpecialType = true;
type |= Types.SPECIAL_TYPE_STORY_REACTION;
}
if (retrieved.getGiftBadge() != null) {
if (hasSpecialType) {
throw new MmsException("Cannot insert message with multiple special types.");
}
type |= Types.SPECIAL_TYPE_GIFT_BADGE;
}
return insertMessageInbox(retrieved, "", threadId, type);
}
@ -1858,6 +1876,55 @@ public class MmsDatabase extends MessageDatabase {
TrimThreadJob.enqueueAsync(threadId);
}
@Override
public void markGiftRedemptionCompleted(long messageId) {
markGiftRedemptionState(messageId, GiftBadge.RedemptionState.REDEEMED);
}
@Override
public void markGiftRedemptionStarted(long messageId) {
markGiftRedemptionState(messageId, GiftBadge.RedemptionState.STARTED);
}
@Override
public void markGiftRedemptionFailed(long messageId) {
markGiftRedemptionState(messageId, GiftBadge.RedemptionState.FAILED);
}
private void markGiftRedemptionState(long messageId, @NonNull GiftBadge.RedemptionState redemptionState) {
String[] projection = SqlUtil.buildArgs(BODY, THREAD_ID);
String where = "(" + MESSAGE_BOX + " & " + Types.SPECIAL_TYPES_MASK + " = " + Types.SPECIAL_TYPE_GIFT_BADGE + ") AND " +
ID + " = ?";
String[] args = SqlUtil.buildArgs(messageId);
boolean updated = false;
long threadId = -1;
getWritableDatabase().beginTransaction();
try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, projection, where, args, null, null, null)) {
if (cursor.moveToFirst()) {
GiftBadge giftBadge = GiftBadge.parseFrom(Base64.decode(CursorUtil.requireString(cursor, BODY)));
GiftBadge updatedBadge = giftBadge.toBuilder().setRedemptionState(redemptionState).build();
ContentValues contentValues = new ContentValues(1);
contentValues.put(BODY, Base64.encodeBytes(updatedBadge.toByteArray()));
updated = getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, args) > 0;
threadId = CursorUtil.requireLong(cursor, THREAD_ID);
getWritableDatabase().setTransactionSuccessful();
}
} catch (IOException e) {
Log.w(TAG, "Failed to mark gift badge " + redemptionState.name(), e, true);
} finally {
getWritableDatabase().endTransaction();
}
if (updated) {
ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId, true));
notifyConversationListeners(threadId);
}
}
@Override
public long insertMessageOutbox(@NonNull OutgoingMediaMessage message,
long threadId,
@ -1899,10 +1966,20 @@ public class MmsDatabase extends MessageDatabase {
type |= Types.EXPIRATION_TIMER_UPDATE_BIT;
}
boolean hasSpecialType = false;
if (message.isStoryReaction()) {
hasSpecialType = true;
type |= Types.SPECIAL_TYPE_STORY_REACTION;
}
if (message.getGiftBadge() != null) {
if (hasSpecialType) {
throw new MmsException("Cannot insert message with multiple special types.");
}
type |= Types.SPECIAL_TYPE_GIFT_BADGE;
}
Map<RecipientId, EarlyReceiptCache.Receipt> earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.getSentTimeMillis());
ContentValues contentValues = new ContentValues();
@ -2421,7 +2498,8 @@ public class MmsDatabase extends MessageDatabase {
-1,
null,
message.getStoryType(),
message.getParentStoryId());
message.getParentStoryId(),
message.getGiftBadge());
}
}
@ -2476,6 +2554,7 @@ public class MmsDatabase extends MessageDatabase {
long receiptTimestamp = CursorUtil.requireLong(cursor, MmsSmsColumns.RECEIPT_TIMESTAMP);
StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE));
ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID));
String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY));
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
@ -2492,13 +2571,21 @@ public class MmsDatabase extends MessageDatabase {
SlideDeck slideDeck = new SlideDeck(context, new MmsNotificationAttachment(status, messageSize));
GiftBadge giftBadge = null;
if (body != null && Types.isGiftBadge(mailbox)) {
try {
giftBadge = GiftBadge.parseFrom(Base64.decode(body));
} catch (IOException e) {
Log.w(TAG, "Error parsing gift badge", e);
}
}
return new NotificationMmsMessageRecord(id, recipient, recipient,
addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId,
contentLocationBytes, messageSize, expiry, status,
transactionIdBytes, mailbox, subscriptionId, slideDeck,
readReceiptCount, viewedReceiptCount, receiptTimestamp, storyType,
parentStoryId);
parentStoryId, giftBadge);
}
private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) {
@ -2558,13 +2645,22 @@ public class MmsDatabase extends MessageDatabase {
Log.w(TAG, "Error parsing message ranges", e);
}
GiftBadge giftBadge = null;
if (body != null && Types.isGiftBadge(box)) {
try {
giftBadge = GiftBadge.parseFrom(Base64.decode(body));
} catch (IOException e) {
Log.w(TAG, "Error parsing gift badge", e);
}
}
return new MediaMmsMessageRecord(id, recipient, recipient,
addressDeviceId, dateSent, dateReceived, dateServer, deliveryReceiptCount,
threadId, body, slideDeck, partCount, box, mismatches,
networkFailures, subscriptionId, expiresIn, expireStarted,
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(),
remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges,
storyType, parentStoryId);
storyType, parentStoryId, giftBadge);
}
private Set<IdentityKeyMismatch> getMismatchedIdentities(String document) {

View file

@ -138,13 +138,14 @@ public interface MmsSmsColumns {
// Special message types
public static final long SPECIAL_TYPES_MASK = 0xF00000000L;
public static final long SPECIAL_TYPE_STORY_REACTION = 0x100000000L;
public static boolean isSpecialType(long type) {
return (type & SPECIAL_TYPES_MASK) != 0L;
}
public static final long SPECIAL_TYPE_GIFT_BADGE = 0x200000000L;
public static boolean isStoryReaction(long type) {
return (type & SPECIAL_TYPE_STORY_REACTION) == SPECIAL_TYPE_STORY_REACTION;
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_STORY_REACTION;
}
public static boolean isGiftBadge(long type) {
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_GIFT_BADGE;
}
public static boolean isDraftMessageType(long type) {

View file

@ -1491,6 +1491,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
value = Bitmask.update(value, Capabilities.ANNOUNCEMENT_GROUPS, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isAnnouncementGroup).serialize().toLong())
value = Bitmask.update(value, Capabilities.CHANGE_NUMBER, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isChangeNumber).serialize().toLong())
value = Bitmask.update(value, Capabilities.STORIES, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isStories).serialize().toLong())
value = Bitmask.update(value, Capabilities.GIFT_BADGES, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isGiftBadges).serialize().toLong())
val values = ContentValues(1).apply {
put(CAPABILITIES, value)
@ -3086,6 +3087,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
announcementGroupCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.ANNOUNCEMENT_GROUPS, Capabilities.BIT_LENGTH).toInt()),
changeNumberCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.CHANGE_NUMBER, Capabilities.BIT_LENGTH).toInt()),
storiesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.STORIES, Capabilities.BIT_LENGTH).toInt()),
giftBadgesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.GIFT_BADGES, Capabilities.BIT_LENGTH).toInt()),
insightsBannerTier = InsightsBannerTier.fromId(cursor.requireInt(SEEN_INVITE_REMINDER)),
storageId = Base64.decodeNullableOrThrow(cursor.requireString(STORAGE_SERVICE_ID)),
mentionSetting = MentionSetting.fromId(cursor.requireInt(MENTION_SETTING)),
@ -3403,6 +3405,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) :
const val ANNOUNCEMENT_GROUPS = 3
const val CHANGE_NUMBER = 4
const val STORIES = 5
const val GIFT_BADGES = 6
}
enum class VibrateState(val id: Int) {

View file

@ -1696,6 +1696,21 @@ public class SmsDatabase extends MessageDatabase {
throw new UnsupportedOperationException();
}
@Override
public void markGiftRedemptionCompleted(long messageId) {
throw new UnsupportedOperationException();
}
@Override
public void markGiftRedemptionStarted(long messageId) {
throw new UnsupportedOperationException();
}
@Override
public void markGiftRedemptionFailed(long messageId) {
throw new UnsupportedOperationException();
}
@Override
public MessageDatabase.Reader getMessages(Collection<Long> messageIds) {
throw new UnsupportedOperationException();

View file

@ -573,10 +573,10 @@ public class ThreadDatabase extends Database {
}
public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean hideV1Groups) {
return getRecentConversationList(limit, includeInactiveGroups, false, hideV1Groups, false);
return getRecentConversationList(limit, includeInactiveGroups, false, false, hideV1Groups, false);
}
public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean groupsOnly, boolean hideV1Groups, boolean hideSms) {
public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean individualsOnly, boolean groupsOnly, boolean hideV1Groups, boolean hideSms) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
String query = !includeInactiveGroups ? MEANINGFUL_MESSAGES + " != 0 AND (" + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " IS NULL OR " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1)"
: MEANINGFUL_MESSAGES + " != 0";
@ -585,6 +585,10 @@ public class ThreadDatabase extends Database {
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " NOT NULL";
}
if (individualsOnly) {
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " IS NULL";
}
if (hideV1Groups) {
query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_TYPE + " != " + RecipientDatabase.GroupType.SIGNAL_V1.getId();
}

View file

@ -13,7 +13,8 @@ data class DonationReceiptRecord(
) {
enum class Type(val code: String) {
RECURRING("recurring"),
BOOST("boost");
BOOST("boost"),
GIFT("gift");
companion object {
fun fromCode(code: String): Type {
@ -46,5 +47,15 @@ data class DonationReceiptRecord(
type = Type.BOOST
)
}
fun createForGift(amount: FiatMoney): DonationReceiptRecord {
return DonationReceiptRecord(
id = -1L,
amount = amount,
timestamp = System.currentTimeMillis(),
subscriptionLevel = -1,
type = Type.GIFT
)
}
}
}

View file

@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.database.SmsDatabase.Status;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -93,13 +94,14 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
long receiptTimestamp,
@Nullable BodyRangeList messageRanges,
@NonNull StoryType storyType,
@Nullable ParentStoryId parentStoryId)
@Nullable ParentStoryId parentStoryId,
@Nullable GiftBadge giftBadge)
{
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp,
storyType, parentStoryId);
storyType, parentStoryId, giftBadge);
this.partCount = partCount;
this.mentionsSelf = mentionsSelf;
this.messageRanges = messageRanges;
@ -153,7 +155,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge());
}
public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List<DatabaseAttachment> attachments) {
@ -174,7 +176,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), slideDeck,
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge());
}
private static @NonNull List<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {

View file

@ -7,6 +7,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
@ -24,6 +25,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
private final @NonNull List<LinkPreview> linkPreviews = new LinkedList<>();
private final @NonNull StoryType storyType;
private final @Nullable ParentStoryId parentStoryId;
private final @Nullable GiftBadge giftBadge;
private final boolean viewOnce;
@ -38,7 +40,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
@NonNull List<ReactionRecord> reactions, boolean remoteDelete, long notifiedTimestamp,
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
@Nullable ParentStoryId parentStoryId)
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge)
{
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId,
dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount,
@ -50,6 +52,7 @@ public abstract class MmsMessageRecord extends MessageRecord {
this.viewOnce = viewOnce;
this.storyType = storyType;
this.parentStoryId = parentStoryId;
this.giftBadge = giftBadge;
this.contacts.addAll(contacts);
this.linkPreviews.addAll(linkPreviews);
@ -104,4 +107,8 @@ public abstract class MmsMessageRecord extends MessageRecord {
public @NonNull List<LinkPreview> getLinkPreviews() {
return linkPreviews;
}
public @Nullable GiftBadge getGiftBadge() {
return giftBadge;
}
}

View file

@ -25,6 +25,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -54,13 +55,13 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
long expiry, int status, byte[] transactionId, long mailbox,
int subscriptionId, SlideDeck slideDeck, int readReceiptCount,
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
@Nullable ParentStoryId parentStoryId)
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge)
{
super(id, "", conversationRecipient, individualRecipient, recipientDeviceId,
dateSent, dateReceived, -1, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
new HashSet<>(), new HashSet<>(), subscriptionId,
0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false,
Collections.emptyList(), false, 0, viewedReceiptCount, receiptTimestamp, storyType, parentStoryId);
Collections.emptyList(), false, 0, viewedReceiptCount, receiptTimestamp, storyType, parentStoryId, giftBadge);
this.contentLocation = contentLocation;
this.messageSize = messageSize;

View file

@ -70,6 +70,7 @@ data class RecipientRecord(
val announcementGroupCapability: Recipient.Capability,
val changeNumberCapability: Recipient.Capability,
val storiesCapability: Recipient.Capability,
val giftBadgesCapability: Recipient.Capability,
val insightsBannerTier: InsightsBannerTier,
val storageId: ByteArray?,
val mentionSetting: MentionSetting,

View file

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

View file

@ -23,6 +23,8 @@ import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
@ -39,29 +41,38 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
public static final String KEY = "BoostReceiptCredentialsSubmissionJob";
private static final String BOOST_QUEUE = "BoostReceiptRedemption";
private static final String GIFT_QUEUE = "GiftReceiptRedemption";
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
private static final String DATA_PAYMENT_INTENT_ID = "data.payment.intent.id";
private static final String DATA_ERROR_SOURCE = "data.error.source";
private static final String DATA_BADGE_LEVEL = "data.badge.level";
private ReceiptCredentialRequestContext requestContext;
private final String paymentIntentId;
private final DonationErrorSource donationErrorSource;
private final String paymentIntentId;
private final long badgeLevel;
static BoostReceiptRequestResponseJob createJob(StripeApi.PaymentIntent paymentIntent) {
private static BoostReceiptRequestResponseJob createJob(StripeApi.PaymentIntent paymentIntent, DonationErrorSource donationErrorSource, long badgeLevel) {
return new BoostReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("BoostReceiptRedemption")
.setQueue(donationErrorSource == DonationErrorSource.BOOST ? BOOST_QUEUE : GIFT_QUEUE)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
null,
paymentIntent.getId()
paymentIntent.getId(),
donationErrorSource,
badgeLevel
);
}
public static JobManager.Chain createJobChain(StripeApi.PaymentIntent paymentIntent) {
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent);
public static JobManager.Chain createJobChainForBoost(@NonNull StripeApi.PaymentIntent paymentIntent) {
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
@ -71,18 +82,38 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
.then(refreshOwnProfileJob);
}
public static JobManager.Chain createJobChainForGift(@NonNull StripeApi.PaymentIntent paymentIntent,
@NonNull RecipientId recipientId,
@Nullable String additionalMessage,
long badgeLevel)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.GIFT, badgeLevel);
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
return ApplicationDependencies.getJobManager()
.startChain(requestReceiptJob)
.then(giftSendJob);
}
private BoostReceiptRequestResponseJob(@NonNull Parameters parameters,
@Nullable ReceiptCredentialRequestContext requestContext,
@NonNull String paymentIntentId)
@NonNull String paymentIntentId,
@NonNull DonationErrorSource donationErrorSource,
long badgeLevel)
{
super(parameters);
this.requestContext = requestContext;
this.paymentIntentId = paymentIntentId;
this.requestContext = requestContext;
this.paymentIntentId = paymentIntentId;
this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel;
}
@Override
public @NonNull Data serialize() {
Data.Builder builder = new Data.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId);
Data.Builder builder = new Data.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId)
.putString(DATA_ERROR_SOURCE, donationErrorSource.serialize())
.putLong(DATA_BADGE_LEVEL, badgeLevel);
if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
@ -124,16 +155,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
.blockingGet();
if (response.getApplicationError().isPresent()) {
handleApplicationError(context, response);
handleApplicationError(context, response, donationErrorSource);
} else if (response.getResult().isPresent()) {
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
if (!isCredentialValid(receiptCredential)) {
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST));
DonationError.routeDonationError(context, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
throw new IOException("Could not validate receipt credential");
}
Log.d(TAG, "Validated credential. Handing off to redemption job.", true);
Log.d(TAG, "Validated credential. Handing off to next job.", true);
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
setOutputData(new Data.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
receiptCredentialPresentation.serialize())
@ -144,7 +175,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
}
}
private static void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response) throws Exception {
private static void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
Throwable applicationException = response.getApplicationError().get();
switch (response.getStatus()) {
case 204:
@ -152,15 +183,15 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
throw new RetryableException();
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST));
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
throw new Exception(applicationException);
case 402:
Log.w(TAG, "User payment failed.", applicationException, true);
DonationError.routeDonationError(context, DonationError.genericPaymentFailure(DonationErrorSource.BOOST));
DonationError.routeDonationError(context, DonationError.genericPaymentFailure(donationErrorSource));
throw new Exception(applicationException);
case 409:
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST));
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
throw new Exception(applicationException);
default:
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true);
@ -197,17 +228,17 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
* - expiration time should have the following characteristics:
* - expiration_time mod 86400 == 0
* - expiration_time is between now and 60 days from now
* - expiration_time is between now and 90 days from now
*/
private boolean isCredentialValid(@NonNull ReceiptCredential receiptCredential) {
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(60);
boolean isCorrectLevel = receiptCredential.getReceiptLevel() == 1;
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(90);
boolean isCorrectLevel = receiptCredential.getReceiptLevel() == badgeLevel;
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
boolean isExpirationWithinMax = receiptCredential.getReceiptExpirationTime() <= maxExpirationTime;
Log.d(TAG, "Credential validation: isCorrectLevel(" + isCorrectLevel +
Log.d(TAG, "Credential validation: isCorrectLevel(" + isCorrectLevel + " actual: " + receiptCredential.getReceiptLevel() + ", expected: " + badgeLevel +
") isExpiration86400(" + isExpiration86400 +
") isExpirationInTheFuture(" + isExpirationInTheFuture +
") isExpirationWithinMax(" + isExpirationWithinMax + ")", true);
@ -226,16 +257,18 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
public static class Factory implements Job.Factory<BoostReceiptRequestResponseJob> {
@Override
public @NonNull BoostReceiptRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) {
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.BOOST.serialize()));
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
try {
if (data.hasString(DATA_REQUEST_BYTES)) {
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel);
} else {
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId);
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel);
}
} catch (InvalidInputException e) {
throw new IllegalStateException(e);

View file

@ -68,7 +68,7 @@ public class ConversationShortcutUpdateJob extends BaseJob {
int maxShortcuts = ConversationUtil.getMaxShortcuts(context);
List<Recipient> ranked = new ArrayList<>(maxShortcuts);
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentConversationList(maxShortcuts, false, false, true, !Util.isDefaultSmsProvider(context)))) {
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentConversationList(maxShortcuts, false, false, false, true, !Util.isDefaultSmsProvider(context)))) {
ThreadRecord record;
while ((record = reader.getNext()) != null) {
ranked.add(record.getRecipient().resolve());

View file

@ -1,16 +1,25 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
@ -23,17 +32,24 @@ import java.util.concurrent.TimeUnit;
* presentation object via setOutputData. This is expected to be the byte[] blob of a ReceiptCredentialPresentation object.
*/
public class DonationReceiptRedemptionJob extends BaseJob {
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
private static final long NO_ID = -1L;
public static final String SUBSCRIPTION_QUEUE = "ReceiptRedemption";
public static final String KEY = "DonationReceiptRedemptionJob";
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
public static final String DATA_ERROR_SOURCE = "data.error.source";
public static final String DATA_GIFT_MESSAGE_ID = "data.gift.message.id";
public static final String DATA_PRIMARY = "data.primary";
private final long giftMessageId;
private final boolean makePrimary;
private final DonationErrorSource errorSource;
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource) {
return new DonationReceiptRedemptionJob(
NO_ID,
false,
errorSource,
new Job.Parameters
.Builder()
@ -47,6 +63,8 @@ public class DonationReceiptRedemptionJob extends BaseJob {
public static DonationReceiptRedemptionJob createJobForBoost() {
return new DonationReceiptRedemptionJob(
NO_ID,
false,
DonationErrorSource.BOOST,
new Job.Parameters
.Builder()
@ -57,14 +75,40 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.build());
}
private DonationReceiptRedemptionJob(@NonNull DonationErrorSource errorSource, @NonNull Job.Parameters parameters) {
public static JobManager.Chain createJobChainForGift(long messageId, boolean primary) {
DonationReceiptRedemptionJob redeemReceiptJob = new DonationReceiptRedemptionJob(
messageId,
primary,
DonationErrorSource.GIFT_REDEMPTION,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("GiftReceiptRedemption-" + messageId)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.build());
RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob();
return ApplicationDependencies.getJobManager()
.startChain(redeemReceiptJob)
.then(refreshOwnProfileJob);
}
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, @NonNull Job.Parameters parameters) {
super(parameters);
this.errorSource = errorSource;
this.giftMessageId = giftMessageId;
this.makePrimary = primary;
this.errorSource = errorSource;
}
@Override
public @NonNull Data serialize() {
return new Data.Builder().putString(DATA_ERROR_SOURCE, errorSource.serialize()).build();
return new Data.Builder()
.putString(DATA_ERROR_SOURCE, errorSource.serialize())
.putLong(DATA_GIFT_MESSAGE_ID, giftMessageId)
.putBoolean(DATA_PRIMARY, makePrimary)
.build();
}
@Override
@ -78,31 +122,31 @@ public class DonationReceiptRedemptionJob extends BaseJob {
Log.d(TAG, "Marking subscription failure", true);
SignalStore.donationsValues().markSubscriptionRedemptionFailed();
MultiDeviceSubscriptionSyncRequestJob.enqueue();
} else if (giftMessageId != NO_ID) {
SignalDatabase.mms().markGiftRedemptionFailed(giftMessageId);
}
}
@Override
public void onAdded() {
if (giftMessageId != NO_ID) {
SignalDatabase.mms().markGiftRedemptionStarted(giftMessageId);
}
}
@Override
protected void onRun() throws Exception {
Data inputData = getInputData();
if (inputData == null) {
Log.w(TAG, "No input data. Exiting.", null, true);
ReceiptCredentialPresentation presentation = getPresentation();
if (presentation == null) {
Log.d(TAG, "No presentation available. Exiting.", true);
return;
}
byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION);
if (presentationBytes == null) {
Log.d(TAG, "No response data. Exiting.", null, true);
return;
}
ReceiptCredentialPresentation presentation = new ReceiptCredentialPresentation(presentationBytes);
Log.d(TAG, "Attempting to redeem token... isForSubscription: " + isForSubscription(), true);
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
.redeemReceipt(presentation,
SignalStore.donationsValues().getDisplayBadgesOnProfile(),
false)
makePrimary)
.blockingGet();
if (response.getApplicationError().isPresent()) {
@ -124,6 +168,61 @@ public class DonationReceiptRedemptionJob extends BaseJob {
if (isForSubscription()) {
Log.d(TAG, "Clearing subscription failure", true);
SignalStore.donationsValues().clearSubscriptionRedemptionFailed();
} else if (giftMessageId != NO_ID) {
Log.d(TAG, "Marking gift redemption completed for " + giftMessageId);
SignalDatabase.mms().markGiftRedemptionCompleted(giftMessageId);
MessageDatabase.MarkedMessageInfo markedMessageInfo = SignalDatabase.mms().setIncomingMessageViewed(giftMessageId);
if (markedMessageInfo != null) {
Log.d(TAG, "Marked gift message viewed for " + giftMessageId);
ApplicationDependencies.getJobManager()
.add(new SendViewedReceiptJob(markedMessageInfo.getThreadId(),
markedMessageInfo.getSyncMessageId().getRecipientId(),
markedMessageInfo.getSyncMessageId().getTimetamp(),
markedMessageInfo.getMessageId()));
}
}
}
private @Nullable ReceiptCredentialPresentation getPresentation() throws InvalidInputException, NoSuchMessageException {
if (giftMessageId == NO_ID) {
return getPresentationFromInputData();
} else {
return getPresentationFromGiftMessage();
}
}
private @Nullable ReceiptCredentialPresentation getPresentationFromInputData() throws InvalidInputException {
Data inputData = getInputData();
if (inputData == null) {
Log.w(TAG, "No input data. Exiting.", true);
return null;
}
byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION);
if (presentationBytes == null) {
Log.d(TAG, "No response data. Exiting.", true);
return null;
}
return new ReceiptCredentialPresentation(presentationBytes);
}
private @Nullable ReceiptCredentialPresentation getPresentationFromGiftMessage() throws InvalidInputException, NoSuchMessageException {
MessageRecord messageRecord = SignalDatabase.mms().getMessageRecord(giftMessageId);
if (MessageRecordUtil.hasGiftBadge(messageRecord)) {
GiftBadge giftBadge = MessageRecordUtil.requireGiftBadge(messageRecord);
if (giftBadge.getRedemptionState() == GiftBadge.RedemptionState.REDEEMED) {
Log.d(TAG, "Already redeemed this gift badge. Exiting.", true);
return null;
} else {
Log.d(TAG, "Attempting redemption of badge in state " + giftBadge.getRedemptionState().name());
return new ReceiptCredentialPresentation(giftBadge.getRedemptionToken().toByteArray());
}
} else {
Log.d(TAG, "No gift badge on message record. Exiting.", true);
return null;
}
}
@ -143,9 +242,11 @@ public class DonationReceiptRedemptionJob extends BaseJob {
@Override
public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @NonNull Data data) {
String serializedErrorSource = data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.UNKNOWN.serialize());
long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID);
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
return new DonationReceiptRedemptionJob(errorSource, parameters);
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, parameters);
}
}
}

View file

@ -0,0 +1,113 @@
package org.thoughtcrime.securesms.jobs
import com.google.protobuf.ByteString
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.gifts.Gifts
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.jobmanager.Data
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.sms.MessageSender
import java.util.concurrent.TimeUnit
/**
* Sends a message to the given recipient containing a redeemable badge token.
* This job assumes that the client has already determined whether the given recipient can receive a gift badge.
*/
class GiftSendJob private constructor(parameters: Parameters, private val recipientId: RecipientId, private val additionalMessage: String?) : Job(parameters) {
companion object {
private val TAG = Log.tag(GiftSendJob::class.java)
const val KEY = "SendGiftJob"
const val DATA_RECIPIENT_ID = "data.recipient.id"
const val DATA_ADDITIONAL_MESSAGE = "data.additional.message"
}
constructor(recipientId: RecipientId, additionalMessage: String?) :
this(
parameters = Parameters.Builder()
.build(),
recipientId = recipientId,
additionalMessage = additionalMessage
)
override fun serialize(): Data = Data.Builder()
.putLong(DATA_RECIPIENT_ID, recipientId.toLong())
.putString(DATA_ADDITIONAL_MESSAGE, additionalMessage)
.build()
override fun getFactoryKey(): String = KEY
override fun run(): Result {
Log.i(TAG, "Getting data and generating message for gift send to $recipientId")
val token = this.inputData?.getStringAsBlob(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION) ?: return Result.failure()
val recipient = Recipient.resolved(recipientId)
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
Log.w(TAG, "Invalid recipient $recipientId for gift send.")
return Result.failure()
}
val thread = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val outgoingMessage = Gifts.createOutgoingGiftMessage(
recipient = recipient,
expiresIn = TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()),
sentTimestamp = System.currentTimeMillis(),
giftBadge = GiftBadge.newBuilder().setRedemptionToken(ByteString.copyFrom(token)).build()
)
Log.i(TAG, "Sending gift badge to $recipientId...")
var didInsert = false
MessageSender.send(context, outgoingMessage, thread, false, null) {
didInsert = true
}
return if (didInsert) {
Log.i(TAG, "Successfully inserted outbox message for gift", true)
if (additionalMessage != null) {
Log.i(TAG, "Sending additional message...")
val result = MultiShareSender.sendSync(
MultiShareArgs.Builder(setOf(ContactSearchKey.RecipientSearchKey.KnownRecipient(recipientId)))
.withDraftText(additionalMessage)
.build()
)
if (result.containsFailures()) {
Log.w(TAG, "Failed to send additional message, but gift sent fine.", true)
}
Result.success()
} else {
Result.success()
}
} else {
Log.w(TAG, "Failed to insert outbox message for gift", true)
Result.failure()
}
}
override fun onFailure() {
Log.w(TAG, "Failed to submit send of gift badge to $recipientId")
}
class Factory : Job.Factory<GiftSendJob> {
override fun create(parameters: Parameters, data: Data): GiftSendJob {
val recipientId = RecipientId.from(data.getLong(DATA_RECIPIENT_ID))
val additionalMessage = data.getStringOrDefault(DATA_ADDITIONAL_MESSAGE, null)
return GiftSendJob(parameters, recipientId, additionalMessage)
}
}
}

View file

@ -94,6 +94,7 @@ public final class JobManagerFactories {
put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory());
put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory());
put(FontDownloaderJob.KEY, new FontDownloaderJob.Factory());
put(GiftSendJob.KEY, new GiftSendJob.Factory());
put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory());
put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory());
put(GroupCallPeekJob.KEY, new GroupCallPeekJob.Factory());

View file

@ -119,6 +119,10 @@ public final class PushGroupSendJob extends PushSendJob {
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
if (message.getGiftBadge() != null) {
throw new MmsException("Cannot send a gift badge to a group!");
}
if (!SignalDatabase.groups().isActive(group.requireGroupId()) && !isGv2UpdateMessage(message)) {
throw new MmsException("Inactive group!");
}

View file

@ -211,6 +211,7 @@ public class PushMediaSendJob extends PushSendJob {
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<SignalServicePreview> previews = getPreviewsFor(message);
SignalServiceDataMessage.GiftBadge giftBadge = getGiftBadgeFor(message);
SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder()
.withBody(message.getBody())
.withAttachments(serviceAttachments)
@ -221,6 +222,7 @@ public class PushMediaSendJob extends PushSendJob {
.withSticker(sticker.orElse(null))
.withSharedContacts(sharedContacts)
.withPreviews(previews)
.withGiftBadge(giftBadge)
.asExpirationUpdate(message.isExpirationUpdate());
if (message.getParentStoryId() != null) {
@ -245,6 +247,10 @@ public class PushMediaSendJob extends PushSendJob {
mediaMessageBuilder.withQuote(getQuoteFor(message).orElse(null));
}
if (message.getGiftBadge() != null) {
mediaMessageBuilder.withBody(null);
}
SignalServiceDataMessage mediaMessage = mediaMessageBuilder.build();
if (Util.equals(SignalStore.account().getAci(), address.getServiceId())) {

View file

@ -16,6 +16,8 @@ import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
import org.signal.libsignal.metadata.certificate.SenderCertificate;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.thoughtcrime.securesms.TextSecureExpiredException;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
@ -26,6 +28,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobmanager.Job;
@ -43,6 +46,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
@ -419,6 +423,21 @@ public abstract class PushSendJob extends SendJob {
.toList();
}
@Nullable SignalServiceDataMessage.GiftBadge getGiftBadgeFor(@NonNull OutgoingMediaMessage message) throws UndeliverableMessageException {
GiftBadge giftBadge = message.getGiftBadge();
if (giftBadge == null) {
return null;
}
try {
ReceiptCredentialPresentation presentation = new ReceiptCredentialPresentation(giftBadge.getRedemptionToken().toByteArray());
return new SignalServiceDataMessage.GiftBadge(presentation);
} catch (InvalidInputException invalidInputException) {
throw new UndeliverableMessageException(invalidInputException);
}
}
protected void rotateSenderCertificateIfNecessary() throws IOException {
try {
Collection<CertificateType> requiredCertificateTypes = SignalStore.phoneNumberPrivacy()

View file

@ -111,6 +111,7 @@ public class RefreshAttributesJob extends BaseJob {
"\n Announcement Groups? " + capabilities.isAnnouncementGroup() +
"\n Change Number? " + capabilities.isChangeNumber() +
"\n Stories? " + capabilities.isStories() +
"\n Gift Badges? " + capabilities.isGiftBadges() +
"\n UUID? " + capabilities.isUuid());
SignalServiceAccountManager signalAccountManager = ApplicationDependencies.getSignalServiceAccountManager();

View file

@ -255,6 +255,8 @@ public class RefreshOwnProfileJob extends BaseJob {
boolean localHasSubscriptionBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isSubscription);
boolean remoteHasBoostBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost);
boolean localHasBoostBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost);
boolean remoteHasGiftBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isGift);
boolean localHasGiftBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isGift);
if (!remoteHasSubscriptionBadges && localHasSubscriptionBadges) {
Badge mostRecentExpiration = Recipient.self()
@ -307,6 +309,19 @@ public class RefreshOwnProfileJob extends BaseJob {
SignalStore.donationsValues().setExpiredBadge(mostRecentExpiration);
}
if (!remoteHasGiftBadges && localHasGiftBadges) {
Badge mostRecentExpiration = Recipient.self()
.getBadges()
.stream()
.filter(badge -> badge.getCategory() == Badge.Category.Donor)
.filter(badge -> isGift(badge.getId()))
.max(Comparator.comparingLong(Badge::getExpirationTimestamp))
.get();
Log.d(TAG, "Marking gift badge as expired, should notify next time the manage donations screen is open.", true);
SignalStore.donationsValues().setExpiredGiftBadge(mostRecentExpiration);
}
boolean userHasVisibleBadges = badges.stream().anyMatch(SignalServiceProfile.Badge::isVisible);
boolean userHasInvisibleBadges = badges.stream().anyMatch(b -> !b.isVisible());
@ -326,13 +341,17 @@ public class RefreshOwnProfileJob extends BaseJob {
}
private static boolean isSubscription(String badgeId) {
return !Objects.equals(badgeId, Badge.BOOST_BADGE_ID);
return !isBoost(badgeId) && !isGift(badgeId);
}
private static boolean isBoost(String badgeId) {
return Objects.equals(badgeId, Badge.BOOST_BADGE_ID);
}
private static boolean isGift(String badgeId) {
return Objects.equals(badgeId, Badge.GIFT_BADGE_ID);
}
public static final class Factory implements Job.Factory<RefreshOwnProfileJob> {
@Override

View file

@ -297,11 +297,11 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
* - expiration time should have the following characteristics:
* - expiration_time mod 86400 == 0
* - expiration_time is between now and 60 days from now
* - expiration_time is between now and 90 days from now
*/
private static boolean isCredentialValid(@NonNull ActiveSubscription.Subscription subscription, @NonNull ReceiptCredential receiptCredential) {
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(60);
long maxExpirationTime = now + TimeUnit.DAYS.toSeconds(90);
boolean isSameLevel = subscription.getLevel() == receiptCredential.getReceiptLevel();
boolean isExpirationAfterSub = subscription.getEndOfCurrentPeriod() < receiptCredential.getReceiptExpirationTime();
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;

View file

@ -25,11 +25,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
private val TAG = Log.tag(DonationsValues::class.java)
private const val KEY_SUBSCRIPTION_CURRENCY_CODE = "donation.currency.code"
private const val KEY_CURRENCY_CODE_BOOST = "donation.currency.code.boost"
private const val KEY_CURRENCY_CODE_ONE_TIME = "donation.currency.code.boost"
private const val KEY_SUBSCRIBER_ID_PREFIX = "donation.subscriber.id."
private const val KEY_LAST_KEEP_ALIVE_LAUNCH = "donation.last.successful.ping"
private const val KEY_LAST_END_OF_PERIOD_SECONDS = "donation.last.end.of.period"
private const val EXPIRED_BADGE = "donation.expired.badge"
private const val EXPIRED_GIFT_BADGE = "donation.expired.gift.badge"
private const val USER_MANUALLY_CANCELLED = "donation.user.manually.cancelled"
private const val KEY_LEVEL_OPERATION_PREFIX = "donation.level.operation."
private const val KEY_LEVEL_HISTORY = "donation.level.history"
@ -46,7 +47,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
override fun onFirstEverAppLaunch() = Unit
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(
KEY_CURRENCY_CODE_BOOST,
KEY_CURRENCY_CODE_ONE_TIME,
KEY_LAST_KEEP_ALIVE_LAUNCH,
KEY_LAST_END_OF_PERIOD_SECONDS,
SHOULD_CANCEL_SUBSCRIPTION_BEFORE_NEXT_SUBSCRIBE_ATTEMPT,
@ -59,8 +60,8 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
private val subscriptionCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) }
val observableSubscriptionCurrency: Observable<Currency> by lazy { subscriptionCurrencyPublisher }
private val boostCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getBoostCurrency()) }
val observableBoostCurrency: Observable<Currency> by lazy { boostCurrencyPublisher }
private val oneTimeCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getOneTimeCurrency()) }
val observableOneTimeCurrency: Observable<Currency> by lazy { oneTimeCurrencyPublisher }
fun getSubscriptionCurrency(): Currency {
val currencyCode = getString(KEY_SUBSCRIPTION_CURRENCY_CODE, null)
@ -87,20 +88,20 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
}
}
fun getBoostCurrency(): Currency {
val boostCurrencyCode = getString(KEY_CURRENCY_CODE_BOOST, null)
return if (boostCurrencyCode == null) {
fun getOneTimeCurrency(): Currency {
val oneTimeCurrency = getString(KEY_CURRENCY_CODE_ONE_TIME, null)
return if (oneTimeCurrency == null) {
val currency = getSubscriptionCurrency()
setBoostCurrency(currency)
setOneTimeCurrency(currency)
currency
} else {
Currency.getInstance(boostCurrencyCode)
Currency.getInstance(oneTimeCurrency)
}
}
fun setBoostCurrency(currency: Currency) {
putString(KEY_CURRENCY_CODE_BOOST, currency.currencyCode)
boostCurrencyPublisher.onNext(currency)
fun setOneTimeCurrency(currency: Currency) {
putString(KEY_CURRENCY_CODE_ONE_TIME, currency.currencyCode)
oneTimeCurrencyPublisher.onNext(currency)
}
fun getSubscriber(currency: Currency): Subscriber? {
@ -179,6 +180,20 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
return Badges.fromDatabaseBadge(BadgeList.Badge.parseFrom(badgeBytes))
}
fun setExpiredGiftBadge(badge: Badge?) {
if (badge != null) {
putBlob(EXPIRED_GIFT_BADGE, Badges.toDatabaseBadge(badge).toByteArray())
} else {
remove(EXPIRED_GIFT_BADGE)
}
}
fun getExpiredGiftBadge(): Badge? {
val badgeBytes = getBlob(EXPIRED_GIFT_BADGE, null) ?: return null
return Badges.fromDatabaseBadge(BadgeList.Badge.parseFrom(badgeBytes))
}
fun getLastKeepAliveLaunchTime(): Long {
return getLong(KEY_LAST_KEEP_ALIVE_LAUNCH, 0L)
}

View file

@ -42,7 +42,7 @@ sealed class MediaSelectionDestination {
companion object {
fun fromParcel(parcelables: List<ContactSearchKey.ParcelableRecipientSearchKey>): MultipleRecipients {
return MultipleRecipients(parcelables.map { it.asContactSearchKey() }.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java))
return MultipleRecipients(parcelables.map { it.asRecipientSearchKey() }.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java))
}
}

View file

@ -244,7 +244,8 @@ class MediaSelectionRepository(context: Context) {
emptyList(),
mentions,
mutableSetOf(),
mutableSetOf()
mutableSetOf(),
null
)
if (isStory && preUploadResults.size > 1) {

View file

@ -140,7 +140,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY) { _, bundle ->
val parcelizedKeys: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
val contactSearchKeys = parcelizedKeys.map { it.asContactSearchKey() }
val contactSearchKeys = parcelizedKeys.map { it.asRecipientSearchKey() }
performSend(contactSearchKeys)
}

View file

@ -64,7 +64,8 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
mediator = ContactSearchMediator(
this,
contactRecycler,
FeatureFlags.shareSelectionLimit()
FeatureFlags.shareSelectionLimit(),
true
) { state ->
ContactSearchConfiguration.build {
query = state.query

View file

@ -122,7 +122,7 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm
}
val contactsRecyclerView: RecyclerView = view.findViewById(R.id.contacts_container)
contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit()) { contactSearchState ->
contactSearchMediator = ContactSearchMediator(this, contactsRecyclerView, FeatureFlags.shareSelectionLimit(), true) { contactSearchState ->
ContactSearchConfiguration.build {
query = contactSearchState.query

View file

@ -75,7 +75,8 @@ class TextStoryPostSendRepository {
listOfNotNull(linkPreview),
emptyList(),
mutableSetOf(),
mutableSetOf()
mutableSetOf(),
null
)
messages.add(OutgoingSecureMediaMessage(message))

Some files were not shown because too many files have changed in this diff Show more