diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5a60e54188..54fbf94a9d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -417,6 +417,11 @@ android:windowSoftInputMode="stateAlwaysHidden"> + + ().onMakeAMonthlyDonation() + }, + onNotNow = { + dismissAllowingStateLoss() + } + ) + }.toMappingModelList() + ) + } + + interface Callback { + fun onMakeAMonthlyDonation() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/ExpiredGiftSheetConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/ExpiredGiftSheetConfiguration.kt new file mode 100644 index 0000000000..cfa5e23651 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/ExpiredGiftSheetConfiguration.kt @@ -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() + } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/GiftMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/GiftMessageView.kt new file mode 100644 index 0000000000..4177e264af --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/GiftMessageView.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/Gifts.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/Gifts.kt new file mode 100644 index 0000000000..938032d698 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/Gifts.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt new file mode 100644 index 0000000000..4ba6e129cf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGift.kt @@ -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() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt new file mode 100644 index 0000000000..ef4a408dc9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/OpenableGiftItemDecoration.kt @@ -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() + private val messageIdsOpenedThisSession = mutableSetOf() + private val animationState = mutableMapOf() + + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/Gift.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/Gift.kt new file mode 100644 index 0000000000..ec5a13e75d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/Gift.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowActivity.kt new file mode 100644 index 0000000000..163f413740 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowActivity.kt @@ -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 = 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() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt new file mode 100644 index 0000000000..e7de159e3b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt @@ -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() + + 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(R.id.google_pay_button) + googlePayButton.setOnGooglePayClickListener { + viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time)) + } + + val textInput = requireView().findViewById(R.id.text_input) + val emojiToggle = textInput.findViewById(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)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRecipientSelectionFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRecipientSelectionFragment.kt new file mode 100644 index 0000000000..0c8dfb1145 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRecipientSelectionFragment.kt @@ -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(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 = 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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt new file mode 100644 index 0000000000..e5a69dc109 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt @@ -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> { + return ApplicationDependencies.getDonationsService() + .getGiftBadges(Locale.getDefault()) + .flatMap(ServiceResponse>::flattenResult) + .map { gifts -> gifts.map { it.key to Badges.fromServiceBadge(it.value) } } + .map { it.first() } + .subscribeOn(Schedulers.io()) + } + + fun getGiftPricing(): Single> { + 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) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt new file mode 100644 index 0000000000..a2ad01f0a0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt @@ -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().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(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 + ) + ) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowState.kt new file mode 100644 index 0000000000..25ac3227af --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowState.kt @@ -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 = emptyMap(), + val stage: Stage = Stage.INIT, + val recipient: Recipient? = null, + val additionalMessage: CharSequence? = null +) { + enum class Stage { + INIT, + READY, + TOKEN_REQUEST, + PAYMENT_PIPELINE, + FAILURE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt new file mode 100644 index 0000000000..e09b744d07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt @@ -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 = PublishSubject.create() + private val networkDisposable: Disposable + + val state: Flowable = store.stateFlowable + val events: Observable = 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 { + 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? = 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 create(modelClass: Class): T { + return modelClass.cast( + GiftFlowViewModel( + repository, + donationPaymentRepository + ) + ) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftRowItem.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftRowItem.kt new file mode 100644 index 0000000000..efccff3f92 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftRowItem.kt @@ -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 { + 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(itemView) { + + private val badgeView = itemView.findViewById(R.id.badge) + private val titleView = itemView.findViewById(R.id.title) + private val checkView = itemView.findViewById(R.id.check) + private val taglineView = itemView.findViewById(R.id.tagline) + private val priceView = itemView.findViewById(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) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GooglePayButton.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GooglePayButton.kt new file mode 100644 index 0000000000..f796a252a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GooglePayButton.kt @@ -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() } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/thanks/GiftThanksSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/thanks/GiftThanksSheet.kt new file mode 100644 index 0000000000..d139f36052 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/thanks/GiftThanksSheet.kt @@ -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()) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/ViewGiftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/ViewGiftRepository.kt new file mode 100644 index 0000000000..e83286f74a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/ViewGiftRepository.kt @@ -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 { + 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 { + 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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftBottomSheet.kt new file mode 100644 index 0000000000..d8a7a639e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftBottomSheet.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftState.kt new file mode 100644 index 0000000000..b262de94d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftState.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftViewModel.kt new file mode 100644 index 0000000000..e9154e230d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftViewModel.kt @@ -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 = 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 create(modelClass: Class): T { + return modelClass.cast(ViewReceivedGiftViewModel(sentFrom, messageId, repository, badgeRepository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftBottomSheet.kt new file mode 100644 index 0000000000..95d333d32c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftBottomSheet.kt @@ -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 + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftState.kt new file mode 100644 index 0000000000..bae863437e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftState.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftViewModel.kt new file mode 100644 index 0000000000..fc4ff4f95d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/sent/ViewSentGiftViewModel.kt @@ -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 = 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 create(modelClass: Class): T { + return modelClass.cast(ViewSentGiftViewModel(sentFrom, giftBadge, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt index aa20dcbbe5..6ce81ca92b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeDisplay112.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeDisplay112.kt new file mode 100644 index 0000000000..adcc40b232 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeDisplay112.kt @@ -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 { + 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 { + 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(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(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)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeDisplay160.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeDisplay160.kt new file mode 100644 index 0000000000..a34a8f0a64 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgeDisplay160.kt @@ -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 { + 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(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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt index dd0ae03313..5a778ef11a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt @@ -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> : PreferenceModel() { + sealed class BadgeModel> : MappingModel { abstract val badge: Badge? - } + abstract val recipient: Recipient - data class Model(override val badge: Badge?) : BadgeModel() { - override fun areItemsTheSame(newItem: Model): Boolean { - return true + data class FeaturedModel(override val badge: Badge?) : BadgeModel() { + 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() { + 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() - data class SubscriptionModel(override val badge: Badge?) : BadgeModel() { - 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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt index 05b06dfaba..0cbe81064c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt @@ -58,7 +58,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment( } val previewView: View = requireView().findViewById(R.id.preview) - val previewViewHolder = BadgePreview.ViewHolder(previewView) + val previewViewHolder = BadgePreview.ViewHolder(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()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt index f99ecfc1c5..c6945f4658 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt index 989594e44e..d075ad256d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsAdapter.kt @@ -45,6 +45,7 @@ class DSLSettingsAdapter : MappingAdapter() { abstract class PreferenceViewHolder>(itemView: View) : MappingViewHolder(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>(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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index faf28a1e8a..3abdf6507c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt index 5ede59b97e..2f6f881ca5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index 388520e10b..949e2fb23f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -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.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 create(modelClass: Class): 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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt index 6330ca8246..f689affa19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt @@ -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 { + override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single { 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::flattenResult) .map { StripeApi.PaymentIntent(it.id, it.clientSecret) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt index 78159ddb81..26670bbaa5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt @@ -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::flattenResult) } else { Single.just(ActiveSubscription.EMPTY) @@ -30,6 +32,7 @@ class SubscriptionsRepository(private val donationsService: DonationsService) { } fun getSubscriptions(): Single> = donationsService.getSubscriptionLevels(Locale.getDefault()) + .subscribeOn(Schedulers.io()) .flatMap(ServiceResponse::flattenResult) .map { subscriptionLevels -> subscriptionLevels.levels.map { (code, level) -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt index 1b827045d1..ec573277f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostRepository.kt @@ -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>> { return donationsService.boostAmounts + .subscribeOn(Schedulers.io()) .flatMap(ServiceResponse>>::flattenResult) .map { result -> result @@ -27,6 +29,7 @@ class BoostRepository(private val donationsService: DonationsService) { fun getBoostBadge(): Single { return donationsService.getBoostBadge(Locale.getDefault()) + .subscribeOn(Schedulers.io()) .flatMap(ServiceResponse::flattenResult) .map(Badges::fromServiceBadge) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt index f89dc915f0..a37f96bab3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt @@ -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 = 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt index 596af14527..4706a860c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt @@ -13,14 +13,14 @@ import java.util.Currency import java.util.Locale class SetCurrencyViewModel( - private val isBoost: Boolean, + private val isOneTime: Boolean, supportedCurrencyCodes: List ) : 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) : ViewModelProvider.Factory { + class Factory(private val isOneTime: Boolean, private val supportedCurrencyCodes: List) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(SetCurrencyViewModel(isBoost, supportedCurrencyCodes))!! + return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!! } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt index 21a47b57b8..d5864c0216 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt index df8f9b26ea..5e197dea00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt @@ -23,6 +23,7 @@ class DonationErrorParams private constructor( callback: Callback ): DonationErrorParams { 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 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 private constructor( } } + private fun getGenericRedemptionError(context: Context, genericError: DonationError.BadgeRedemptionError.GenericError, callback: Callback): DonationErrorParams { + 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 getVerificationErrorParams(context: Context, verificationError: DonationError.GiftRecipientVerificationError, callback: Callback): DonationErrorParams { + 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 getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback): DonationErrorParams { return when (declinedError.declineCode) { is StripeDeclineCode.Known -> when (declinedError.declineCode.code) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorSource.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorSource.kt index 52a522914d..204b2e7b3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorSource.kt @@ -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"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt index 1d82185f64..5801273ce9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsEvent.kt deleted file mode 100644 index 57e0f5ab60..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsEvent.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.manage - -enum class ManageDonationsEvent { - NOT_SUBSCRIBED, - ERROR_GETTING_SUBSCRIPTION -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index 518fa83cdd..41f16bed26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -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()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt index 59bb9e7f3d..1f4747fc85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt index 80c67deb5c..a58d4aeeaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt @@ -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() private val disposables = CompositeDisposable() + private val networkDisposable: Disposable val state: LiveData = store.stateLiveData - val events: Observable = 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) + } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt index 9205b5c1d1..d7c654c0c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt @@ -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) } ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListItem.kt index ea702e991b..560098f6b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListItem.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt index 0506bd44c2..66cf769ae6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt index 730398881e..106288fa55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt @@ -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>( 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 { override fun areItemsTheSame(newItem: T): Boolean { @@ -197,7 +199,8 @@ abstract class PreferenceModel>( 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/OutlinedSwitch.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/OutlinedSwitch.kt new file mode 100644 index 0000000000..76345f24a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/OutlinedSwitch.kt @@ -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 { + 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(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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/TextInput.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/TextInput.kt new file mode 100644 index 0000000000..955a7ecf01 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/TextInput.kt @@ -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) { + 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 { + override fun areItemsTheSame(newItem: MultilineModel): Boolean = true + + override fun areContentsTheSame(newItem: MultilineModel): Boolean = text == newItem.text + } + + class MultilineViewHolder(itemView: View, private val events: Observable) : MappingViewHolder(itemView) { + + private val inputLayout: TextInputLayout = itemView.findViewById(R.id.input_layout) + private val input: EditText = itemView.findViewById(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) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java index 5573126ac5..9ed9777d60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index 677b867db2..25672590bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt index f842fe9c60..b81d28227a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt @@ -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(itemView, onClick) { + private class StoryViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder(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(itemView, onClick) { + private class KnownRecipientViewHolder(itemView: View, displayCheckBox: Boolean, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder(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(itemView: View, val onClick: (D, Boolean) -> Unit) : MappingViewHolder(itemView) { + private abstract class BaseRecipientViewHolder(itemView: View, private val displayCheckBox: Boolean, val onClick: (D, Boolean) -> Unit) : MappingViewHolder(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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt index 9ac474a215..abe3f36851 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt index 44f47ed9b2..1a5a8d244a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt index 6af85c9f79..3f47668e76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index e8f7c0055a..e58463b5e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -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 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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java index 47cf65a631..cf3a841f3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index ad72c822d7..7bfb604315 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -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 stickerStub; private Stub revealableStub; private Stub