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:
Alex Hart
2021-10-21 16:39:02 -03:00
committed by Greyson Parrelli
parent d88999d6d4
commit c1820459b7
91 changed files with 2765 additions and 696 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
class DonationExceptions {
object TimedOutWaitingForTokenRedemption : Exception()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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());

View File

@@ -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();

View File

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

View File

@@ -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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();