mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-22 18:55:12 +00:00
Implement further features for badges.
* Add Subscriptions API * Add Accept-Language header to profile requests * Fix several UI bugs, add error dialogs, etc.
This commit is contained in:
committed by
Greyson Parrelli
parent
d88999d6d4
commit
c1820459b7
@@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.SubscriberIdKeepAliveListener;
|
||||
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
@@ -333,6 +334,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
LocalBackupListener.schedule(this);
|
||||
RotateSenderCertificateListener.schedule(this);
|
||||
MessageProcessReceiver.startOrUpdateAlarm(this);
|
||||
SubscriberIdKeepAliveListener.schedule(this);
|
||||
|
||||
if (BuildConfig.PLAY_STORE_DISABLED) {
|
||||
UpdateApkRefreshListener.schedule(this);
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.res.use
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
@@ -15,8 +14,6 @@ import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
private val TAG = Log.tag(BadgeImageView::class.java)
|
||||
|
||||
class BadgeImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
package org.thoughtcrime.securesms.badges
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.flexbox.AlignItems
|
||||
import com.google.android.flexbox.FlexDirection
|
||||
import com.google.android.flexbox.FlexboxLayoutManager
|
||||
import com.google.android.flexbox.JustifyContent
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.util.ScreenDensity
|
||||
import org.whispersystems.libsignal.util.Pair
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import java.math.BigDecimal
|
||||
import java.sql.Timestamp
|
||||
|
||||
object Badges {
|
||||
fun DSLConfiguration.displayBadges(context: Context, badges: List<Badge>, selectedBadge: Badge? = null) {
|
||||
fun DSLConfiguration.displayBadges(
|
||||
context: Context,
|
||||
badges: List<Badge>,
|
||||
selectedBadge: Badge? = null,
|
||||
fadedBadgeId: String? = null
|
||||
) {
|
||||
badges
|
||||
.map { Badge.Model(it, it == selectedBadge) }
|
||||
.map {
|
||||
Badge.Model(
|
||||
badge = it,
|
||||
isSelected = it == selectedBadge,
|
||||
isFaded = it.id == fadedBadgeId
|
||||
)
|
||||
}
|
||||
.forEach { customPref(it) }
|
||||
|
||||
val perRow = context.resources.getInteger(R.integer.badge_columns)
|
||||
@@ -32,4 +51,41 @@ object Badges {
|
||||
|
||||
return layoutManager
|
||||
}
|
||||
|
||||
private fun getBadgeImageUri(densityPath: String): Uri {
|
||||
return Uri.parse(BuildConfig.BADGE_STATIC_ROOT).buildUpon()
|
||||
.appendPath(densityPath)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getBestBadgeImageUriForDevice(serviceBadge: SignalServiceProfile.Badge): Pair<Uri, String> {
|
||||
val bestDensity = ScreenDensity.getBestDensityBucketForDevice()
|
||||
return when (bestDensity) {
|
||||
"ldpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[0]), "ldpi")
|
||||
"mdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[1]), "mdpi")
|
||||
"hdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[2]), "hdpi")
|
||||
"xxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[4]), "xxhdpi")
|
||||
"xxxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[5]), "xxxhdpi")
|
||||
else -> Pair(getBadgeImageUri(serviceBadge.sprites6[3]), "xdpi")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTimestamp(bigDecimal: BigDecimal): Long {
|
||||
return Timestamp(bigDecimal.toLong() * 1000).time
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun fromServiceBadge(serviceBadge: SignalServiceProfile.Badge): Badge {
|
||||
val uriAndDensity: Pair<Uri, String> = getBestBadgeImageUriForDevice(serviceBadge)
|
||||
return Badge(
|
||||
serviceBadge.id,
|
||||
fromCode(serviceBadge.category),
|
||||
serviceBadge.name,
|
||||
serviceBadge.description,
|
||||
uriAndDensity.first(),
|
||||
uriAndDensity.second(),
|
||||
serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
|
||||
serviceBadge.isVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ package org.thoughtcrime.securesms.badges.glide
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
@@ -19,7 +17,7 @@ class BadgeSpriteTransformation(
|
||||
) : BitmapTransformation() {
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update("BadgeSpriteTransformation(${size.code},$density,$isDarkTheme)".toByteArray(CHARSET))
|
||||
messageDigest.update("BadgeSpriteTransformation(${size.code},$density,$isDarkTheme).$VERSION".toByteArray(CHARSET))
|
||||
}
|
||||
|
||||
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
|
||||
@@ -33,11 +31,51 @@ class BadgeSpriteTransformation(
|
||||
return outBitmap
|
||||
}
|
||||
|
||||
enum class Size(val code: String) {
|
||||
SMALL("small"),
|
||||
MEDIUM("medium"),
|
||||
LARGE("large"),
|
||||
XLARGE("xlarge");
|
||||
enum class Size(val code: String, val frameMap: Map<Density, FrameSet>) {
|
||||
SMALL(
|
||||
"small",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(124, 1, 13, 13), Frame(145, 31, 13, 13)),
|
||||
Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
|
||||
Density.HDPI to FrameSet(Frame(244, 1, 25, 25), Frame(283, 58, 25, 25)),
|
||||
Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
|
||||
Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
|
||||
Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
|
||||
)
|
||||
),
|
||||
MEDIUM(
|
||||
"medium",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(124, 16, 19, 19), Frame(160, 31, 19, 19)),
|
||||
Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
|
||||
Density.HDPI to FrameSet(Frame(244, 28, 37, 37), Frame(310, 58, 37, 37)),
|
||||
Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
|
||||
Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
|
||||
Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
|
||||
)
|
||||
),
|
||||
LARGE(
|
||||
"large",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(145, 1, 28, 28), Frame(124, 46, 28, 28)),
|
||||
Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
|
||||
Density.HDPI to FrameSet(Frame(283, 1, 55, 55), Frame(244, 85, 55, 55)),
|
||||
Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
|
||||
Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
|
||||
Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
|
||||
)
|
||||
),
|
||||
XLARGE(
|
||||
"xlarge",
|
||||
mapOf(
|
||||
Density.LDPI to FrameSet(Frame(1, 1, 121, 121), Frame(1, 1, 121, 121)),
|
||||
Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
|
||||
Density.HDPI to FrameSet(Frame(1, 1, 241, 241), Frame(1, 1, 241, 241)),
|
||||
Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
|
||||
Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
|
||||
Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
|
||||
)
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun fromInteger(integer: Int): Size {
|
||||
@@ -52,55 +90,42 @@ class BadgeSpriteTransformation(
|
||||
}
|
||||
}
|
||||
|
||||
enum class Density(val density: String) {
|
||||
LDPI("ldpi"),
|
||||
MDPI("mdpi"),
|
||||
HDPI("hdpi"),
|
||||
XHDPI("xhdpi"),
|
||||
XXHDPI("xxhdpi"),
|
||||
XXXHDPI("xxxhdpi")
|
||||
}
|
||||
|
||||
data class FrameSet(val light: Frame, val dark: Frame)
|
||||
|
||||
data class Frame(
|
||||
val x: Int,
|
||||
val y: Int,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
) {
|
||||
fun toBounds(): Rect {
|
||||
return Rect(x, y, x + width, y + height)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PADDING = 1
|
||||
private const val VERSION = 1
|
||||
|
||||
@VisibleForTesting
|
||||
fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
|
||||
val scaleFactor: Int = when (density) {
|
||||
"ldpi" -> 75
|
||||
"mdpi" -> 100
|
||||
"hdpi" -> 150
|
||||
"xhdpi" -> 200
|
||||
"xxhdpi" -> 300
|
||||
"xxxhdpi" -> 400
|
||||
else -> throw IllegalArgumentException("Unexpected density $density")
|
||||
}
|
||||
private fun getDensity(density: String): Density {
|
||||
return Density.values().first { it.density == density }
|
||||
}
|
||||
|
||||
val smallLength = 8 * scaleFactor / 100
|
||||
val mediumLength = 12 * scaleFactor / 100
|
||||
val largeLength = 18 * scaleFactor / 100
|
||||
val xlargeLength = 80 * scaleFactor / 100
|
||||
private fun getFrame(size: Size, density: Density, isDarkTheme: Boolean): Frame {
|
||||
val frameSet: FrameSet = size.frameMap[density]!!
|
||||
return if (isDarkTheme) frameSet.dark else frameSet.light
|
||||
}
|
||||
|
||||
val sideLength: Int = when (size) {
|
||||
Size.SMALL -> smallLength
|
||||
Size.MEDIUM -> mediumLength
|
||||
Size.LARGE -> largeLength
|
||||
Size.XLARGE -> xlargeLength
|
||||
}
|
||||
|
||||
val lightOffset: Int = when (size) {
|
||||
Size.LARGE -> PADDING
|
||||
Size.MEDIUM -> (largeLength + PADDING * 2) * 2 + PADDING
|
||||
Size.SMALL -> (largeLength + PADDING * 2) * 2 + (mediumLength + PADDING * 2) * 2 + PADDING
|
||||
Size.XLARGE -> (largeLength + PADDING * 2) * 2 + (mediumLength + PADDING * 2) * 2 + (smallLength + PADDING * 2) * 2 + PADDING
|
||||
}
|
||||
|
||||
val darkOffset = if (isDarkTheme) {
|
||||
when (size) {
|
||||
Size.XLARGE -> 0
|
||||
else -> sideLength + PADDING * 2
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
return Rect(
|
||||
lightOffset + darkOffset,
|
||||
PADDING,
|
||||
lightOffset + darkOffset + sideLength,
|
||||
sideLength + PADDING
|
||||
)
|
||||
private fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
|
||||
return getFrame(size, getDensity(density), isDarkTheme).toBounds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import java.security.MessageDigest
|
||||
|
||||
typealias OnBadgeClicked = (Badge, Boolean) -> Unit
|
||||
typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
|
||||
|
||||
/**
|
||||
* A Badge that can be collected and displayed by a user.
|
||||
@@ -70,14 +70,18 @@ data class Badge(
|
||||
|
||||
class Model(
|
||||
val badge: Badge,
|
||||
val isSelected: Boolean = false
|
||||
val isSelected: Boolean = false,
|
||||
val isFaded: Boolean = false
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge.id == badge.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge && isSelected == newItem.isSelected
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
badge == newItem.badge &&
|
||||
isSelected == newItem.isSelected &&
|
||||
isFaded == newItem.isFaded
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: Model): Any? {
|
||||
@@ -103,7 +107,7 @@ data class Badge(
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener {
|
||||
onBadgeClicked(model.badge, model.isSelected)
|
||||
onBadgeClicked(model.badge, model.isSelected, model.isFaded)
|
||||
}
|
||||
|
||||
checkAnimator?.cancel()
|
||||
@@ -117,7 +121,7 @@ data class Badge(
|
||||
return
|
||||
}
|
||||
|
||||
badge.alpha = if (model.badge.isExpired()) 0.5f else 1f
|
||||
badge.alpha = if (model.badge.isExpired() || model.isFaded) 0.5f else 1f
|
||||
|
||||
GlideApp.with(badge)
|
||||
.load(model.badge)
|
||||
@@ -162,26 +166,4 @@ data class Badge(
|
||||
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ImageSet(
|
||||
val ldpi: String,
|
||||
val mdpi: String,
|
||||
val hdpi: String,
|
||||
val xhdpi: String,
|
||||
val xxhdpi: String,
|
||||
val xxxhdpi: String
|
||||
) : Parcelable {
|
||||
fun getByDensity(density: String): String {
|
||||
return when (density) {
|
||||
"ldpi" -> ldpi
|
||||
"mdpi" -> mdpi
|
||||
"hdpi" -> hdpi
|
||||
"xhdpi" -> xhdpi
|
||||
"xxhdpi" -> xxhdpi
|
||||
"xxxhdpi" -> xxxhdpi
|
||||
else -> xhdpi
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,22 +22,30 @@ object BadgePreview {
|
||||
|
||||
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.badge?.id == badge?.id
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: Model): Any? {
|
||||
return Unit
|
||||
}
|
||||
}
|
||||
|
||||
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
||||
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return newItem.badge?.id == badge?.id
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: SubscriptionModel): Any? {
|
||||
return Unit
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
@@ -46,8 +54,11 @@ object BadgePreview {
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
|
||||
override fun bind(model: T) {
|
||||
avatar.setRecipient(Recipient.self())
|
||||
avatar.disableQuickContact()
|
||||
if (payload.isEmpty()) {
|
||||
avatar.setRecipient(Recipient.self())
|
||||
avatar.disableQuickContact()
|
||||
}
|
||||
|
||||
badge.setBadge(model.badge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, isSelected ->
|
||||
Badge.register(adapter) { badge, isSelected, _ ->
|
||||
if (!isSelected) {
|
||||
viewModel.setSelectedBadge(badge)
|
||||
}
|
||||
@@ -69,8 +69,16 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
var hasBoundPreview = false
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
|
||||
|
||||
if (hasBoundPreview) {
|
||||
previewViewHolder.setPayload(listOf(Unit))
|
||||
} else {
|
||||
hasBoundPreview = true
|
||||
}
|
||||
|
||||
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ 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.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
@@ -26,11 +28,15 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
) {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val viewModel: BadgesOverviewViewModel by viewModels(factoryProducer = { BadgesOverviewViewModel.Factory(BadgeRepository(requireContext())) })
|
||||
private val viewModel: BadgesOverviewViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, _ ->
|
||||
if (badge.isExpired()) {
|
||||
Badge.register(adapter) { badge, _, isFaded ->
|
||||
if (badge.isExpired() || isFaded) {
|
||||
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
|
||||
} else {
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
|
||||
@@ -56,7 +62,11 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
return configure {
|
||||
sectionHeaderPref(R.string.BadgesOverviewFragment__my_badges)
|
||||
|
||||
displayBadges(requireContext(), state.allUnlockedBadges)
|
||||
displayBadges(
|
||||
context = requireContext(),
|
||||
badges = state.allUnlockedBadges,
|
||||
fadedBadgeId = state.fadedBadgeId
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
|
||||
|
||||
@@ -7,6 +7,7 @@ data class BadgesOverviewState(
|
||||
val allUnlockedBadges: List<Badge> = listOf(),
|
||||
val featuredBadge: Badge? = null,
|
||||
val displayBadgesOnProfile: Boolean = false,
|
||||
val fadedBadgeId: String? = null
|
||||
) {
|
||||
|
||||
val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
|
||||
|
||||
@@ -5,17 +5,25 @@ 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.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.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
|
||||
private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
|
||||
|
||||
class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : ViewModel() {
|
||||
class BadgesOverviewViewModel(
|
||||
private val badgeRepository: BadgeRepository,
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
) : ViewModel() {
|
||||
private val store = Store(BadgesOverviewState())
|
||||
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
|
||||
|
||||
@@ -33,6 +41,19 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
|
||||
featuredBadge = recipient.featuredBadge
|
||||
)
|
||||
}
|
||||
|
||||
disposables += Single.zip(
|
||||
subscriptionsRepository.getActiveSubscription(),
|
||||
subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency())
|
||||
) { active, all ->
|
||||
if (!active.isActive && active.activeSubscription?.willCancelAtPeriodEnd() == true) {
|
||||
Optional.fromNullable<String>(all.firstOrNull { it.level == active.activeSubscription?.level }?.badge?.id)
|
||||
} else {
|
||||
Optional.absent()
|
||||
}
|
||||
}.subscribeBy { badgeId ->
|
||||
store.update { it.copy(fadedBadgeId = badgeId.orNull()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) {
|
||||
@@ -53,9 +74,12 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
|
||||
class Factory(
|
||||
private val badgeRepository: BadgeRepository,
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository)))
|
||||
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.Subscript
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeViewModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -32,7 +33,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
private val donationRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
private val subscribeViewModel: SubscribeViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
SubscribeViewModel.Factory(SubscriptionsRepository(), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
|
||||
SubscribeViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -43,7 +44,6 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
|
||||
warmDonationViewModels()
|
||||
|
||||
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
|
||||
@@ -63,6 +63,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
|
||||
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
|
||||
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToSubscriptions()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +135,9 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
@JvmStatic
|
||||
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
|
||||
|
||||
@JvmStatic
|
||||
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.SUBSCRIPTIONS)
|
||||
|
||||
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
|
||||
return Intent(context, AppSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
@@ -154,7 +158,8 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
HELP(2),
|
||||
PROXY(3),
|
||||
NOTIFICATIONS(4),
|
||||
CHANGE_NUMBER(5);
|
||||
CHANGE_NUMBER(5),
|
||||
SUBSCRIPTIONS(6);
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int?): StartLocation {
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -15,7 +15,9 @@ 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
|
||||
@@ -25,17 +27,27 @@ import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
|
||||
|
||||
private val viewModel: AppSettingsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
AppSettingsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
adapter.registerFactory(BioPreference::class.java, MappingAdapter.LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
|
||||
adapter.registerFactory(PaymentsPreference::class.java, MappingAdapter.LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
|
||||
|
||||
val viewModel = ViewModelProvider(this)[AppSettingsViewModel::class.java]
|
||||
adapter.registerFactory(SubscriptionPreference::class.java, MappingAdapter.LayoutFactory(::SubscriptionPreferenceViewHolder, R.layout.dsl_preference_item))
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refreshActiveSubscription()
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
|
||||
@@ -132,16 +144,19 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
)
|
||||
|
||||
if (FeatureFlags.donorBadges()) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
onClick = {
|
||||
findNavController()
|
||||
.navigate(
|
||||
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
|
||||
.setSkipToSubscribe(true /* TODO [alex] -- Check state to see if user has active subscription or not. */)
|
||||
)
|
||||
}
|
||||
customPref(
|
||||
SubscriptionPreference(
|
||||
title = DSLSettingsText.from(R.string.preferences__subscription),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
isActive = state.hasActiveSubscription,
|
||||
onClick = { isActive ->
|
||||
findNavController()
|
||||
.navigate(
|
||||
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
|
||||
.setSkipToSubscribe(!isActive)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
// TODO [alex] -- clap
|
||||
clickPref(
|
||||
@@ -172,6 +187,29 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
}
|
||||
}
|
||||
|
||||
private class SubscriptionPreference(
|
||||
override val title: DSLSettingsText,
|
||||
override val summary: DSLSettingsText? = null,
|
||||
override val icon: DSLSettingsIcon? = null,
|
||||
override val isEnabled: Boolean = true,
|
||||
val isActive: Boolean = false,
|
||||
val onClick: (Boolean) -> Unit
|
||||
) : PreferenceModel<SubscriptionPreference>() {
|
||||
override fun areItemsTheSame(newItem: SubscriptionPreference): Boolean {
|
||||
return true
|
||||
}
|
||||
override fun areContentsTheSame(newItem: SubscriptionPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && isActive == newItem.isActive
|
||||
}
|
||||
}
|
||||
|
||||
private class SubscriptionPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SubscriptionPreference>(itemView) {
|
||||
override fun bind(model: SubscriptionPreference) {
|
||||
super.bind(model)
|
||||
itemView.setOnClickListener { model.onClick(model.isActive) }
|
||||
}
|
||||
}
|
||||
|
||||
private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel<BioPreference>() {
|
||||
override fun areContentsTheSame(newItem: BioPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
|
||||
|
||||
@@ -2,4 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class AppSettingsState(val self: Recipient, val unreadPaymentsCount: Int)
|
||||
data class AppSettingsState(
|
||||
val self: Recipient,
|
||||
val unreadPaymentsCount: Int,
|
||||
val hasActiveSubscription: Boolean
|
||||
)
|
||||
|
||||
@@ -2,18 +2,47 @@ 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.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.livedata.LiveDataUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AppSettingsViewModel : ViewModel() {
|
||||
class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
|
||||
|
||||
val unreadPaymentsLiveData = UnreadPaymentsLiveData()
|
||||
val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
|
||||
private val store = Store(AppSettingsState(Recipient.self(), 0, false))
|
||||
|
||||
val state: LiveData<AppSettingsState> = LiveDataUtil.combineLatest(unreadPaymentsLiveData, selfLiveData) { payments, self ->
|
||||
val unreadPaymentsCount = payments.transform { it.unreadCount }.or(0)
|
||||
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
|
||||
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
|
||||
|
||||
AppSettingsState(self, unreadPaymentsCount)
|
||||
val state: LiveData<AppSettingsState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.transform { it.unreadCount }.or(0)) }
|
||||
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.isActive) } },
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ sealed class DonationEvent {
|
||||
object RequestTokenError : DonationEvent()
|
||||
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
|
||||
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
|
||||
class SubscriptionCancellationFailed(val throwable: Throwable) : DonationEvent()
|
||||
object SubscriptionCancelled : DonationEvent()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
class DonationExceptions {
|
||||
object TimedOutWaitingForTokenRedemption : Exception()
|
||||
}
|
||||
@@ -5,18 +5,44 @@ import android.content.Intent
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
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.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher {
|
||||
/**
|
||||
* Manages bindings with payment APIs
|
||||
*
|
||||
* Steps for setting up payments for a subscription:
|
||||
* 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. Generate and send a SubscriberId, which is a 32 byte ID representing this user, to Signal Service, which creates a Stripe Customer
|
||||
* 1. Create a SetupIntent via the Stripe API
|
||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
||||
* 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:
|
||||
* 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
|
||||
* 1. Confirm the PaymentIntent via the Stripe API
|
||||
*/
|
||||
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
|
||||
private val configuration = StripeApi.Configuration(publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY)
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(configuration))
|
||||
private val stripeApi = StripeApi(configuration, this, ApplicationDependencies.getOkHttpClient())
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
|
||||
|
||||
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
|
||||
|
||||
@@ -46,10 +72,153 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
}
|
||||
}
|
||||
|
||||
fun continueSubscriptionSetup(paymentData: PaymentData): Completable {
|
||||
return stripeApi.createSetupIntent()
|
||||
.flatMapCompletable { result ->
|
||||
stripeApi.confirmSetupIntent(GooglePayPaymentSource(paymentData), result.setupIntent)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelActiveSubscription(): Completable {
|
||||
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
return ApplicationDependencies.getDonationsService().cancelSubscription(localSubscriber.subscriberId).flatMapCompletable {
|
||||
when {
|
||||
it.status == 200 -> Completable.complete()
|
||||
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
|
||||
it.executionError.isPresent -> Completable.error(it.executionError.get())
|
||||
else -> Completable.error(AssertionError("Something bad happened"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureSubscriberId(): Completable {
|
||||
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.putSubscription(subscriberId)
|
||||
.flatMapCompletable {
|
||||
when {
|
||||
it.status == 200 -> Completable.complete()
|
||||
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
|
||||
it.executionError.isPresent -> Completable.error(it.executionError.get())
|
||||
else -> Completable.error(AssertionError("Something bad happened"))
|
||||
}
|
||||
}
|
||||
.doOnComplete {
|
||||
SignalStore
|
||||
.donationsValues()
|
||||
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
|
||||
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
|
||||
subscriber.subscriberId,
|
||||
subscriptionLevel,
|
||||
subscriber.currencyCode,
|
||||
levelUpdateOperation.idempotencyKey.serialize()
|
||||
).flatMapCompletable { response ->
|
||||
when {
|
||||
response.status == 200 -> Completable.complete()
|
||||
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
|
||||
response.executionError.isPresent -> Completable.error(response.executionError.get())
|
||||
else -> Completable.error(AssertionError("should never happen"))
|
||||
}
|
||||
}.andThen {
|
||||
SignalStore.donationsValues().clearLevelOperation(levelUpdateOperation)
|
||||
it.onComplete()
|
||||
}.andThen {
|
||||
val jobIds = SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation()
|
||||
val countDownLatch = CountDownLatch(2)
|
||||
|
||||
val firstJobListener = JobTracker.JobListener { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val secondJobListener = JobTracker.JobListener { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationDependencies.getJobManager().addListener(jobIds.first(), firstJobListener)
|
||||
ApplicationDependencies.getJobManager().addListener(jobIds.second(), secondJobListener)
|
||||
|
||||
try {
|
||||
if (!countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
} else {
|
||||
it.onComplete()
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
}
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
|
||||
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation()
|
||||
if (levelUpdateOperation == null || subscriptionLevel != levelUpdateOperation.level) {
|
||||
val newOperation = LevelUpdateOperation(
|
||||
idempotencyKey = IdempotencyKey.generate(),
|
||||
level = subscriptionLevel
|
||||
)
|
||||
|
||||
SignalStore.donationsValues().setLevelOperation(newOperation)
|
||||
newOperation
|
||||
} else {
|
||||
levelUpdateOperation
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
|
||||
.map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
|
||||
.flatMap { response ->
|
||||
when {
|
||||
response.status == 200 -> Single.just(StripeApi.PaymentIntent(response.result.get().id, response.result.get().clientSecret))
|
||||
response.executionError.isPresent -> Single.error(response.executionError.get())
|
||||
response.applicationError.isPresent -> Single.error(response.applicationError.get())
|
||||
else -> Single.error(AssertionError("should never get here"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> {
|
||||
return Single.fromCallable {
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
ApplicationDependencies.getDonationsService().createSubscriptionPaymentMethod(it.subscriberId)
|
||||
}.flatMap { response ->
|
||||
when {
|
||||
response.status == 200 -> Single.just(StripeApi.SetupIntent(response.result.get().id, response.result.get().clientSecret))
|
||||
response.executionError.isPresent -> Single.error(response.executionError.get())
|
||||
response.applicationError.isPresent -> Single.error(response.applicationError.get())
|
||||
else -> Single.error(AssertionError("should never get here"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||
return Single.fromCallable {
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
ApplicationDependencies.getDonationsService().setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
|
||||
}.flatMapCompletable { response ->
|
||||
when {
|
||||
response.status == 200 -> Completable.complete()
|
||||
response.executionError.isPresent -> Completable.error(response.executionError.get())
|
||||
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
|
||||
else -> Completable.error(AssertionError("Should never get here"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
|
||||
* in the currency indicated.
|
||||
*/
|
||||
class SubscriptionsRepository {
|
||||
class SubscriptionsRepository(private val donationsService: DonationsService) {
|
||||
|
||||
fun getActiveSubscription(currency: Currency): Maybe<Subscription> = Maybe.empty()
|
||||
fun getActiveSubscription(): Single<ActiveSubscription> {
|
||||
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
||||
return if (localSubscription != null) {
|
||||
donationsService.getSubscription(localSubscription.subscriberId).flatMap {
|
||||
when {
|
||||
it.status == 200 -> Single.just(it.result.get())
|
||||
it.applicationError.isPresent -> Single.error(it.applicationError.get())
|
||||
it.executionError.isPresent -> Single.error(it.executionError.get())
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Single.just(ActiveSubscription(null))
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = Single.fromCallable {
|
||||
listOf()
|
||||
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = donationsService.subscriptionLevels.map { response ->
|
||||
response.result.transform { subscriptionLevels ->
|
||||
subscriptionLevels.levels.map { (code, level) ->
|
||||
Subscription(
|
||||
id = code,
|
||||
title = level.badge.name,
|
||||
badge = Badges.fromServiceBadge(level.badge),
|
||||
price = FiatMoney(level.currencies[currency.currencyCode]!!, currency),
|
||||
level = code.toInt()
|
||||
)
|
||||
}.sortedBy {
|
||||
it.level
|
||||
}
|
||||
}.or(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -15,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
@@ -24,11 +28,15 @@ import org.thoughtcrime.securesms.util.SpanUtil
|
||||
/**
|
||||
* UX to allow users to donate ephemerally.
|
||||
*/
|
||||
class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
class BoostFragment : DSLSettingsBottomSheetFragment(
|
||||
layoutId = R.layout.boost_bottom_sheet
|
||||
) {
|
||||
|
||||
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private lateinit var processingDonationPaymentDialog: AlertDialog
|
||||
|
||||
private val sayThanks: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
|
||||
.append(" ")
|
||||
@@ -45,6 +53,11 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
Boost.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.processing_payment_dialog)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
@@ -52,17 +65,24 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
|
||||
when (event) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", event.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", event.throwable)
|
||||
is DonationEvent.GooglePayUnavailableError -> onGooglePayUnavailable(event.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> onPaymentError(event.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.RequestTokenError -> onPaymentError(null)
|
||||
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> Unit
|
||||
is DonationEvent.SubscriptionCancellationFailed -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: BoostState): DSLConfiguration {
|
||||
if (state.stage == BoostState.Stage.PAYMENT_PIPELINE) {
|
||||
processingDonationPaymentDialog.show()
|
||||
} else {
|
||||
processingDonationPaymentDialog.hide()
|
||||
}
|
||||
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.boostBadge))
|
||||
|
||||
@@ -87,7 +107,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == BoostState.Stage.READY,
|
||||
onClick = {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment())
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment(true))
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -137,7 +157,43 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(boostBadge: Badge) {
|
||||
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true))
|
||||
findNavController().navigate(
|
||||
BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true),
|
||||
NavOptions.Builder().setPopUpTo(R.id.boostFragment, true).build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPaymentError(throwable: Throwable?) {
|
||||
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
|
||||
Log.w(TAG, "Error occurred while redeeming token", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__redemption_still_pending)
|
||||
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Error occurred while processing payment", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__payment_failed)
|
||||
.setMessage(R.string.DonationsErrors__your_payment)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayUnavailable(throwable: Throwable?) {
|
||||
Log.w(TAG, "Google Pay error", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__google_pay_unavailable)
|
||||
.setMessage(R.string.DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -14,11 +14,12 @@ data class BoostState(
|
||||
val selectedBoost: Boost? = null,
|
||||
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, Currency.getInstance(currencySelection.selectedCurrencyCode)),
|
||||
val isCustomAmountFocused: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
val stage: Stage = Stage.INIT,
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
TOKEN_REQUEST,
|
||||
PAYMENT_PIPELINE,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
@@ -31,7 +32,7 @@ class BoostViewModel(
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<BoostState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private var boostToPurchase: Boost? = null
|
||||
|
||||
@@ -40,7 +41,7 @@ class BoostViewModel(
|
||||
}
|
||||
|
||||
init {
|
||||
val currencyObservable = SignalStore.donationsValues().observableCurrency
|
||||
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
|
||||
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
|
||||
val boostBadge = boostRepository.getBoostBadge()
|
||||
|
||||
@@ -91,22 +92,28 @@ class BoostViewModel(
|
||||
if (boost != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
onError = { throwable ->
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
},
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
// TODO [alex] Now we need to do the whole query for a token, submit token rigamarole
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -13,7 +13,11 @@ import java.util.Locale
|
||||
*/
|
||||
class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: SetCurrencyViewModel by viewModels()
|
||||
private val viewModel: SetCurrencyViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
SetCurrencyViewModel.Factory(SetCurrencyFragmentArgs.fromBundle(requireArguments()).isBoost)
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.currency
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -10,14 +11,14 @@ import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
class SetCurrencyViewModel : ViewModel() {
|
||||
class SetCurrencyViewModel(val isBoost: Boolean) : ViewModel() {
|
||||
|
||||
private val store = Store(SetCurrencyState())
|
||||
|
||||
val state: LiveData<SetCurrencyState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
val defaultCurrency = SignalStore.donationsValues().getCurrency()
|
||||
val defaultCurrency = SignalStore.donationsValues().getSubscriptionCurrency()
|
||||
|
||||
store.update { state ->
|
||||
val platformCurrencies = Currency.getAvailableCurrencies()
|
||||
@@ -34,7 +35,12 @@ class SetCurrencyViewModel : ViewModel() {
|
||||
|
||||
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
||||
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
||||
SignalStore.donationsValues().setCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
|
||||
if (isBoost) {
|
||||
SignalStore.donationsValues().setBoostCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
} else {
|
||||
SignalStore.donationsValues().setSubscriptionCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -65,4 +71,10 @@ class SetCurrencyViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val isBoost: Boolean) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(SetCurrencyViewModel(isBoost))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,17 @@ object ActiveSubscriptionPreference {
|
||||
|
||||
class Model(
|
||||
val subscription: Subscription,
|
||||
val onAddBoostClick: () -> Unit
|
||||
val onAddBoostClick: () -> Unit,
|
||||
val renewalTimestamp: Long = -1L
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return subscription.id == newItem.subscription.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && subscription == newItem.subscription
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
subscription == newItem.subscription &&
|
||||
renewalTimestamp == newItem.renewalTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +60,7 @@ object ActiveSubscriptionPreference {
|
||||
R.string.MySupportPreference__renews_s,
|
||||
DateUtils.formatDate(
|
||||
Locale.getDefault(),
|
||||
model.subscription.renewalTimestamp
|
||||
model.renewalTimestamp
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.widget.Toast
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -13,7 +14,11 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
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.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
|
||||
@@ -23,7 +28,7 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
ManageDonationsViewModel.Factory(SubscriptionsRepository())
|
||||
ManageDonationsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,6 +58,7 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
}
|
||||
|
||||
ActiveSubscriptionPreference.register(adapter)
|
||||
IndeterminateLoadingCircle.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
@@ -83,17 +89,32 @@ class ManageDonationsFragment : DSLSettingsFragment() {
|
||||
)
|
||||
)
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
subscription = state.activeSubscription,
|
||||
onAddBoostClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
}
|
||||
)
|
||||
)
|
||||
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
|
||||
val activeSubscription = state.transactionState.activeSubscription
|
||||
if (activeSubscription.isActive) {
|
||||
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.activeSubscription.level == it.level }
|
||||
if (subscription != null) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
dividerPref()
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
subscription = subscription,
|
||||
onAddBoostClick = {
|
||||
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
|
||||
},
|
||||
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.activeSubscription.endOfCurrentPeriod)
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
|
||||
@@ -2,8 +2,16 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
data class ManageDonationsState(
|
||||
val featuredBadge: Badge? = null,
|
||||
val activeSubscription: Subscription? = null
|
||||
)
|
||||
val transactionState: TransactionState = TransactionState.Init,
|
||||
val availableSubscriptions: List<Subscription> = emptyList()
|
||||
) {
|
||||
sealed class TransactionState {
|
||||
object Init : TransactionState()
|
||||
object InTransaction : TransactionState()
|
||||
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ 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.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
@@ -11,7 +13,10 @@ import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
class ManageDonationsViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
@@ -22,7 +27,7 @@ class ManageDonationsViewModel(
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<ManageDonationsState> = store.stateLiveData
|
||||
val events: Observable<ManageDonationsEvent> = eventPublisher
|
||||
val events: Observable<ManageDonationsEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
init {
|
||||
store.update(Recipient.self().live().liveDataResolved) { self, state ->
|
||||
@@ -36,16 +41,34 @@ class ManageDonationsViewModel(
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
disposables += subscriptionsRepository.getActiveSubscription(SignalStore.donationsValues().getCurrency()).subscribeBy(
|
||||
onSuccess = { subscription -> store.update { it.copy(activeSubscription = subscription) } },
|
||||
onComplete = {
|
||||
store.update { it.copy(activeSubscription = null) }
|
||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
||||
|
||||
val levelUpdateOperationEdges: Observable<Optional<LevelUpdateOperation>> = SignalStore.donationsValues().levelUpdateOperationObservable.distinctUntilChanged()
|
||||
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription()
|
||||
|
||||
disposables += levelUpdateOperationEdges.flatMapSingle { optionalKey ->
|
||||
if (optionalKey.isPresent) {
|
||||
Single.just(ManageDonationsState.TransactionState.InTransaction)
|
||||
} else {
|
||||
activeSubscription.map { ManageDonationsState.TransactionState.NotInTransaction(it) }
|
||||
}
|
||||
}.subscribeBy(
|
||||
onNext = { transactionState ->
|
||||
store.update {
|
||||
it.copy(transactionState = transactionState)
|
||||
}
|
||||
|
||||
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && !transactionState.activeSubscription.isActive) {
|
||||
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
|
||||
}
|
||||
)
|
||||
|
||||
disposables += subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency()).subscribeBy { subs ->
|
||||
store.update { it.copy(availableSubscriptions = subs) }
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -38,7 +38,10 @@ data class CurrencySelection(
|
||||
|
||||
override fun bind(model: Model) {
|
||||
spinner.text = model.currencySelection.selectedCurrencyCode
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
|
||||
if (model.isEnabled) {
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.graphics.Color
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
@@ -16,18 +18,26 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
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.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* UX for creating and changing a subscription
|
||||
*/
|
||||
class SubscribeFragment : DSLSettingsFragment() {
|
||||
class SubscribeFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.subscribe_fragment
|
||||
) {
|
||||
|
||||
private val viewModel: SubscribeViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
@@ -43,9 +53,11 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
)
|
||||
}
|
||||
|
||||
private lateinit var processingDonationPaymentDialog: AlertDialog
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refresh()
|
||||
viewModel.refreshActiveSubscription()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
@@ -54,6 +66,11 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
Subscription.register(adapter)
|
||||
GooglePayButton.register(adapter)
|
||||
|
||||
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.processing_payment_dialog)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
@@ -61,19 +78,28 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe {
|
||||
when (it) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", it.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", it.throwable)
|
||||
is DonationEvent.GooglePayUnavailableError -> onGooglePayUnavailable(it.throwable)
|
||||
is DonationEvent.PaymentConfirmationError -> onPaymentError(it.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
|
||||
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
|
||||
DonationEvent.RequestTokenError -> onPaymentError(null)
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
|
||||
is DonationEvent.SubscriptionCancellationFailed -> onSubscriptionFailedToCancel(it.throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
|
||||
if (state.hasInProgressSubscriptionTransaction || state.stage == SubscribeState.Stage.PAYMENT_PIPELINE) {
|
||||
processingDonationPaymentDialog.show()
|
||||
} else {
|
||||
processingDonationPaymentDialog.hide()
|
||||
}
|
||||
|
||||
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
|
||||
|
||||
return configure {
|
||||
customPref(BadgePreview.SubscriptionModel(state.previewBadge))
|
||||
customPref(BadgePreview.SubscriptionModel(state.selectedSubscription?.badge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
@@ -91,35 +117,63 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
currencySelection = state.currencySelection,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
isEnabled = areFieldsEnabled && state.activeSubscription?.isActive != true,
|
||||
onClick = {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment())
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment(false))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
state.subscriptions.forEach {
|
||||
val isActive = state.activeSubscription?.activeSubscription?.level == it.level
|
||||
customPref(
|
||||
Subscription.Model(
|
||||
subscription = it,
|
||||
isSelected = state.selectedSubscription == it,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY,
|
||||
isActive = state.activeSubscription == it,
|
||||
onClick = { viewModel.setSelectedSubscription(it) }
|
||||
isEnabled = areFieldsEnabled,
|
||||
isActive = isActive,
|
||||
willRenew = isActive && state.activeSubscription?.activeSubscription?.willCancelAtPeriodEnd() ?: false,
|
||||
onClick = { viewModel.setSelectedSubscription(it) },
|
||||
renewalTimestamp = TimeUnit.SECONDS.toMillis(state.activeSubscription?.activeSubscription?.endOfCurrentPeriod ?: 0L)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.activeSubscription != null) {
|
||||
if (state.activeSubscription?.isActive == true) {
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
val activeAndSameLevel = state.activeSubscription.isActive &&
|
||||
state.selectedSubscription?.level == state.activeSubscription.activeSubscription?.level
|
||||
val isExpiring = state.activeSubscription.isActive && state.activeSubscription.activeSubscription?.willCancelAtPeriodEnd() == true
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
|
||||
isEnabled = areFieldsEnabled && (!activeAndSameLevel || isExpiring),
|
||||
onClick = {
|
||||
// TODO [alex] -- Dunno what the update process requires.
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.add(Calendar.MONTH, 1)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__update_subscription_question)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.SubscribeFragment__you_will_be_charged_the_full_amount,
|
||||
DateUtils.formatDateWithYear(Locale.getDefault(), calendar.timeInMillis)
|
||||
)
|
||||
)
|
||||
.setPositiveButton(R.string.SubscribeFragment__update) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
viewModel.updateSubscription()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
isEnabled = areFieldsEnabled,
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
@@ -141,7 +195,7 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
onClick = this@SubscribeFragment::onGooglePayButtonClicked,
|
||||
isEnabled = state.stage == SubscribeState.Stage.READY
|
||||
isEnabled = areFieldsEnabled && state.selectedSubscription != null
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -150,7 +204,7 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
|
||||
onClick = {
|
||||
// TODO
|
||||
// TODO [alex] support page
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -162,11 +216,62 @@ class SubscribeFragment : DSLSettingsFragment() {
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed(badge: Badge) {
|
||||
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false))
|
||||
findNavController().navigate(
|
||||
SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPaymentError(throwable: Throwable?) {
|
||||
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
|
||||
Log.w(TAG, "Error occurred while redeeming token", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__redemption_still_pending)
|
||||
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Error occurred while processing payment", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__payment_failed)
|
||||
.setMessage(R.string.DonationsErrors__your_payment)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGooglePayUnavailable(throwable: Throwable?) {
|
||||
Log.w(TAG, "Google Pay error", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__google_pay_unavailable)
|
||||
.setMessage(R.string.DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubscriptionCancelled() {
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG)
|
||||
.setTextColor(Color.WHITE)
|
||||
.show()
|
||||
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.home(requireContext()))
|
||||
}
|
||||
|
||||
private fun onSubscriptionFailedToCancel(throwable: Throwable) {
|
||||
Log.w(TAG, "Failed to cancel subscription", throwable)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
|
||||
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
data class SubscribeState(
|
||||
val previewBadge: Badge? = null,
|
||||
val currencySelection: CurrencySelection = CurrencySelection("USD"),
|
||||
val subscriptions: List<Subscription> = listOf(),
|
||||
val selectedSubscription: Subscription? = null,
|
||||
val activeSubscription: Subscription? = null,
|
||||
val activeSubscription: ActiveSubscription? = null,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val stage: Stage = Stage.INIT
|
||||
val stage: Stage = Stage.INIT,
|
||||
val hasInProgressSubscriptionTransaction: Boolean = false,
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
TOKEN_REQUEST,
|
||||
PAYMENT_PIPELINE,
|
||||
CANCELLING
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
@@ -18,7 +19,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Cu
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Currency
|
||||
|
||||
class SubscribeViewModel(
|
||||
private val subscriptionsRepository: SubscriptionsRepository,
|
||||
@@ -31,31 +33,35 @@ class SubscribeViewModel(
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<SubscribeState> = store.stateLiveData
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private var subscriptionToPurchase: Subscription? = null
|
||||
private val activeSubscriptionSubject = PublishSubject.create<ActiveSubscription>()
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
init {
|
||||
val currency: Observable<Currency> = SignalStore.donationsValues().observableSubscriptionCurrency
|
||||
val allSubscriptions: Observable<List<Subscription>> = currency.switchMapSingle { subscriptionsRepository.getSubscriptions(it) }
|
||||
refreshActiveSubscription()
|
||||
|
||||
val currency = SignalStore.donationsValues().getCurrency()
|
||||
disposables += SignalStore.donationsValues().levelUpdateOperationObservable.subscribeBy {
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
hasInProgressSubscriptionTransaction = it.isPresent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val allSubscriptions = subscriptionsRepository.getSubscriptions(currency)
|
||||
val activeSubscription = subscriptionsRepository.getActiveSubscription(currency)
|
||||
.map { Optional.of(it) }
|
||||
.defaultIfEmpty(Optional.absent())
|
||||
|
||||
disposables += allSubscriptions.zipWith(activeSubscription, ::Pair).subscribe { (subs, active) ->
|
||||
disposables += Observable.combineLatest(allSubscriptions, activeSubscriptionSubject, ::Pair).subscribe { (subs, active) ->
|
||||
store.update {
|
||||
it.copy(
|
||||
subscriptions = subs,
|
||||
selectedSubscription = it.selectedSubscription ?: active.orNull() ?: subs.firstOrNull(),
|
||||
activeSubscription = active.orNull(),
|
||||
stage = SubscribeState.Stage.READY
|
||||
selectedSubscription = it.selectedSubscription ?: resolveSelectedSubscription(active, subs),
|
||||
activeSubscription = active,
|
||||
stage = if (it.stage == SubscribeState.Stage.INIT) SubscribeState.Stage.READY else it.stage,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -65,11 +71,39 @@ class SubscribeViewModel(
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
store.update { it.copy(currencySelection = CurrencySelection(SignalStore.donationsValues().getCurrency().currencyCode)) }
|
||||
disposables += currency.map { CurrencySelection(it.currencyCode) }.subscribe { selection ->
|
||||
store.update { it.copy(currencySelection = selection) }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshActiveSubscription() {
|
||||
subscriptionsRepository
|
||||
.getActiveSubscription()
|
||||
.subscribeBy { activeSubscriptionSubject.onNext(it) }
|
||||
}
|
||||
|
||||
private fun resolveSelectedSubscription(activeSubscription: ActiveSubscription, subscriptions: List<Subscription>): Subscription? {
|
||||
return if (activeSubscription.isActive) {
|
||||
subscriptions.firstOrNull { it.level == activeSubscription.activeSubscription.level }
|
||||
} else {
|
||||
subscriptions.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
|
||||
// TODO [alex] -- cancel api call
|
||||
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
|
||||
onComplete = {
|
||||
eventPublisher.onNext(DonationEvent.SubscriptionCancelled)
|
||||
SignalStore.donationsValues().setLastEndOfPeriod(0L)
|
||||
refreshActiveSubscription()
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
},
|
||||
onError = { throwable ->
|
||||
eventPublisher.onNext(DonationEvent.SubscriptionCancellationFailed(throwable))
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
@@ -77,44 +111,72 @@ class SubscribeViewModel(
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
val subscription = subscriptionToPurchase
|
||||
subscriptionToPurchase = null
|
||||
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode, resultCode, data, this.fetchTokenRequestCode,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
val subscription = subscriptionToPurchase
|
||||
subscriptionToPurchase = null
|
||||
|
||||
if (subscription != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
donationPaymentRepository.continuePayment(subscription.price, paymentData).subscribeBy(
|
||||
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
|
||||
|
||||
val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId()
|
||||
val continueSetup = donationPaymentRepository.continueSubscriptionSetup(paymentData)
|
||||
val setLevel = donationPaymentRepository.setSubscriptionLevel(subscription.level.toString())
|
||||
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
ensureSubscriberId.andThen(continueSetup).andThen(setLevel).subscribeBy(
|
||||
onError = { throwable ->
|
||||
refreshActiveSubscription()
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
},
|
||||
onComplete = {
|
||||
// Now we need to do the whole query for a token, submit token rigamarole
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError)
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSubscription() {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
donationPaymentRepository.setSubscriptionLevel(store.state.selectedSubscription!!.level.toString())
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.selectedSubscription!!.badge))
|
||||
},
|
||||
onError = { throwable ->
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.selectedSubscription == null) {
|
||||
return
|
||||
}
|
||||
|
||||
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
|
||||
store.update { it.copy(stage = SubscribeState.Stage.TOKEN_REQUEST) }
|
||||
|
||||
subscriptionToPurchase = snapshot.selectedSubscription
|
||||
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.price, snapshot.selectedSubscription.title, fetchTokenRequestCode)
|
||||
|
||||
@@ -1,54 +1,122 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
|
||||
|
||||
import android.animation.Animator
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.airbnb.lottie.LottieDrawable
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private lateinit var displayOnProfileSwitch: SwitchMaterial
|
||||
private lateinit var switch: SwitchMaterial
|
||||
private lateinit var heading: TextView
|
||||
|
||||
private lateinit var badgeRepository: BadgeRepository
|
||||
private lateinit var controlState: ControlState
|
||||
|
||||
private enum class ControlState {
|
||||
FEATURE,
|
||||
DISPLAY
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.thanks_for_your_support_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
badgeRepository = BadgeRepository(requireContext())
|
||||
|
||||
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
|
||||
val lottie: LottieAnimationView = view.findViewById(R.id.thanks_bottom_sheet_lottie)
|
||||
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
|
||||
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
|
||||
val controlText: TextView = view.findViewById(R.id.thanks_bottom_sheet_control_text)
|
||||
val controlNote: View = view.findViewById(R.id.thanks_bottom_sheet_featured_note)
|
||||
|
||||
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
|
||||
displayOnProfileSwitch = view.findViewById(R.id.thanks_bottom_sheet_display_on_profile)
|
||||
switch = view.findViewById(R.id.thanks_bottom_sheet_switch)
|
||||
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
badgeView.setBadge(args.badge)
|
||||
badgeName.text = args.badge.name
|
||||
displayOnProfileSwitch.isChecked = true
|
||||
|
||||
val otherBadges = Recipient.self().badges.filterNot { it.id == args.badge.id }
|
||||
val hasOtherBadges = otherBadges.isNotEmpty()
|
||||
val displayingBadges = otherBadges.all { it.visible }
|
||||
|
||||
if (hasOtherBadges && displayingBadges) {
|
||||
switch.isChecked = false
|
||||
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge)
|
||||
controlNote.visible = true
|
||||
controlState = ControlState.FEATURE
|
||||
} else if (hasOtherBadges && !displayingBadges) {
|
||||
switch.isChecked = false
|
||||
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
|
||||
controlNote.visible = false
|
||||
controlState = ControlState.DISPLAY
|
||||
} else {
|
||||
switch.isChecked = true
|
||||
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
|
||||
controlNote.visible = false
|
||||
controlState = ControlState.DISPLAY
|
||||
}
|
||||
|
||||
if (args.isBoost) {
|
||||
presentBoostCopy()
|
||||
lottie.visible = true
|
||||
lottie.playAnimation()
|
||||
lottie.addAnimatorListener(object : AnimationCompleteListener() {
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
lottie.removeAnimatorListener(this)
|
||||
lottie.setMinAndMaxFrame(30, 91)
|
||||
lottie.repeatMode = LottieDrawable.RESTART
|
||||
lottie.repeatCount = LottieDrawable.INFINITE
|
||||
lottie.frame = 30
|
||||
lottie.playAnimation()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
presentSubscriptionCopy()
|
||||
lottie.visible = false
|
||||
}
|
||||
|
||||
done.setOnClickListener { dismissAllowingStateLoss() }
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
val isDisplayOnProfile = displayOnProfileSwitch.isChecked
|
||||
// TODO [alex] -- Not sure what state we're in with regards to submitting the token.
|
||||
val controlChecked = switch.isChecked
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
|
||||
if (controlState == ControlState.DISPLAY) {
|
||||
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe()
|
||||
} else {
|
||||
badgeRepository.setFeaturedBadge(args.badge).subscribe()
|
||||
}
|
||||
|
||||
if (args.isBoost) {
|
||||
findNavController().popBackStack()
|
||||
} else {
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentBoostCopy() {
|
||||
|
||||
@@ -194,7 +194,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
|
||||
val recipientId = args.recipientId
|
||||
if (recipientId != null) {
|
||||
Badge.register(adapter) { badge, _ ->
|
||||
Badge.register(adapter) { badge, _, _ ->
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, recipientId, badge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object IndeterminateLoadingCircle : PreferenceModel<IndeterminateLoadingCircle>() {
|
||||
override fun areItemsTheSame(newItem: IndeterminateLoadingCircle): Boolean = true
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<IndeterminateLoadingCircle>(itemView) {
|
||||
override fun bind(model: IndeterminateLoadingCircle) = Unit
|
||||
}
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(IndeterminateLoadingCircle::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.indeterminate_loading_circle_pref))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.concurrent.DeadlockDetector;
|
||||
import org.signal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||
import org.thoughtcrime.securesms.KbsEnclave;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusRepository;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
||||
@@ -108,6 +109,7 @@ public class ApplicationDependencies {
|
||||
private static volatile AudioManagerCompat audioManagerCompat;
|
||||
private static volatile DonationsService donationsService;
|
||||
private static volatile DeadlockDetector deadlockDetector;
|
||||
private static volatile ClientZkReceiptOperations clientZkReceiptOperations;
|
||||
|
||||
@MainThread
|
||||
public static void init(@NonNull Application application, @NonNull Provider provider) {
|
||||
@@ -605,6 +607,17 @@ public class ApplicationDependencies {
|
||||
return donationsService;
|
||||
}
|
||||
|
||||
public static @NonNull ClientZkReceiptOperations getClientZkReceiptOperations() {
|
||||
if (clientZkReceiptOperations == null) {
|
||||
synchronized (LOCK) {
|
||||
if (clientZkReceiptOperations == null) {
|
||||
clientZkReceiptOperations = provider.provideClientZkReceiptOperations();
|
||||
}
|
||||
}
|
||||
}
|
||||
return clientZkReceiptOperations;
|
||||
}
|
||||
|
||||
public static @NonNull DeadlockDetector getDeadlockDetector() {
|
||||
if (deadlockDetector == null) {
|
||||
synchronized (LOCK) {
|
||||
@@ -653,5 +666,6 @@ public class ApplicationDependencies {
|
||||
@NonNull AudioManagerCompat provideAndroidCallAudioManager();
|
||||
@NonNull DonationsService provideDonationsService();
|
||||
@NonNull DeadlockDetector provideDeadlockDetector();
|
||||
@NonNull ClientZkReceiptOperations provideClientZkReceiptOperations();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.concurrent.DeadlockDetector;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusRepository;
|
||||
import org.thoughtcrime.securesms.components.TypingStatusSender;
|
||||
@@ -322,6 +323,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
|
||||
return new DeadlockDetector(new Handler(handlerThread.getLooper()), TimeUnit.SECONDS.toMillis(5));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ClientZkReceiptOperations provideClientZkReceiptOperations() {
|
||||
return provideClientZkOperations().getReceiptOperations();
|
||||
}
|
||||
|
||||
private @NonNull WebSocketFactory provideWebSocketFactory(@NonNull SignalWebSocketHealthMonitor healthMonitor) {
|
||||
return new WebSocketFactory() {
|
||||
@Override
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -60,7 +61,7 @@ public final class GroupCandidateHelper {
|
||||
if (profileKey != null) {
|
||||
Log.i(TAG, String.format("No profile key credential on recipient %s, fetching", recipient.getId()));
|
||||
|
||||
Optional<ProfileKeyCredential> profileKeyCredentialOptional = signalServiceAccountManager.resolveProfileKeyCredential(uuid, profileKey);
|
||||
Optional<ProfileKeyCredential> profileKeyCredentialOptional = signalServiceAccountManager.resolveProfileKeyCredential(uuid, profileKey, Locale.getDefault());
|
||||
|
||||
if (profileKeyCredentialOptional.isPresent()) {
|
||||
boolean updatedProfileKey = recipientDatabase.setProfileKeyCredential(recipient.getId(), profileKey, profileKeyCredentialOptional.get());
|
||||
|
||||
@@ -38,7 +38,8 @@ import java.util.List;
|
||||
public class HelpFragment extends LoggingFragment {
|
||||
|
||||
public static final String START_CATEGORY_INDEX = "start_category_index";
|
||||
public static final int PAYMENT_INDEX = 5;
|
||||
public static final int PAYMENT_INDEX = 6;
|
||||
public static final int DONATION_INDEX = 7;
|
||||
|
||||
private EditText problem;
|
||||
private CheckBox includeDebugLogs;
|
||||
@@ -92,7 +93,7 @@ public class HelpFragment extends LoggingFragment {
|
||||
emoji.add(view.findViewById(feeling.getViewId()));
|
||||
}
|
||||
|
||||
categoryAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.HelpFragment__categories, android.R.layout.simple_spinner_item);
|
||||
categoryAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.HelpFragment__categories_2, android.R.layout.simple_spinner_item);
|
||||
categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
|
||||
categorySpinner.setAdapter(categoryAdapter);
|
||||
@@ -208,7 +209,7 @@ public class HelpFragment extends LoggingFragment {
|
||||
suffix.append(getString(feeling.getStringId()));
|
||||
}
|
||||
|
||||
String[] englishCategories = ResourceUtil.getEnglishResources(getContext()).getStringArray(R.array.HelpFragment__categories);
|
||||
String[] englishCategories = ResourceUtil.getEnglishResources(requireContext()).getStringArray(R.array.HelpFragment__categories_2);
|
||||
String category = (helpViewModel.getCategoryIndex() >= 0 && helpViewModel.getCategoryIndex() < englishCategories.length) ? englishCategories[helpViewModel.getCategoryIndex()]
|
||||
: categoryAdapter.getItem(helpViewModel.getCategoryIndex()).toString();
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey;
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Job to redeem a verified donation receipt. It is up to the Job prior in the chain to specify a valid
|
||||
* presentation object via setOutputData. This is expected to be the byte[] blob of a ReceiptCredentialPresentation object.
|
||||
*/
|
||||
public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
|
||||
|
||||
public static final String KEY = "DonationReceiptRedemptionJob";
|
||||
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
|
||||
|
||||
public static DonationReceiptRedemptionJob createJob() {
|
||||
return new DonationReceiptRedemptionJob(
|
||||
new Job.Parameters
|
||||
.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue("ReceiptRedemption")
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.setMaxInstancesForQueue(1)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(7))
|
||||
.build());
|
||||
}
|
||||
|
||||
private DonationReceiptRedemptionJob(@NonNull Job.Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
Data inputData = getInputData();
|
||||
|
||||
if (inputData == null) {
|
||||
Log.w(TAG, "No input data. Failing.");
|
||||
throw new IllegalStateException("Expected a presentation object in input data.");
|
||||
}
|
||||
|
||||
byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION);
|
||||
if (presentationBytes == null) {
|
||||
Log.d(TAG, "No response data. Exiting.");
|
||||
return;
|
||||
}
|
||||
|
||||
ReceiptCredentialPresentation presentation = new ReceiptCredentialPresentation(presentationBytes);
|
||||
|
||||
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
|
||||
.redeemReceipt(presentation, false, false)
|
||||
.blockingGet();
|
||||
|
||||
if (response.getApplicationError().isPresent()) {
|
||||
Log.w(TAG, "Encountered a non-recoverable exception", response.getApplicationError().get());
|
||||
throw new IOException(response.getApplicationError().get());
|
||||
} else if (response.getExecutionError().isPresent()) {
|
||||
Log.w(TAG, "Encountered a retryable exception", response.getExecutionError().get());
|
||||
throw new RetryableException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof RetryableException;
|
||||
}
|
||||
|
||||
private final static class RetryableException extends Exception {
|
||||
}
|
||||
|
||||
public static class Factory implements Job.Factory<DonationReceiptRedemptionJob> {
|
||||
@Override
|
||||
public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new DonationReceiptRedemptionJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,9 @@ public final class JobManagerFactories {
|
||||
put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
|
||||
put(TypingSendJob.KEY, new TypingSendJob.Factory());
|
||||
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
|
||||
put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory());
|
||||
put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory());
|
||||
put(DonationReceiptRedemptionJob.KEY, new DonationReceiptRedemptionJob.Factory());
|
||||
|
||||
// Migrations
|
||||
put(AccountRecordMigrationJob.KEY, new AccountRecordMigrationJob.Factory());
|
||||
@@ -193,7 +196,7 @@ public final class JobManagerFactories {
|
||||
put(StickerLaunchMigrationJob.KEY, new StickerLaunchMigrationJob.Factory());
|
||||
put(StickerAdditionMigrationJob.KEY, new StickerAdditionMigrationJob.Factory());
|
||||
put(StickerDayByDayMigrationJob.KEY, new StickerDayByDayMigrationJob.Factory());
|
||||
put(StickerMyDailyLifeMigrationJob.KEY, new StickerMyDailyLifeMigrationJob.Factory());
|
||||
put(StickerMyDailyLifeMigrationJob.KEY, new StickerMyDailyLifeMigrationJob.Factory());
|
||||
put(StorageCapabilityMigrationJob.KEY, new StorageCapabilityMigrationJob.Factory());
|
||||
put(StorageServiceMigrationJob.KEY, new StorageServiceMigrationJob.Factory());
|
||||
put(TrimByLengthSettingsMigrationJob.KEY, new TrimByLengthSettingsMigrationJob.Factory());
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.badges.Badges;
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -176,25 +177,7 @@ public class RefreshOwnProfileJob extends BaseJob {
|
||||
|
||||
DatabaseFactory.getRecipientDatabase(context)
|
||||
.setBadges(Recipient.self().getId(),
|
||||
badges.stream().map(RefreshOwnProfileJob::adaptFromServiceBadge).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
private static Badge adaptFromServiceBadge(@NonNull SignalServiceProfile.Badge serviceBadge) {
|
||||
Pair<Uri, String> uriAndDensity = RetrieveProfileJob.getBestBadgeImageUriForDevice(serviceBadge);
|
||||
return new Badge(
|
||||
serviceBadge.getId(),
|
||||
Badge.Category.Companion.fromCode(serviceBadge.getCategory()),
|
||||
serviceBadge.getName(),
|
||||
serviceBadge.getDescription(),
|
||||
uriAndDensity.first(),
|
||||
uriAndDensity.second(),
|
||||
getTimestamp(serviceBadge.getExpiration()),
|
||||
serviceBadge.isVisible()
|
||||
);
|
||||
}
|
||||
|
||||
private static long getTimestamp(@NonNull BigDecimal bigDecimal) {
|
||||
return new Timestamp(bigDecimal.longValue() * 1000).getTime();
|
||||
badges.stream().map(Badges::fromServiceBadge).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<RefreshOwnProfileJob> {
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.zkgroup.profiles.ProfileKeyCredential;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.badges.Badges;
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -354,47 +355,7 @@ public class RetrieveProfileJob extends BaseJob {
|
||||
|
||||
DatabaseFactory.getRecipientDatabase(context)
|
||||
.setBadges(recipient.getId(),
|
||||
badges.stream().map(RetrieveProfileJob::adaptFromServiceBadge).collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
|
||||
private static Badge adaptFromServiceBadge(@NonNull SignalServiceProfile.Badge serviceBadge) {
|
||||
Pair<Uri, String> uriAndDensity = RetrieveProfileJob.getBestBadgeImageUriForDevice(serviceBadge);
|
||||
return new Badge(
|
||||
serviceBadge.getId(),
|
||||
Badge.Category.Companion.fromCode(serviceBadge.getCategory()),
|
||||
serviceBadge.getName(),
|
||||
serviceBadge.getDescription(),
|
||||
uriAndDensity.first(),
|
||||
uriAndDensity.second(),
|
||||
0L,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public static @NonNull Pair<Uri, String> getBestBadgeImageUriForDevice(@NonNull SignalServiceProfile.Badge serviceBadge) {
|
||||
String bestDensity = ScreenDensity.getBestDensityBucketForDevice();
|
||||
|
||||
switch (bestDensity) {
|
||||
case "ldpi":
|
||||
return new Pair<>(getBadgeImageUri(serviceBadge.getLdpiUri()), "ldpi");
|
||||
case "mdpi":
|
||||
return new Pair<>(getBadgeImageUri(serviceBadge.getMdpiUri()), "mdpi");
|
||||
case "hdpi":
|
||||
return new Pair<>(getBadgeImageUri(serviceBadge.getHdpiUri()), "hdpi");
|
||||
case "xxhdpi":
|
||||
return new Pair<>(getBadgeImageUri(serviceBadge.getXxhdpiUri()), "xxhdpi");
|
||||
case "xxxhdpi":
|
||||
return new Pair<>(getBadgeImageUri(serviceBadge.getXxxhdpiUri()), "xxxhdpi");
|
||||
default:
|
||||
return new Pair<>(getBadgeImageUri(serviceBadge.getXhdpiUri()), "xdpi");
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull Uri getBadgeImageUri(@NonNull String densityPath) {
|
||||
return Uri.parse(BuildConfig.BADGE_STATIC_ROOT).buildUpon()
|
||||
.appendPath(densityPath)
|
||||
.build();
|
||||
badges.stream().map(Badges::fromServiceBadge).collect(java.util.stream.Collectors.toList()));
|
||||
}
|
||||
|
||||
private void setProfileKeyCredential(@NonNull Recipient recipient,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber;
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Job that, once there is a valid local subscriber id, should be run every 3 days
|
||||
* to ensure that a user's subscription does not lapse.
|
||||
*/
|
||||
public class SubscriptionKeepAliveJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "SubscriptionKeepAliveJob";
|
||||
|
||||
private static final String TAG = Log.tag(SubscriptionKeepAliveJob.class);
|
||||
private static final long JOB_TIMEOUT = TimeUnit.DAYS.toMillis(3);
|
||||
|
||||
public SubscriptionKeepAliveJob() {
|
||||
this(new Parameters.Builder()
|
||||
.setQueue(KEY)
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxInstancesForQueue(1)
|
||||
.setLifespan(JOB_TIMEOUT)
|
||||
.build());
|
||||
}
|
||||
|
||||
private SubscriptionKeepAliveJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
Subscriber subscriber = SignalStore.donationsValues().getSubscriber();
|
||||
if (subscriber == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
|
||||
.putSubscription(subscriber.getSubscriberId())
|
||||
.blockingGet();
|
||||
|
||||
if (!response.getResult().isPresent()) {
|
||||
if (response.getStatus() == 403) {
|
||||
Log.w(TAG, "Response code 403, possibly corrupted subscription id.");
|
||||
// TODO [alex] - Probably need some UX around this, or some kind of protocol.
|
||||
}
|
||||
|
||||
throw new IOException("Failed to ping subscription service.");
|
||||
}
|
||||
|
||||
ServiceResponse<ActiveSubscription> activeSubscriptionResponse = ApplicationDependencies.getDonationsService()
|
||||
.getSubscription(subscriber.getSubscriberId())
|
||||
.blockingGet();
|
||||
|
||||
if (!response.getResult().isPresent()) {
|
||||
throw new IOException("Failed to perform active subscription check");
|
||||
}
|
||||
|
||||
ActiveSubscription activeSubscription = activeSubscriptionResponse.getResult().get();
|
||||
if (activeSubscription.getActiveSubscription() == null || !activeSubscription.getActiveSubscription().isActive()) {
|
||||
Log.i(TAG, "User does not have an active subscription. Exiting.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSubscription.getActiveSubscription().getEndOfCurrentPeriod() > SignalStore.donationsValues().getLastEndOfPeriod()) {
|
||||
Log.i(TAG, "Last end of period change. Requesting receipt refresh.");
|
||||
SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static class Factory implements Job.Factory<SubscriptionKeepAliveJob> {
|
||||
@Override
|
||||
public @NonNull SubscriptionKeepAliveJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new SubscriptionKeepAliveJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredential;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredentialRequestContext;
|
||||
import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
|
||||
import org.signal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber;
|
||||
import org.thoughtcrime.securesms.subscription.SubscriptionNotification;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
|
||||
* we get a response.
|
||||
*/
|
||||
public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
||||
|
||||
private static final String TAG = Log.tag(SubscriptionReceiptRequestResponseJob.class);
|
||||
|
||||
public static final String KEY = "SubscriptionReceiptCredentialsSubmissionJob";
|
||||
|
||||
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
|
||||
private static final String DATA_SUBSCRIBER_ID = "data.subscriber.id";
|
||||
|
||||
private ReceiptCredentialRequestContext requestContext;
|
||||
|
||||
private final SubscriberId subscriberId;
|
||||
|
||||
static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId) {
|
||||
return new SubscriptionReceiptRequestResponseJob(
|
||||
new Parameters
|
||||
.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue("ReceiptRedemption")
|
||||
.setMaxInstancesForQueue(1)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(7))
|
||||
.build(),
|
||||
null,
|
||||
subscriberId
|
||||
);
|
||||
}
|
||||
|
||||
public static Pair<String, String> enqueueSubscriptionContinuation() {
|
||||
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
|
||||
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId());
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJob();
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(requestReceiptJob)
|
||||
.then(redeemReceiptJob)
|
||||
.enqueue();
|
||||
|
||||
return new Pair<>(requestReceiptJob.getId(), redeemReceiptJob.getId());
|
||||
}
|
||||
|
||||
private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters,
|
||||
@Nullable ReceiptCredentialRequestContext requestContext,
|
||||
@NonNull SubscriberId subscriberId)
|
||||
{
|
||||
super(parameters);
|
||||
this.requestContext = requestContext;
|
||||
this.subscriberId = subscriberId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
Data.Builder builder = new Data.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes());
|
||||
|
||||
if (requestContext != null) {
|
||||
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
SubscriptionNotification.VerificationFailed.INSTANCE.show(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
ActiveSubscription.Subscription subscription = getLatestSubscriptionInformation();
|
||||
if (subscription == null || !subscription.isActive()) {
|
||||
Log.d(TAG, "User does not have an active subscription. Exiting.");
|
||||
return;
|
||||
} else {
|
||||
Log.i(TAG, "Recording end of period from active subscription.");
|
||||
SignalStore.donationsValues().setLastEndOfPeriod(subscription.getEndOfCurrentPeriod());
|
||||
}
|
||||
|
||||
if (requestContext == null) {
|
||||
SecureRandom secureRandom = new SecureRandom();
|
||||
byte[] randomBytes = new byte[ReceiptSerial.SIZE];
|
||||
|
||||
secureRandom.nextBytes(randomBytes);
|
||||
|
||||
ReceiptSerial receiptSerial = new ReceiptSerial(randomBytes);
|
||||
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
|
||||
|
||||
requestContext = operations.createReceiptCredentialRequestContext(secureRandom, receiptSerial);
|
||||
}
|
||||
|
||||
ServiceResponse<ReceiptCredentialResponse> response = ApplicationDependencies.getDonationsService()
|
||||
.submitReceiptCredentialRequest(subscriberId, requestContext.getRequest())
|
||||
.blockingGet();
|
||||
|
||||
if (response.getApplicationError().isPresent()) {
|
||||
if (response.getStatus() == 204) {
|
||||
Log.w(TAG, "User does not have receipts available to exchange. Exiting.", response.getApplicationError().get());
|
||||
} else {
|
||||
Log.w(TAG, "Encountered a permanent failure: " + response.getStatus(), response.getApplicationError().get());
|
||||
throw new Exception(response.getApplicationError().get());
|
||||
}
|
||||
} else if (response.getResult().isPresent()) {
|
||||
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
|
||||
|
||||
if (!isCredentialValid(subscription, receiptCredential)) {
|
||||
throw new IOException("Could not validate receipt credential");
|
||||
}
|
||||
|
||||
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
|
||||
setOutputData(new Data.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
|
||||
receiptCredentialPresentation.serialize())
|
||||
.build());
|
||||
} else {
|
||||
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orNull());
|
||||
throw new RetryableException();
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable ActiveSubscription.Subscription getLatestSubscriptionInformation() throws Exception {
|
||||
ServiceResponse<ActiveSubscription> activeSubscription = ApplicationDependencies.getDonationsService()
|
||||
.getSubscription(subscriberId)
|
||||
.blockingGet();
|
||||
|
||||
if (activeSubscription.getResult().isPresent()) {
|
||||
return activeSubscription.getResult().get().getActiveSubscription();
|
||||
} else if (activeSubscription.getApplicationError().isPresent()) {
|
||||
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.");
|
||||
throw new IOException(activeSubscription.getApplicationError().get());
|
||||
} else {
|
||||
throw new RetryableException();
|
||||
}
|
||||
}
|
||||
|
||||
private ReceiptCredentialPresentation getReceiptCredentialPresentation(@NonNull ReceiptCredential receiptCredential) throws RetryableException {
|
||||
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
|
||||
|
||||
try {
|
||||
return operations.createReceiptCredentialPresentation(receiptCredential);
|
||||
} catch (VerificationFailedException e) {
|
||||
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e);
|
||||
requestContext = null;
|
||||
throw new RetryableException();
|
||||
}
|
||||
}
|
||||
|
||||
private ReceiptCredential getReceiptCredential(@NonNull ReceiptCredentialResponse response) throws RetryableException {
|
||||
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
|
||||
|
||||
try {
|
||||
return operations.receiveReceiptCredential(requestContext, response);
|
||||
} catch (VerificationFailedException e) {
|
||||
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e);
|
||||
requestContext = null;
|
||||
throw new RetryableException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the generated Receipt Credential has the following characteristics
|
||||
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
|
||||
* - expiration time should have the following characteristics:
|
||||
* - expiration_time mod 86400 == 0
|
||||
* - expiration_time is between now and 60 days from now
|
||||
*/
|
||||
private boolean isCredentialValid(@NonNull ActiveSubscription.Subscription subscription, @NonNull ReceiptCredential receiptCredential) {
|
||||
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
||||
long monthFromNow = now + TimeUnit.DAYS.toSeconds(60);
|
||||
boolean isSameLevel = subscription.getLevel() == receiptCredential.getReceiptLevel();
|
||||
boolean isExpirationAfterSub = subscription.getEndOfCurrentPeriod() < receiptCredential.getReceiptExpirationTime();
|
||||
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
|
||||
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
|
||||
boolean isExpirationWithinAMonth = receiptCredential.getReceiptExpirationTime() < monthFromNow;
|
||||
|
||||
return isSameLevel && isExpirationAfterSub && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinAMonth;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof RetryableException;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
final static class RetryableException extends Exception {
|
||||
}
|
||||
|
||||
public static class Factory implements Job.Factory<SubscriptionReceiptRequestResponseJob> {
|
||||
@Override
|
||||
public @NonNull SubscriptionReceiptRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
SubscriberId subscriberId = SubscriberId.fromBytes(data.getStringAsBlob(DATA_SUBSCRIBER_ID));
|
||||
|
||||
try {
|
||||
if (data.hasString(DATA_REQUEST_BYTES)) {
|
||||
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
|
||||
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
|
||||
|
||||
return new SubscriptionReceiptRequestResponseJob(parameters, requestContext, subscriberId);
|
||||
} else {
|
||||
return new SubscriptionReceiptRequestResponseJob(parameters, null, subscriberId);
|
||||
}
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,42 @@ import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
internal class DonationsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
|
||||
companion object {
|
||||
private const val KEY_CURRENCY_CODE = "donation.currency.code"
|
||||
private const val KEY_SUBSCRIPTION_CURRENCY_CODE = "donation.currency.code"
|
||||
private const val KEY_CURRENCY_CODE_BOOST = "donation.currency.code.boost"
|
||||
private const val KEY_SUBSCRIBER_ID_PREFIX = "donation.subscriber.id."
|
||||
private const val KEY_IDEMPOTENCY = "donation.idempotency.key"
|
||||
private const val KEY_LEVEL = "donation.level"
|
||||
private const val KEY_LAST_KEEP_ALIVE_LAUNCH = "donation.last.successful.ping"
|
||||
private const val KEY_LAST_END_OF_PERIOD = "donation.last.end.of.period"
|
||||
}
|
||||
|
||||
override fun onFirstEverAppLaunch() = Unit
|
||||
|
||||
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(KEY_CURRENCY_CODE)
|
||||
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(KEY_SUBSCRIPTION_CURRENCY_CODE, KEY_LAST_KEEP_ALIVE_LAUNCH)
|
||||
|
||||
private val currencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getCurrency()) }
|
||||
val observableCurrency: Observable<Currency> by lazy { currencyPublisher }
|
||||
private val subscriptionCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) }
|
||||
val observableSubscriptionCurrency: Observable<Currency> by lazy { subscriptionCurrencyPublisher }
|
||||
|
||||
fun getCurrency(): Currency {
|
||||
val currencyCode = getString(KEY_CURRENCY_CODE, null)
|
||||
private val boostCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getBoostCurrency()) }
|
||||
val observableBoostCurrency: Observable<Currency> by lazy { boostCurrencyPublisher }
|
||||
|
||||
private val levelUpdateOperationPublisher: Subject<Optional<LevelUpdateOperation>> by lazy { BehaviorSubject.createDefault(Optional.fromNullable(getLevelOperation())) }
|
||||
val levelUpdateOperationObservable: Observable<Optional<LevelUpdateOperation>> by lazy { levelUpdateOperationPublisher }
|
||||
|
||||
fun getSubscriptionCurrency(): Currency {
|
||||
val currencyCode = getString(KEY_SUBSCRIPTION_CURRENCY_CODE, null)
|
||||
val currency: Currency? = if (currencyCode == null) {
|
||||
val localeCurrency = CurrencyUtil.getCurrencyByLocale(Locale.getDefault())
|
||||
if (localeCurrency == null) {
|
||||
@@ -48,8 +65,105 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurrency(currency: Currency) {
|
||||
putString(KEY_CURRENCY_CODE, currency.currencyCode)
|
||||
currencyPublisher.onNext(currency)
|
||||
fun getBoostCurrency(): Currency {
|
||||
val boostCurrencyCode = getString(KEY_CURRENCY_CODE_BOOST, null)
|
||||
return if (boostCurrencyCode == null) {
|
||||
val currency = getSubscriptionCurrency()
|
||||
setBoostCurrency(currency)
|
||||
currency
|
||||
} else {
|
||||
Currency.getInstance(boostCurrencyCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionCurrency(currency: Currency) {
|
||||
putString(KEY_SUBSCRIPTION_CURRENCY_CODE, currency.currencyCode)
|
||||
subscriptionCurrencyPublisher.onNext(currency)
|
||||
}
|
||||
|
||||
fun setBoostCurrency(currency: Currency) {
|
||||
putString(KEY_CURRENCY_CODE_BOOST, currency.currencyCode)
|
||||
boostCurrencyPublisher.onNext(currency)
|
||||
}
|
||||
|
||||
fun getSubscriber(): Subscriber? {
|
||||
val currencyCode = getSubscriptionCurrency().currencyCode
|
||||
val subscriberIdBytes = getBlob("$KEY_SUBSCRIBER_ID_PREFIX$currencyCode", null)
|
||||
|
||||
return if (subscriberIdBytes == null) {
|
||||
null
|
||||
} else {
|
||||
Subscriber(SubscriberId.fromBytes(subscriberIdBytes), currencyCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun requireSubscriber(): Subscriber {
|
||||
return getSubscriber() ?: throw Exception("Subscriber ID is not set.")
|
||||
}
|
||||
|
||||
fun setSubscriber(subscriber: Subscriber) {
|
||||
val currencyCode = subscriber.currencyCode
|
||||
putBlob("$KEY_SUBSCRIBER_ID_PREFIX$currencyCode", subscriber.subscriberId.bytes)
|
||||
}
|
||||
|
||||
fun getLevelOperation(): LevelUpdateOperation? {
|
||||
val level = getString(KEY_LEVEL, null)
|
||||
val idempotencyKey = getIdempotencyKey()
|
||||
|
||||
return if (level == null || idempotencyKey == null) {
|
||||
null
|
||||
} else {
|
||||
LevelUpdateOperation(idempotencyKey, level)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLevelOperation(levelUpdateOperation: LevelUpdateOperation) {
|
||||
putString(KEY_LEVEL, levelUpdateOperation.level)
|
||||
setIdempotencyKey(levelUpdateOperation.idempotencyKey)
|
||||
dispatchLevelOperation()
|
||||
}
|
||||
|
||||
fun clearLevelOperation(levelUpdateOperation: LevelUpdateOperation): Boolean {
|
||||
val currentKey = getIdempotencyKey()
|
||||
return if (currentKey == levelUpdateOperation.idempotencyKey) {
|
||||
clearLevelOperation()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearLevelOperation() {
|
||||
remove(KEY_IDEMPOTENCY)
|
||||
remove(KEY_LEVEL)
|
||||
dispatchLevelOperation()
|
||||
}
|
||||
|
||||
private fun getIdempotencyKey(): IdempotencyKey? {
|
||||
return getBlob(KEY_IDEMPOTENCY, null)?.let { IdempotencyKey.fromBytes(it) }
|
||||
}
|
||||
|
||||
private fun setIdempotencyKey(key: IdempotencyKey) {
|
||||
putBlob(KEY_IDEMPOTENCY, key.bytes)
|
||||
}
|
||||
|
||||
fun getLastKeepAliveLaunchTime(): Long {
|
||||
return getLong(KEY_LAST_KEEP_ALIVE_LAUNCH, 0L)
|
||||
}
|
||||
|
||||
fun setLastKeepAliveLaunchTime(timestamp: Long) {
|
||||
putLong(KEY_LAST_KEEP_ALIVE_LAUNCH, timestamp)
|
||||
}
|
||||
|
||||
fun getLastEndOfPeriod(): Long {
|
||||
return getLong(KEY_LAST_END_OF_PERIOD, 0L)
|
||||
}
|
||||
|
||||
fun setLastEndOfPeriod(timestamp: Long) {
|
||||
putLong(KEY_LAST_END_OF_PERIOD, timestamp)
|
||||
}
|
||||
|
||||
private fun dispatchLevelOperation() {
|
||||
levelUpdateOperationPublisher.onNext(Optional.fromNullable(getLevelOperation()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public final class NotificationIds {
|
||||
public static final int LEGACY_SQLCIPHER_MIGRATION = 494949;
|
||||
public static final int USER_NOTIFICATION_MIGRATION = 525600;
|
||||
public static final int DEVICE_TRANSFER = 625420;
|
||||
public static final int SUBSCRIPTION_VERIFY_FAILED = 630001;
|
||||
|
||||
private NotificationIds() { }
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ public class Recipient {
|
||||
this.systemContactName = details.systemContactName;
|
||||
this.extras = details.extras;
|
||||
this.hasGroupsInCommon = details.hasGroupsInCommon;
|
||||
this.badges = FeatureFlags.donorBadges() ? details.badges : Collections.emptyList();
|
||||
this.badges = details.badges;
|
||||
}
|
||||
|
||||
public @NonNull RecipientId getId() {
|
||||
@@ -1028,14 +1028,14 @@ public class Recipient {
|
||||
}
|
||||
|
||||
public @NonNull List<Badge> getBadges() {
|
||||
return badges;
|
||||
return FeatureFlags.donorBadges() ? badges : Collections.emptyList();
|
||||
}
|
||||
|
||||
public @Nullable Badge getFeaturedBadge() {
|
||||
if (badges.isEmpty()) {
|
||||
if (getBadges().isEmpty()) {
|
||||
return null;
|
||||
} else {
|
||||
return badges.get(0);
|
||||
return getBadges().get(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.service;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Manages the scheduling of jobs for keeping a subscription id alive.
|
||||
*/
|
||||
public class SubscriberIdKeepAliveListener extends PersistentAlarmManagerListener {
|
||||
|
||||
private static final long INTERVAL = TimeUnit.DAYS.toMillis(3);
|
||||
|
||||
@Override
|
||||
protected long getNextScheduledExecutionTime(Context context) {
|
||||
return SignalStore.donationsValues().getLastKeepAliveLaunchTime() + INTERVAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected long onAlarm(Context context, long scheduledTime) {
|
||||
if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
ApplicationDependencies.getJobManager().add(new SubscriptionKeepAliveJob());
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
SignalStore.donationsValues().setLastKeepAliveLaunchTime(now);
|
||||
|
||||
return now + INTERVAL;
|
||||
}
|
||||
|
||||
public static void schedule(Context context) {
|
||||
new SubscriberIdKeepAliveListener().onReceive(context, new Intent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.subscription
|
||||
|
||||
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
|
||||
|
||||
/**
|
||||
* Binds a Subscription level update with an idempotency key.
|
||||
*
|
||||
* We are to use the same idempotency key whenever we want to retry updating to a particular level.
|
||||
*/
|
||||
data class LevelUpdateOperation(
|
||||
val idempotencyKey: IdempotencyKey,
|
||||
val level: String
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.subscription
|
||||
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
|
||||
data class Subscriber(
|
||||
val subscriberId: SubscriberId,
|
||||
val currencyCode: String
|
||||
)
|
||||
@@ -23,10 +23,9 @@ data class Subscription(
|
||||
val title: String,
|
||||
val badge: Badge,
|
||||
val price: FiatMoney,
|
||||
val level: Int,
|
||||
) {
|
||||
|
||||
val renewalTimestamp = badge.expirationTimestamp
|
||||
|
||||
companion object {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_preference))
|
||||
@@ -37,8 +36,10 @@ data class Subscription(
|
||||
val subscription: Subscription,
|
||||
val isSelected: Boolean,
|
||||
val isActive: Boolean,
|
||||
val willRenew: Boolean,
|
||||
override val isEnabled: Boolean,
|
||||
val onClick: () -> Unit
|
||||
val onClick: () -> Unit,
|
||||
val renewalTimestamp: Long
|
||||
) : PreferenceModel<Model>(isEnabled = isEnabled) {
|
||||
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
@@ -49,7 +50,17 @@ data class Subscription(
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
newItem.subscription == subscription &&
|
||||
newItem.isSelected == isSelected &&
|
||||
newItem.isActive == isActive
|
||||
newItem.isActive == isActive &&
|
||||
newItem.renewalTimestamp == renewalTimestamp &&
|
||||
newItem.willRenew == willRenew
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: Model): Any? {
|
||||
return if (newItem.subscription.badge == subscription.badge) {
|
||||
Unit
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +76,13 @@ data class Subscription(
|
||||
itemView.isEnabled = model.isEnabled
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
itemView.isSelected = model.isSelected
|
||||
badge.setBadge(model.subscription.badge)
|
||||
|
||||
if (payload.isEmpty()) {
|
||||
badge.setBadge(model.subscription.badge)
|
||||
}
|
||||
|
||||
title.text = model.subscription.title
|
||||
tagline.text = model.subscription.id
|
||||
tagline.text = context.getString(R.string.Subscription__earn_a_s_badge, model.subscription.badge.name)
|
||||
|
||||
val formattedPrice = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
@@ -75,11 +90,17 @@ data class Subscription(
|
||||
FiatMoneyUtil.formatOptions()
|
||||
)
|
||||
|
||||
if (model.isActive) {
|
||||
if (model.isActive && model.willRenew) {
|
||||
price.text = context.getString(
|
||||
R.string.Subscription__s_per_month_dot_renews_s,
|
||||
formattedPrice,
|
||||
DateUtils.formatDate(Locale.getDefault(), model.subscription.renewalTimestamp)
|
||||
DateUtils.formatDateWithYear(Locale.getDefault(), model.renewalTimestamp)
|
||||
)
|
||||
} else if (model.isActive) {
|
||||
price.text = context.getString(
|
||||
R.string.Subscription__s_per_month_dot_expires_s,
|
||||
formattedPrice,
|
||||
DateUtils.formatDateWithYear(Locale.getDefault(), model.renewalTimestamp)
|
||||
)
|
||||
} else {
|
||||
price.text = context.getString(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.subscription
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
|
||||
sealed class SubscriptionNotification {
|
||||
object VerificationFailed : SubscriptionNotification() {
|
||||
override fun show(context: Context) {
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.FAILURES)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.Subscription__verification_failed))
|
||||
.setContentText(context.getString(R.string.Subscription__please_contact_support_for_more_information))
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
null,
|
||||
context.getString(R.string.Subscription__contact_support),
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX),
|
||||
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_ONE_SHOT else 0
|
||||
)
|
||||
).build()
|
||||
)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat
|
||||
.from(context)
|
||||
.notify(NotificationIds.SUBSCRIPTION_VERIFY_FAILED, notification)
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun show(context: Context)
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import com.google.android.gms.wallet.WalletConstants
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
|
||||
object Environment {
|
||||
const val IS_STAGING: Boolean = BuildConfig.BUILD_ENVIRONMENT_TYPE == "Staging"
|
||||
|
||||
object Donations {
|
||||
val GOOGLE_PAY_CONFIGURATION = GooglePayApi.Configuration(
|
||||
walletEnvironment = if (IS_STAGING) WalletConstants.ENVIRONMENT_TEST else WalletConstants.ENVIRONMENT_PRODUCTION
|
||||
)
|
||||
|
||||
val STRIPE_CONFIGURATION = StripeApi.Configuration(
|
||||
publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,12 +397,17 @@ public final class FeatureFlags {
|
||||
return getBoolean(CHANGE_NUMBER_ENABLED, false);
|
||||
}
|
||||
|
||||
/** Whether or not to show donor badges in the UI. */
|
||||
/** Whether or not to show donor badges in the UI.
|
||||
*
|
||||
* WARNING: Donor Badges is an unfinished feature and should not be enabled in production builds.
|
||||
* Enabling this flag in a custom build can result in crashes and could result in your Google Pay
|
||||
* account being charged real money.
|
||||
*/
|
||||
public static boolean donorBadges() {
|
||||
if (Environment.IS_STAGING) {
|
||||
return true;
|
||||
} else {
|
||||
return getBoolean(DONOR_BADGES, false);
|
||||
return getBoolean(DONOR_BADGES, false ) || SignalStore.donationsValues().getSubscriber() != null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
@@ -100,7 +101,7 @@ public final class ProfileUtil {
|
||||
Optional<ProfileKey> profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey());
|
||||
|
||||
return Single.fromCallable(() -> toSignalServiceAddress(context, recipient))
|
||||
.flatMap(address -> profileService.getProfile(address, profileKey, unidentifiedAccess, requestType).map(p -> new Pair<>(recipient, p)))
|
||||
.flatMap(address -> profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, Locale.getDefault()).map(p -> new Pair<>(recipient, p)))
|
||||
.onErrorReturn(t -> new Pair<>(recipient, ServiceResponse.forUnknownError(t)));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -67,7 +68,7 @@ public class UsernameUtil {
|
||||
|
||||
try {
|
||||
Log.d(TAG, "No local user with this username. Searching remotely.");
|
||||
SignalServiceProfile profile = ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfileByUsername(username, Optional.absent());
|
||||
SignalServiceProfile profile = ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfileByUsername(username, Optional.absent(), Locale.getDefault());
|
||||
return Optional.fromNullable(profile.getUuid());
|
||||
} catch (IOException e) {
|
||||
return Optional.absent();
|
||||
|
||||
Reference in New Issue
Block a user