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
+2 -1
View File
@@ -179,7 +179,7 @@ android {
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"unset\""
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\""
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
@@ -357,6 +357,7 @@ android {
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
}
}
@@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.SubscriberIdKeepAliveListener;
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -333,6 +334,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
LocalBackupListener.schedule(this);
RotateSenderCertificateListener.schedule(this);
MessageProcessReceiver.startOrUpdateAlarm(this);
SubscriberIdKeepAliveListener.schedule(this);
if (BuildConfig.PLAY_STORE_DISABLED) {
UpdateApkRefreshListener.schedule(this);
@@ -5,7 +5,6 @@ import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.res.use
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.badges.models.Badge
@@ -15,8 +14,6 @@ import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.visible
import java.lang.IllegalArgumentException
private val TAG = Log.tag(BadgeImageView::class.java)
class BadgeImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
@@ -1,19 +1,38 @@
package org.thoughtcrime.securesms.badges
import android.content.Context
import android.net.Uri
import androidx.recyclerview.widget.RecyclerView
import com.google.android.flexbox.AlignItems
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.flexbox.JustifyContent
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.util.ScreenDensity
import org.whispersystems.libsignal.util.Pair
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import java.math.BigDecimal
import java.sql.Timestamp
object Badges {
fun DSLConfiguration.displayBadges(context: Context, badges: List<Badge>, selectedBadge: Badge? = null) {
fun DSLConfiguration.displayBadges(
context: Context,
badges: List<Badge>,
selectedBadge: Badge? = null,
fadedBadgeId: String? = null
) {
badges
.map { Badge.Model(it, it == selectedBadge) }
.map {
Badge.Model(
badge = it,
isSelected = it == selectedBadge,
isFaded = it.id == fadedBadgeId
)
}
.forEach { customPref(it) }
val perRow = context.resources.getInteger(R.integer.badge_columns)
@@ -32,4 +51,41 @@ object Badges {
return layoutManager
}
private fun getBadgeImageUri(densityPath: String): Uri {
return Uri.parse(BuildConfig.BADGE_STATIC_ROOT).buildUpon()
.appendPath(densityPath)
.build()
}
private fun getBestBadgeImageUriForDevice(serviceBadge: SignalServiceProfile.Badge): Pair<Uri, String> {
val bestDensity = ScreenDensity.getBestDensityBucketForDevice()
return when (bestDensity) {
"ldpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[0]), "ldpi")
"mdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[1]), "mdpi")
"hdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[2]), "hdpi")
"xxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[4]), "xxhdpi")
"xxxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[5]), "xxxhdpi")
else -> Pair(getBadgeImageUri(serviceBadge.sprites6[3]), "xdpi")
}
}
private fun getTimestamp(bigDecimal: BigDecimal): Long {
return Timestamp(bigDecimal.toLong() * 1000).time
}
@JvmStatic
fun fromServiceBadge(serviceBadge: SignalServiceProfile.Badge): Badge {
val uriAndDensity: Pair<Uri, String> = getBestBadgeImageUriForDevice(serviceBadge)
return Badge(
serviceBadge.id,
fromCode(serviceBadge.category),
serviceBadge.name,
serviceBadge.description,
uriAndDensity.first(),
uriAndDensity.second(),
serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
serviceBadge.isVisible
)
}
}
@@ -3,10 +3,8 @@ package org.thoughtcrime.securesms.badges.glide
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import androidx.annotation.VisibleForTesting
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import java.lang.IllegalArgumentException
import java.security.MessageDigest
/**
@@ -19,7 +17,7 @@ class BadgeSpriteTransformation(
) : BitmapTransformation() {
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update("BadgeSpriteTransformation(${size.code},$density,$isDarkTheme)".toByteArray(CHARSET))
messageDigest.update("BadgeSpriteTransformation(${size.code},$density,$isDarkTheme).$VERSION".toByteArray(CHARSET))
}
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
@@ -33,11 +31,51 @@ class BadgeSpriteTransformation(
return outBitmap
}
enum class Size(val code: String) {
SMALL("small"),
MEDIUM("medium"),
LARGE("large"),
XLARGE("xlarge");
enum class Size(val code: String, val frameMap: Map<Density, FrameSet>) {
SMALL(
"small",
mapOf(
Density.LDPI to FrameSet(Frame(124, 1, 13, 13), Frame(145, 31, 13, 13)),
Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
Density.HDPI to FrameSet(Frame(244, 1, 25, 25), Frame(283, 58, 25, 25)),
Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
)
),
MEDIUM(
"medium",
mapOf(
Density.LDPI to FrameSet(Frame(124, 16, 19, 19), Frame(160, 31, 19, 19)),
Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
Density.HDPI to FrameSet(Frame(244, 28, 37, 37), Frame(310, 58, 37, 37)),
Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
)
),
LARGE(
"large",
mapOf(
Density.LDPI to FrameSet(Frame(145, 1, 28, 28), Frame(124, 46, 28, 28)),
Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
Density.HDPI to FrameSet(Frame(283, 1, 55, 55), Frame(244, 85, 55, 55)),
Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
)
),
XLARGE(
"xlarge",
mapOf(
Density.LDPI to FrameSet(Frame(1, 1, 121, 121), Frame(1, 1, 121, 121)),
Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
Density.HDPI to FrameSet(Frame(1, 1, 241, 241), Frame(1, 1, 241, 241)),
Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
)
);
companion object {
fun fromInteger(integer: Int): Size {
@@ -52,55 +90,42 @@ class BadgeSpriteTransformation(
}
}
enum class Density(val density: String) {
LDPI("ldpi"),
MDPI("mdpi"),
HDPI("hdpi"),
XHDPI("xhdpi"),
XXHDPI("xxhdpi"),
XXXHDPI("xxxhdpi")
}
data class FrameSet(val light: Frame, val dark: Frame)
data class Frame(
val x: Int,
val y: Int,
val width: Int,
val height: Int
) {
fun toBounds(): Rect {
return Rect(x, y, x + width, y + height)
}
}
companion object {
private const val PADDING = 1
private const val VERSION = 1
@VisibleForTesting
fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
val scaleFactor: Int = when (density) {
"ldpi" -> 75
"mdpi" -> 100
"hdpi" -> 150
"xhdpi" -> 200
"xxhdpi" -> 300
"xxxhdpi" -> 400
else -> throw IllegalArgumentException("Unexpected density $density")
}
private fun getDensity(density: String): Density {
return Density.values().first { it.density == density }
}
val smallLength = 8 * scaleFactor / 100
val mediumLength = 12 * scaleFactor / 100
val largeLength = 18 * scaleFactor / 100
val xlargeLength = 80 * scaleFactor / 100
private fun getFrame(size: Size, density: Density, isDarkTheme: Boolean): Frame {
val frameSet: FrameSet = size.frameMap[density]!!
return if (isDarkTheme) frameSet.dark else frameSet.light
}
val sideLength: Int = when (size) {
Size.SMALL -> smallLength
Size.MEDIUM -> mediumLength
Size.LARGE -> largeLength
Size.XLARGE -> xlargeLength
}
val lightOffset: Int = when (size) {
Size.LARGE -> PADDING
Size.MEDIUM -> (largeLength + PADDING * 2) * 2 + PADDING
Size.SMALL -> (largeLength + PADDING * 2) * 2 + (mediumLength + PADDING * 2) * 2 + PADDING
Size.XLARGE -> (largeLength + PADDING * 2) * 2 + (mediumLength + PADDING * 2) * 2 + (smallLength + PADDING * 2) * 2 + PADDING
}
val darkOffset = if (isDarkTheme) {
when (size) {
Size.XLARGE -> 0
else -> sideLength + PADDING * 2
}
} else {
0
}
return Rect(
lightOffset + darkOffset,
PADDING,
lightOffset + darkOffset + sideLength,
sideLength + PADDING
)
private fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
return getFrame(size, getDensity(density), isDarkTheme).toBounds()
}
}
}
@@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ThemeUtil
import java.security.MessageDigest
typealias OnBadgeClicked = (Badge, Boolean) -> Unit
typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
/**
* A Badge that can be collected and displayed by a user.
@@ -70,14 +70,18 @@ data class Badge(
class Model(
val badge: Badge,
val isSelected: Boolean = false
val isSelected: Boolean = false,
val isFaded: Boolean = false
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.badge.id == badge.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge && isSelected == newItem.isSelected
return super.areContentsTheSame(newItem) &&
badge == newItem.badge &&
isSelected == newItem.isSelected &&
isFaded == newItem.isFaded
}
override fun getChangePayload(newItem: Model): Any? {
@@ -103,7 +107,7 @@ data class Badge(
override fun bind(model: Model) {
itemView.setOnClickListener {
onBadgeClicked(model.badge, model.isSelected)
onBadgeClicked(model.badge, model.isSelected, model.isFaded)
}
checkAnimator?.cancel()
@@ -117,7 +121,7 @@ data class Badge(
return
}
badge.alpha = if (model.badge.isExpired()) 0.5f else 1f
badge.alpha = if (model.badge.isExpired() || model.isFaded) 0.5f else 1f
GlideApp.with(badge)
.load(model.badge)
@@ -162,26 +166,4 @@ data class Badge(
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
}
}
@Parcelize
data class ImageSet(
val ldpi: String,
val mdpi: String,
val hdpi: String,
val xhdpi: String,
val xxhdpi: String,
val xxxhdpi: String
) : Parcelable {
fun getByDensity(density: String): String {
return when (density) {
"ldpi" -> ldpi
"mdpi" -> mdpi
"hdpi" -> hdpi
"xhdpi" -> xhdpi
"xxhdpi" -> xxhdpi
"xxxhdpi" -> xxxhdpi
else -> xhdpi
}
}
}
}
@@ -22,22 +22,30 @@ object BadgePreview {
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.badge?.id == badge?.id
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
override fun getChangePayload(newItem: Model): Any? {
return Unit
}
}
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
return newItem.badge?.id == badge?.id
return true
}
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
override fun getChangePayload(newItem: SubscriptionModel): Any? {
return Unit
}
}
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
@@ -46,8 +54,11 @@ object BadgePreview {
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
override fun bind(model: T) {
avatar.setRecipient(Recipient.self())
avatar.disableQuickContact()
if (payload.isEmpty()) {
avatar.setRecipient(Recipient.self())
avatar.disableQuickContact()
}
badge.setBadge(model.badge)
}
}
@@ -51,7 +51,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, isSelected ->
Badge.register(adapter) { badge, isSelected, _ ->
if (!isSelected) {
viewModel.setSelectedBadge(badge)
}
@@ -69,8 +69,16 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
}
}
var hasBoundPreview = false
viewModel.state.observe(viewLifecycleOwner) { state ->
save.isEnabled = state.stage == SelectFeaturedBadgeState.Stage.READY
if (hasBoundPreview) {
previewViewHolder.setPayload(listOf(Unit))
} else {
hasBoundPreview = true
}
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
adapter.submitList(getConfiguration(state).toMappingModelList())
}
@@ -13,7 +13,9 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.LifecycleDisposable
@@ -26,11 +28,15 @@ class BadgesOverviewFragment : DSLSettingsFragment(
) {
private val lifecycleDisposable = LifecycleDisposable()
private val viewModel: BadgesOverviewViewModel by viewModels(factoryProducer = { BadgesOverviewViewModel.Factory(BadgeRepository(requireContext())) })
private val viewModel: BadgesOverviewViewModel by viewModels(
factoryProducer = {
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, _ ->
if (badge.isExpired()) {
Badge.register(adapter) { badge, _, isFaded ->
if (badge.isExpired() || isFaded) {
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
} else {
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
@@ -56,7 +62,11 @@ class BadgesOverviewFragment : DSLSettingsFragment(
return configure {
sectionHeaderPref(R.string.BadgesOverviewFragment__my_badges)
displayBadges(requireContext(), state.allUnlockedBadges)
displayBadges(
context = requireContext(),
badges = state.allUnlockedBadges,
fadedBadgeId = state.fadedBadgeId
)
switchPref(
title = DSLSettingsText.from(R.string.BadgesOverviewFragment__display_badges_on_profile),
@@ -7,6 +7,7 @@ data class BadgesOverviewState(
val allUnlockedBadges: List<Badge> = listOf(),
val featuredBadge: Badge? = null,
val displayBadgesOnProfile: Boolean = false,
val fadedBadgeId: String? = null
) {
val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() }
@@ -5,17 +5,25 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : ViewModel() {
class BadgesOverviewViewModel(
private val badgeRepository: BadgeRepository,
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModel() {
private val store = Store(BadgesOverviewState())
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
@@ -33,6 +41,19 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
featuredBadge = recipient.featuredBadge
)
}
disposables += Single.zip(
subscriptionsRepository.getActiveSubscription(),
subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency())
) { active, all ->
if (!active.isActive && active.activeSubscription?.willCancelAtPeriodEnd() == true) {
Optional.fromNullable<String>(all.firstOrNull { it.level == active.activeSubscription?.level }?.badge?.id)
} else {
Optional.absent()
}
}.subscribeBy { badgeId ->
store.update { it.copy(fadedBadgeId = badgeId.orNull()) }
}
}
fun setDisplayBadgesOnProfile(displayBadgesOnProfile: Boolean) {
@@ -53,9 +74,12 @@ class BadgesOverviewViewModel(private val badgeRepository: BadgeRepository) : Vi
disposables.clear()
}
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
class Factory(
private val badgeRepository: BadgeRepository,
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository)))
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
}
}
}
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.Subscript
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.BoostViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.subscribe.SubscribeViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -32,7 +33,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
private val donationRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
private val subscribeViewModel: SubscribeViewModel by viewModels(
factoryProducer = {
SubscribeViewModel.Factory(SubscriptionsRepository(), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
SubscribeViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()), donationRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
}
)
@@ -43,7 +44,6 @@ class AppSettingsActivity : DSLSettingsActivity() {
)
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
warmDonationViewModels()
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
@@ -63,6 +63,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToSubscriptions()
}
}
@@ -134,6 +135,9 @@ class AppSettingsActivity : DSLSettingsActivity() {
@JvmStatic
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
@JvmStatic
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.SUBSCRIPTIONS)
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
@@ -154,7 +158,8 @@ class AppSettingsActivity : DSLSettingsActivity() {
HELP(2),
PROXY(3),
NOTIFICATIONS(4),
CHANGE_NUMBER(5);
CHANGE_NUMBER(5),
SUBSCRIPTIONS(6);
companion object {
fun fromCode(code: Int?): StartLocation {
@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app
import android.view.View
import android.widget.TextView
import androidx.lifecycle.ViewModelProvider
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
@@ -15,7 +15,9 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
@@ -25,17 +27,27 @@ import org.thoughtcrime.securesms.util.MappingViewHolder
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
private val viewModel: AppSettingsViewModel by viewModels(
factoryProducer = {
AppSettingsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.registerFactory(BioPreference::class.java, MappingAdapter.LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
adapter.registerFactory(PaymentsPreference::class.java, MappingAdapter.LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
val viewModel = ViewModelProvider(this)[AppSettingsViewModel::class.java]
adapter.registerFactory(SubscriptionPreference::class.java, MappingAdapter.LayoutFactory(::SubscriptionPreferenceViewHolder, R.layout.dsl_preference_item))
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
override fun onResume() {
super.onResume()
viewModel.refreshActiveSubscription()
}
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
return configure {
@@ -132,16 +144,19 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
)
if (FeatureFlags.donorBadges()) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
onClick = {
findNavController()
.navigate(
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
.setSkipToSubscribe(true /* TODO [alex] -- Check state to see if user has active subscription or not. */)
)
}
customPref(
SubscriptionPreference(
title = DSLSettingsText.from(R.string.preferences__subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
isActive = state.hasActiveSubscription,
onClick = { isActive ->
findNavController()
.navigate(
AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscriptions()
.setSkipToSubscribe(!isActive)
)
}
)
)
// TODO [alex] -- clap
clickPref(
@@ -172,6 +187,29 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
}
}
private class SubscriptionPreference(
override val title: DSLSettingsText,
override val summary: DSLSettingsText? = null,
override val icon: DSLSettingsIcon? = null,
override val isEnabled: Boolean = true,
val isActive: Boolean = false,
val onClick: (Boolean) -> Unit
) : PreferenceModel<SubscriptionPreference>() {
override fun areItemsTheSame(newItem: SubscriptionPreference): Boolean {
return true
}
override fun areContentsTheSame(newItem: SubscriptionPreference): Boolean {
return super.areContentsTheSame(newItem) && isActive == newItem.isActive
}
}
private class SubscriptionPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SubscriptionPreference>(itemView) {
override fun bind(model: SubscriptionPreference) {
super.bind(model)
itemView.setOnClickListener { model.onClick(model.isActive) }
}
}
private class BioPreference(val recipient: Recipient, val onClick: () -> Unit) : PreferenceModel<BioPreference>() {
override fun areContentsTheSame(newItem: BioPreference): Boolean {
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
@@ -2,4 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app
import org.thoughtcrime.securesms.recipients.Recipient
data class AppSettingsState(val self: Recipient, val unreadPaymentsCount: Int)
data class AppSettingsState(
val self: Recipient,
val unreadPaymentsCount: Int,
val hasActiveSubscription: Boolean
)
@@ -2,18 +2,47 @@ package org.thoughtcrime.securesms.components.settings.app
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.concurrent.TimeUnit
class AppSettingsViewModel : ViewModel() {
class AppSettingsViewModel(private val subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
val unreadPaymentsLiveData = UnreadPaymentsLiveData()
val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
private val store = Store(AppSettingsState(Recipient.self(), 0, false))
val state: LiveData<AppSettingsState> = LiveDataUtil.combineLatest(unreadPaymentsLiveData, selfLiveData) { payments, self ->
val unreadPaymentsCount = payments.transform { it.unreadCount }.or(0)
private val unreadPaymentsLiveData = UnreadPaymentsLiveData()
private val selfLiveData: LiveData<Recipient> = Recipient.self().live().liveData
AppSettingsState(self, unreadPaymentsCount)
val state: LiveData<AppSettingsState> = store.stateLiveData
init {
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.transform { it.unreadCount }.or(0)) }
store.update(selfLiveData) { self, state -> state.copy(self = self) }
}
fun refreshActiveSubscription() {
if (!FeatureFlags.donorBadges()) {
return
}
store.update {
it.copy(hasActiveSubscription = TimeUnit.SECONDS.toMillis(SignalStore.donationsValues().getLastEndOfPeriod()) > System.currentTimeMillis())
}
subscriptionsRepository.getActiveSubscription().subscribeBy(
onSuccess = { subscription -> store.update { it.copy(hasActiveSubscription = subscription.isActive) } },
)
}
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T
}
}
}
@@ -11,5 +11,6 @@ sealed class DonationEvent {
object RequestTokenError : DonationEvent()
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
class SubscriptionCancellationFailed(val throwable: Throwable) : DonationEvent()
object SubscriptionCancelled : DonationEvent()
}
@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
class DonationExceptions {
object TimedOutWaitingForTokenRedemption : Exception()
}
@@ -5,18 +5,44 @@ import android.content.Intent
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.Environment
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher {
/**
* Manages bindings with payment APIs
*
* Steps for setting up payments for a subscription:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Generate and send a SubscriberId, which is a 32 byte ID representing this user, to Signal Service, which creates a Stripe Customer
* 1. Create a SetupIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the SetupIntent via the Stripe API
* 1. Set the default PaymentMethod for the customer, using the PaymentMethod id, via the Signal service
*
* For Boosts:
* 1. Ask GooglePay for a payment token. This will pop up the Google Pay Sheet, which allows the user to select a payment method.
* 1. Create a PaymentIntent via the Stripe API
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
* 1. Confirm the PaymentIntent via the Stripe API
*/
class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private val configuration = StripeApi.Configuration(publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY)
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(configuration))
private val stripeApi = StripeApi(configuration, this, ApplicationDependencies.getOkHttpClient())
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
@@ -46,10 +72,153 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
}
fun continueSubscriptionSetup(paymentData: PaymentData): Completable {
return stripeApi.createSetupIntent()
.flatMapCompletable { result ->
stripeApi.confirmSetupIntent(GooglePayPaymentSource(paymentData), result.setupIntent)
}
}
fun cancelActiveSubscription(): Completable {
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
return ApplicationDependencies.getDonationsService().cancelSubscription(localSubscriber.subscriberId).flatMapCompletable {
when {
it.status == 200 -> Completable.complete()
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
it.executionError.isPresent -> Completable.error(it.executionError.get())
else -> Completable.error(AssertionError("Something bad happened"))
}
}
}
fun ensureSubscriberId(): Completable {
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
return ApplicationDependencies
.getDonationsService()
.putSubscription(subscriberId)
.flatMapCompletable {
when {
it.status == 200 -> Completable.complete()
it.applicationError.isPresent -> Completable.error(it.applicationError.get())
it.executionError.isPresent -> Completable.error(it.executionError.get())
else -> Completable.error(AssertionError("Something bad happened"))
}
}
.doOnComplete {
SignalStore
.donationsValues()
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
}
}
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
subscriber.subscriberId,
subscriptionLevel,
subscriber.currencyCode,
levelUpdateOperation.idempotencyKey.serialize()
).flatMapCompletable { response ->
when {
response.status == 200 -> Completable.complete()
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
response.executionError.isPresent -> Completable.error(response.executionError.get())
else -> Completable.error(AssertionError("should never happen"))
}
}.andThen {
SignalStore.donationsValues().clearLevelOperation(levelUpdateOperation)
it.onComplete()
}.andThen {
val jobIds = SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation()
val countDownLatch = CountDownLatch(2)
val firstJobListener = JobTracker.JobListener { _, jobState ->
if (jobState.isComplete) {
countDownLatch.countDown()
}
}
val secondJobListener = JobTracker.JobListener { _, jobState ->
if (jobState.isComplete) {
countDownLatch.countDown()
}
}
ApplicationDependencies.getJobManager().addListener(jobIds.first(), firstJobListener)
ApplicationDependencies.getJobManager().addListener(jobIds.second(), secondJobListener)
try {
if (!countDownLatch.await(10, TimeUnit.SECONDS)) {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
} else {
it.onComplete()
}
} catch (e: InterruptedException) {
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
}
}
}.subscribeOn(Schedulers.io())
}
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation()
if (levelUpdateOperation == null || subscriptionLevel != levelUpdateOperation.level) {
val newOperation = LevelUpdateOperation(
idempotencyKey = IdempotencyKey.generate(),
level = subscriptionLevel
)
SignalStore.donationsValues().setLevelOperation(newOperation)
newOperation
} else {
levelUpdateOperation
}
}
override fun fetchPaymentIntent(price: FiatMoney, description: String?): Single<StripeApi.PaymentIntent> {
return ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode)
.map { StripeApi.PaymentIntent(it.result.get().id, it.result.get().clientSecret) }
.flatMap { response ->
when {
response.status == 200 -> Single.just(StripeApi.PaymentIntent(response.result.get().id, response.result.get().clientSecret))
response.executionError.isPresent -> Single.error(response.executionError.get())
response.applicationError.isPresent -> Single.error(response.applicationError.get())
else -> Single.error(AssertionError("should never get here"))
}
}
}
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> {
return Single.fromCallable {
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
ApplicationDependencies.getDonationsService().createSubscriptionPaymentMethod(it.subscriberId)
}.flatMap { response ->
when {
response.status == 200 -> Single.just(StripeApi.SetupIntent(response.result.get().id, response.result.get().clientSecret))
response.executionError.isPresent -> Single.error(response.executionError.get())
response.applicationError.isPresent -> Single.error(response.applicationError.get())
else -> Single.error(AssertionError("should never get here"))
}
}
}
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
return Single.fromCallable {
SignalStore.donationsValues().requireSubscriber()
}.flatMap {
ApplicationDependencies.getDonationsService().setDefaultPaymentMethodId(it.subscriberId, paymentMethodId)
}.flatMapCompletable { response ->
when {
response.status == 200 -> Completable.complete()
response.executionError.isPresent -> Completable.error(response.executionError.get())
response.applicationError.isPresent -> Completable.error(response.applicationError.get())
else -> Completable.error(AssertionError("Should never get here"))
}
}
}
}
@@ -1,19 +1,49 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
/**
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
* in the currency indicated.
*/
class SubscriptionsRepository {
class SubscriptionsRepository(private val donationsService: DonationsService) {
fun getActiveSubscription(currency: Currency): Maybe<Subscription> = Maybe.empty()
fun getActiveSubscription(): Single<ActiveSubscription> {
val localSubscription = SignalStore.donationsValues().getSubscriber()
return if (localSubscription != null) {
donationsService.getSubscription(localSubscription.subscriberId).flatMap {
when {
it.status == 200 -> Single.just(it.result.get())
it.applicationError.isPresent -> Single.error(it.applicationError.get())
it.executionError.isPresent -> Single.error(it.executionError.get())
else -> throw AssertionError()
}
}
} else {
Single.just(ActiveSubscription(null))
}
}
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = Single.fromCallable {
listOf()
fun getSubscriptions(currency: Currency): Single<List<Subscription>> = donationsService.subscriptionLevels.map { response ->
response.result.transform { subscriptionLevels ->
subscriptionLevels.levels.map { (code, level) ->
Subscription(
id = code,
title = level.badge.name,
badge = Badges.fromServiceBadge(level.badge),
price = FiatMoney(level.currencies[currency.currencyCode]!!, currency),
level = code.toInt()
)
}.sortedBy {
it.level
}
}.or(emptyList())
}
}
@@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.text.SpannableStringBuilder
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
@@ -15,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
@@ -24,11 +28,15 @@ import org.thoughtcrime.securesms.util.SpanUtil
/**
* UX to allow users to donate ephemerally.
*/
class BoostFragment : DSLSettingsBottomSheetFragment() {
class BoostFragment : DSLSettingsBottomSheetFragment(
layoutId = R.layout.boost_bottom_sheet
) {
private val viewModel: BoostViewModel by viewModels(ownerProducer = { requireActivity() })
private val lifecycleDisposable = LifecycleDisposable()
private lateinit var processingDonationPaymentDialog: AlertDialog
private val sayThanks: CharSequence by lazy {
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
.append(" ")
@@ -45,6 +53,11 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
Boost.register(adapter)
GooglePayButton.register(adapter)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
.setCancelable(false)
.create()
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
@@ -52,17 +65,24 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
when (event) {
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", event.throwable)
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", event.throwable)
is DonationEvent.GooglePayUnavailableError -> onGooglePayUnavailable(event.throwable)
is DonationEvent.PaymentConfirmationError -> onPaymentError(event.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
DonationEvent.RequestTokenError -> onPaymentError(null)
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> Unit
is DonationEvent.SubscriptionCancellationFailed -> Unit
}
}
}
private fun getConfiguration(state: BoostState): DSLConfiguration {
if (state.stage == BoostState.Stage.PAYMENT_PIPELINE) {
processingDonationPaymentDialog.show()
} else {
processingDonationPaymentDialog.hide()
}
return configure {
customPref(BadgePreview.SubscriptionModel(state.boostBadge))
@@ -87,7 +107,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
currencySelection = state.currencySelection,
isEnabled = state.stage == BoostState.Stage.READY,
onClick = {
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment())
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToSetDonationCurrencyFragment(true))
}
)
)
@@ -137,7 +157,43 @@ class BoostFragment : DSLSettingsBottomSheetFragment() {
}
private fun onPaymentConfirmed(boostBadge: Badge) {
findNavController().navigate(BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true))
findNavController().navigate(
BoostFragmentDirections.actionBoostFragmentToBoostThanksForYourSupportBottomSheetDialog(boostBadge).setIsBoost(true),
NavOptions.Builder().setPopUpTo(R.id.boostFragment, true).build()
)
}
private fun onPaymentError(throwable: Throwable?) {
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Error occurred while redeeming token", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_still_pending)
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
} else {
Log.w(TAG, "Error occurred while processing payment", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__payment_failed)
.setMessage(R.string.DonationsErrors__your_payment)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
}
}
private fun onGooglePayUnavailable(throwable: Throwable?) {
Log.w(TAG, "Google Pay error", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__google_pay_unavailable)
.setMessage(R.string.DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
}
companion object {
@@ -14,11 +14,12 @@ data class BoostState(
val selectedBoost: Boost? = null,
val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, Currency.getInstance(currencySelection.selectedCurrencyCode)),
val isCustomAmountFocused: Boolean = false,
val stage: Stage = Stage.INIT
val stage: Stage = Stage.INIT,
) {
enum class Stage {
INIT,
READY,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
}
}
@@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@@ -31,7 +32,7 @@ class BoostViewModel(
private val disposables = CompositeDisposable()
val state: LiveData<BoostState> = store.stateLiveData
val events: Observable<DonationEvent> = eventPublisher
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
private var boostToPurchase: Boost? = null
@@ -40,7 +41,7 @@ class BoostViewModel(
}
init {
val currencyObservable = SignalStore.donationsValues().observableCurrency
val currencyObservable = SignalStore.donationsValues().observableBoostCurrency
val boosts = currencyObservable.flatMapSingle { boostRepository.getBoosts(it) }
val boostBadge = boostRepository.getBoostBadge()
@@ -91,22 +92,28 @@ class BoostViewModel(
if (boost != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
onError = { throwable ->
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
},
onComplete = {
// Now we need to do the whole query for a token, submit token rigamarole
// TODO [alex] Now we need to do the whole query for a token, submit token rigamarole
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.boostBadge!!))
}
)
} else {
store.update { it.copy(stage = BoostState.Stage.READY) }
}
}
override fun onError() {
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
store.update { it.copy(stage = BoostState.Stage.READY) }
eventPublisher.onNext(DonationEvent.RequestTokenError)
}
override fun onCancelled() {
store.update { it.copy(stage = BoostState.Stage.PAYMENT_PIPELINE) }
store.update { it.copy(stage = BoostState.Stage.READY) }
}
}
)
@@ -13,7 +13,11 @@ import java.util.Locale
*/
class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
private val viewModel: SetCurrencyViewModel by viewModels()
private val viewModel: SetCurrencyViewModel by viewModels(
factoryProducer = {
SetCurrencyViewModel.Factory(SetCurrencyFragmentArgs.fromBundle(requireArguments()).isBoost)
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel.state.observe(viewLifecycleOwner) { state ->
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.currency
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -10,14 +11,14 @@ import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Currency
import java.util.Locale
class SetCurrencyViewModel : ViewModel() {
class SetCurrencyViewModel(val isBoost: Boolean) : ViewModel() {
private val store = Store(SetCurrencyState())
val state: LiveData<SetCurrencyState> = store.stateLiveData
init {
val defaultCurrency = SignalStore.donationsValues().getCurrency()
val defaultCurrency = SignalStore.donationsValues().getSubscriptionCurrency()
store.update { state ->
val platformCurrencies = Currency.getAvailableCurrencies()
@@ -34,7 +35,12 @@ class SetCurrencyViewModel : ViewModel() {
fun setSelectedCurrency(selectedCurrencyCode: String) {
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
SignalStore.donationsValues().setCurrency(Currency.getInstance(selectedCurrencyCode))
if (isBoost) {
SignalStore.donationsValues().setBoostCurrency(Currency.getInstance(selectedCurrencyCode))
} else {
SignalStore.donationsValues().setSubscriptionCurrency(Currency.getInstance(selectedCurrencyCode))
}
}
@VisibleForTesting
@@ -65,4 +71,10 @@ class SetCurrencyViewModel : ViewModel() {
}
}
}
class Factory(private val isBoost: Boolean) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(SetCurrencyViewModel(isBoost))!!
}
}
}
@@ -21,14 +21,17 @@ object ActiveSubscriptionPreference {
class Model(
val subscription: Subscription,
val onAddBoostClick: () -> Unit
val onAddBoostClick: () -> Unit,
val renewalTimestamp: Long = -1L
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return subscription.id == newItem.subscription.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && subscription == newItem.subscription
return super.areContentsTheSame(newItem) &&
subscription == newItem.subscription &&
renewalTimestamp == newItem.renewalTimestamp
}
}
@@ -57,7 +60,7 @@ object ActiveSubscriptionPreference {
R.string.MySupportPreference__renews_s,
DateUtils.formatDate(
Locale.getDefault(),
model.subscription.renewalTimestamp
model.renewalTimestamp
)
)
@@ -5,6 +5,7 @@ import android.widget.Toast
import androidx.fragment.app.viewModels
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -13,7 +14,11 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import java.util.concurrent.TimeUnit
/**
* Fragment displayed when a user enters "Subscriptions" via app settings but is already
@@ -23,7 +28,7 @@ class ManageDonationsFragment : DSLSettingsFragment() {
private val viewModel: ManageDonationsViewModel by viewModels(
factoryProducer = {
ManageDonationsViewModel.Factory(SubscriptionsRepository())
ManageDonationsViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
}
)
@@ -53,6 +58,7 @@ class ManageDonationsFragment : DSLSettingsFragment() {
}
ActiveSubscriptionPreference.register(adapter)
IndeterminateLoadingCircle.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
@@ -83,17 +89,32 @@ class ManageDonationsFragment : DSLSettingsFragment() {
)
)
if (state.activeSubscription != null) {
customPref(
ActiveSubscriptionPreference.Model(
subscription = state.activeSubscription,
onAddBoostClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
}
)
)
if (state.transactionState is ManageDonationsState.TransactionState.NotInTransaction) {
val activeSubscription = state.transactionState.activeSubscription
if (activeSubscription.isActive) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { activeSubscription.activeSubscription.level == it.level }
if (subscription != null) {
space(DimensionUnit.DP.toPixels(16f).toInt())
dividerPref()
customPref(
ActiveSubscriptionPreference.Model(
subscription = subscription,
onAddBoostClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
},
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.activeSubscription.endOfCurrentPeriod)
)
)
dividerPref()
} else {
customPref(IndeterminateLoadingCircle)
}
} else {
customPref(IndeterminateLoadingCircle)
}
} else {
customPref(IndeterminateLoadingCircle)
}
clickPref(
@@ -2,8 +2,16 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
data class ManageDonationsState(
val featuredBadge: Badge? = null,
val activeSubscription: Subscription? = null
)
val transactionState: TransactionState = TransactionState.Init,
val availableSubscriptions: List<Subscription> = emptyList()
) {
sealed class TransactionState {
object Init : TransactionState()
object InTransaction : TransactionState()
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
}
}
@@ -3,7 +3,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
@@ -11,7 +13,10 @@ import io.reactivex.rxjava3.subjects.PublishSubject
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
class ManageDonationsViewModel(
private val subscriptionsRepository: SubscriptionsRepository
@@ -22,7 +27,7 @@ class ManageDonationsViewModel(
private val disposables = CompositeDisposable()
val state: LiveData<ManageDonationsState> = store.stateLiveData
val events: Observable<ManageDonationsEvent> = eventPublisher
val events: Observable<ManageDonationsEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
init {
store.update(Recipient.self().live().liveDataResolved) { self, state ->
@@ -36,16 +41,34 @@ class ManageDonationsViewModel(
fun refresh() {
disposables.clear()
disposables += subscriptionsRepository.getActiveSubscription(SignalStore.donationsValues().getCurrency()).subscribeBy(
onSuccess = { subscription -> store.update { it.copy(activeSubscription = subscription) } },
onComplete = {
store.update { it.copy(activeSubscription = null) }
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
val levelUpdateOperationEdges: Observable<Optional<LevelUpdateOperation>> = SignalStore.donationsValues().levelUpdateOperationObservable.distinctUntilChanged()
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription()
disposables += levelUpdateOperationEdges.flatMapSingle { optionalKey ->
if (optionalKey.isPresent) {
Single.just(ManageDonationsState.TransactionState.InTransaction)
} else {
activeSubscription.map { ManageDonationsState.TransactionState.NotInTransaction(it) }
}
}.subscribeBy(
onNext = { transactionState ->
store.update {
it.copy(transactionState = transactionState)
}
if (transactionState is ManageDonationsState.TransactionState.NotInTransaction && !transactionState.activeSubscription.isActive) {
eventPublisher.onNext(ManageDonationsEvent.NOT_SUBSCRIBED)
}
},
onError = {
eventPublisher.onNext(ManageDonationsEvent.ERROR_GETTING_SUBSCRIPTION)
}
)
disposables += subscriptionsRepository.getSubscriptions(SignalStore.donationsValues().getSubscriptionCurrency()).subscribeBy { subs ->
store.update { it.copy(availableSubscriptions = subs) }
}
}
class Factory(
@@ -38,7 +38,10 @@ data class CurrencySelection(
override fun bind(model: Model) {
spinner.text = model.currencySelection.selectedCurrencyCode
itemView.setOnClickListener { model.onClick() }
if (model.isEnabled) {
itemView.setOnClickListener { model.onClick() }
}
}
}
}
@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import android.graphics.Color
import android.text.SpannableStringBuilder
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
@@ -16,18 +18,26 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
import java.util.Calendar
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* UX for creating and changing a subscription
*/
class SubscribeFragment : DSLSettingsFragment() {
class SubscribeFragment : DSLSettingsFragment(
layoutId = R.layout.subscribe_fragment
) {
private val viewModel: SubscribeViewModel by viewModels(ownerProducer = { requireActivity() })
@@ -43,9 +53,11 @@ class SubscribeFragment : DSLSettingsFragment() {
)
}
private lateinit var processingDonationPaymentDialog: AlertDialog
override fun onResume() {
super.onResume()
viewModel.refresh()
viewModel.refreshActiveSubscription()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
@@ -54,6 +66,11 @@ class SubscribeFragment : DSLSettingsFragment() {
Subscription.register(adapter)
GooglePayButton.register(adapter)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
.setCancelable(false)
.create()
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
@@ -61,19 +78,28 @@ class SubscribeFragment : DSLSettingsFragment() {
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe {
when (it) {
is DonationEvent.GooglePayUnavailableError -> Log.w(TAG, "Google Pay error", it.throwable)
is DonationEvent.PaymentConfirmationError -> Log.w(TAG, "Payment confirmation error", it.throwable)
is DonationEvent.GooglePayUnavailableError -> onGooglePayUnavailable(it.throwable)
is DonationEvent.PaymentConfirmationError -> onPaymentError(it.throwable)
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
DonationEvent.RequestTokenError -> Log.w(TAG, "Request token could not be fetched")
DonationEvent.RequestTokenError -> onPaymentError(null)
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
is DonationEvent.SubscriptionCancellationFailed -> onSubscriptionFailedToCancel(it.throwable)
}
}
}
private fun getConfiguration(state: SubscribeState): DSLConfiguration {
if (state.hasInProgressSubscriptionTransaction || state.stage == SubscribeState.Stage.PAYMENT_PIPELINE) {
processingDonationPaymentDialog.show()
} else {
processingDonationPaymentDialog.hide()
}
val areFieldsEnabled = state.stage == SubscribeState.Stage.READY && !state.hasInProgressSubscriptionTransaction
return configure {
customPref(BadgePreview.SubscriptionModel(state.previewBadge))
customPref(BadgePreview.SubscriptionModel(state.selectedSubscription?.badge))
sectionHeaderPref(
title = DSLSettingsText.from(
@@ -91,35 +117,63 @@ class SubscribeFragment : DSLSettingsFragment() {
customPref(
CurrencySelection.Model(
currencySelection = state.currencySelection,
isEnabled = state.stage == SubscribeState.Stage.READY,
isEnabled = areFieldsEnabled && state.activeSubscription?.isActive != true,
onClick = {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment())
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSetDonationCurrencyFragment(false))
}
)
)
state.subscriptions.forEach {
val isActive = state.activeSubscription?.activeSubscription?.level == it.level
customPref(
Subscription.Model(
subscription = it,
isSelected = state.selectedSubscription == it,
isEnabled = state.stage == SubscribeState.Stage.READY,
isActive = state.activeSubscription == it,
onClick = { viewModel.setSelectedSubscription(it) }
isEnabled = areFieldsEnabled,
isActive = isActive,
willRenew = isActive && state.activeSubscription?.activeSubscription?.willCancelAtPeriodEnd() ?: false,
onClick = { viewModel.setSelectedSubscription(it) },
renewalTimestamp = TimeUnit.SECONDS.toMillis(state.activeSubscription?.activeSubscription?.endOfCurrentPeriod ?: 0L)
)
)
}
if (state.activeSubscription != null) {
if (state.activeSubscription?.isActive == true) {
space(DimensionUnit.DP.toPixels(16f).toInt())
val activeAndSameLevel = state.activeSubscription.isActive &&
state.selectedSubscription?.level == state.activeSubscription.activeSubscription?.level
val isExpiring = state.activeSubscription.isActive && state.activeSubscription.activeSubscription?.willCancelAtPeriodEnd() == true
primaryButton(
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
isEnabled = areFieldsEnabled && (!activeAndSameLevel || isExpiring),
onClick = {
// TODO [alex] -- Dunno what the update process requires.
val calendar = Calendar.getInstance()
calendar.add(Calendar.MONTH, 1)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__update_subscription_question)
.setMessage(
getString(
R.string.SubscribeFragment__you_will_be_charged_the_full_amount,
DateUtils.formatDateWithYear(Locale.getDefault(), calendar.timeInMillis)
)
)
.setPositiveButton(R.string.SubscribeFragment__update) { dialog, _ ->
dialog.dismiss()
viewModel.updateSubscription()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
isEnabled = areFieldsEnabled,
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
@@ -141,7 +195,7 @@ class SubscribeFragment : DSLSettingsFragment() {
customPref(
GooglePayButton.Model(
onClick = this@SubscribeFragment::onGooglePayButtonClicked,
isEnabled = state.stage == SubscribeState.Stage.READY
isEnabled = areFieldsEnabled && state.selectedSubscription != null
)
)
}
@@ -150,7 +204,7 @@ class SubscribeFragment : DSLSettingsFragment() {
text = DSLSettingsText.from(R.string.SubscribeFragment__more_payment_options),
icon = DSLSettingsIcon.from(R.drawable.ic_open_20, R.color.signal_accent_primary),
onClick = {
// TODO
// TODO [alex] support page
}
)
}
@@ -162,11 +216,62 @@ class SubscribeFragment : DSLSettingsFragment() {
}
private fun onPaymentConfirmed(badge: Badge) {
findNavController().navigate(SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false))
findNavController().navigate(
SubscribeFragmentDirections.actionSubscribeFragmentToSubscribeThanksForYourSupportBottomSheetDialog(badge).setIsBoost(false),
)
}
private fun onPaymentError(throwable: Throwable?) {
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Error occurred while redeeming token", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_still_pending)
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
} else {
Log.w(TAG, "Error occurred while processing payment", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__payment_failed)
.setMessage(R.string.DonationsErrors__your_payment)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
}
}
}
private fun onGooglePayUnavailable(throwable: Throwable?) {
Log.w(TAG, "Google Pay error", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__google_pay_unavailable)
.setMessage(R.string.DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
}
private fun onSubscriptionCancelled() {
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
Snackbar.make(requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG)
.setTextColor(Color.WHITE)
.show()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.home(requireContext()))
}
private fun onSubscriptionFailedToCancel(throwable: Throwable) {
Log.w(TAG, "Failed to cancel subscription", throwable)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__failed_to_cancel_subscription)
.setMessage(R.string.DonationsErrors__subscription_cancellation_requires_an_internet_connection)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()
}
}
companion object {
@@ -1,21 +1,22 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
data class SubscribeState(
val previewBadge: Badge? = null,
val currencySelection: CurrencySelection = CurrencySelection("USD"),
val subscriptions: List<Subscription> = listOf(),
val selectedSubscription: Subscription? = null,
val activeSubscription: Subscription? = null,
val activeSubscription: ActiveSubscription? = null,
val isGooglePayAvailable: Boolean = false,
val stage: Stage = Stage.INIT
val stage: Stage = Stage.INIT,
val hasInProgressSubscriptionTransaction: Boolean = false,
) {
enum class Stage {
INIT,
READY,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
CANCELLING
}
@@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@@ -18,7 +19,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Cu
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
class SubscribeViewModel(
private val subscriptionsRepository: SubscriptionsRepository,
@@ -31,31 +33,35 @@ class SubscribeViewModel(
private val disposables = CompositeDisposable()
val state: LiveData<SubscribeState> = store.stateLiveData
val events: Observable<DonationEvent> = eventPublisher
val events: Observable<DonationEvent> = eventPublisher.observeOn(AndroidSchedulers.mainThread())
private var subscriptionToPurchase: Subscription? = null
private val activeSubscriptionSubject = PublishSubject.create<ActiveSubscription>()
override fun onCleared() {
disposables.clear()
}
fun refresh() {
disposables.clear()
init {
val currency: Observable<Currency> = SignalStore.donationsValues().observableSubscriptionCurrency
val allSubscriptions: Observable<List<Subscription>> = currency.switchMapSingle { subscriptionsRepository.getSubscriptions(it) }
refreshActiveSubscription()
val currency = SignalStore.donationsValues().getCurrency()
disposables += SignalStore.donationsValues().levelUpdateOperationObservable.subscribeBy {
store.update { state ->
state.copy(
hasInProgressSubscriptionTransaction = it.isPresent
)
}
}
val allSubscriptions = subscriptionsRepository.getSubscriptions(currency)
val activeSubscription = subscriptionsRepository.getActiveSubscription(currency)
.map { Optional.of(it) }
.defaultIfEmpty(Optional.absent())
disposables += allSubscriptions.zipWith(activeSubscription, ::Pair).subscribe { (subs, active) ->
disposables += Observable.combineLatest(allSubscriptions, activeSubscriptionSubject, ::Pair).subscribe { (subs, active) ->
store.update {
it.copy(
subscriptions = subs,
selectedSubscription = it.selectedSubscription ?: active.orNull() ?: subs.firstOrNull(),
activeSubscription = active.orNull(),
stage = SubscribeState.Stage.READY
selectedSubscription = it.selectedSubscription ?: resolveSelectedSubscription(active, subs),
activeSubscription = active,
stage = if (it.stage == SubscribeState.Stage.INIT) SubscribeState.Stage.READY else it.stage,
)
}
}
@@ -65,11 +71,39 @@ class SubscribeViewModel(
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
)
store.update { it.copy(currencySelection = CurrencySelection(SignalStore.donationsValues().getCurrency().currencyCode)) }
disposables += currency.map { CurrencySelection(it.currencyCode) }.subscribe { selection ->
store.update { it.copy(currencySelection = selection) }
}
}
fun refreshActiveSubscription() {
subscriptionsRepository
.getActiveSubscription()
.subscribeBy { activeSubscriptionSubject.onNext(it) }
}
private fun resolveSelectedSubscription(activeSubscription: ActiveSubscription, subscriptions: List<Subscription>): Subscription? {
return if (activeSubscription.isActive) {
subscriptions.firstOrNull { it.level == activeSubscription.activeSubscription.level }
} else {
subscriptions.firstOrNull()
}
}
fun cancel() {
store.update { it.copy(stage = SubscribeState.Stage.CANCELLING) }
// TODO [alex] -- cancel api call
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
onComplete = {
eventPublisher.onNext(DonationEvent.SubscriptionCancelled)
SignalStore.donationsValues().setLastEndOfPeriod(0L)
refreshActiveSubscription()
store.update { it.copy(stage = SubscribeState.Stage.READY) }
},
onError = { throwable ->
eventPublisher.onNext(DonationEvent.SubscriptionCancellationFailed(throwable))
store.update { it.copy(stage = SubscribeState.Stage.READY) }
}
)
}
fun onActivityResult(
@@ -77,44 +111,72 @@ class SubscribeViewModel(
resultCode: Int,
data: Intent?
) {
val subscription = subscriptionToPurchase
subscriptionToPurchase = null
donationPaymentRepository.onActivityResult(
requestCode, resultCode, data, this.fetchTokenRequestCode,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
val subscription = subscriptionToPurchase
subscriptionToPurchase = null
if (subscription != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
donationPaymentRepository.continuePayment(subscription.price, paymentData).subscribeBy(
onError = { eventPublisher.onNext(DonationEvent.PaymentConfirmationError(it)) },
val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId()
val continueSetup = donationPaymentRepository.continueSubscriptionSetup(paymentData)
val setLevel = donationPaymentRepository.setSubscriptionLevel(subscription.level.toString())
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
ensureSubscriberId.andThen(continueSetup).andThen(setLevel).subscribeBy(
onError = { throwable ->
refreshActiveSubscription()
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
},
onComplete = {
// Now we need to do the whole query for a token, submit token rigamarole
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(subscription.badge))
}
)
} else {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
}
}
override fun onError() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.RequestTokenError)
}
override fun onCancelled() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
store.update { it.copy(stage = SubscribeState.Stage.READY) }
}
}
)
}
fun updateSubscription() {
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.setSubscriptionLevel(store.state.selectedSubscription!!.level.toString())
.subscribeBy(
onComplete = {
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.selectedSubscription!!.badge))
},
onError = { throwable ->
store.update { it.copy(stage = SubscribeState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
}
)
}
fun requestTokenFromGooglePay() {
val snapshot = store.state
if (snapshot.selectedSubscription == null) {
return
}
store.update { it.copy(stage = SubscribeState.Stage.PAYMENT_PIPELINE) }
store.update { it.copy(stage = SubscribeState.Stage.TOKEN_REQUEST) }
subscriptionToPurchase = snapshot.selectedSubscription
donationPaymentRepository.requestTokenFromGooglePay(snapshot.selectedSubscription.price, snapshot.selectedSubscription.title, fetchTokenRequestCode)
@@ -1,54 +1,122 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.thanks
import android.animation.Animator
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.navigation.fragment.findNavController
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieDrawable
import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.visible
class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
private lateinit var displayOnProfileSwitch: SwitchMaterial
private lateinit var switch: SwitchMaterial
private lateinit var heading: TextView
private lateinit var badgeRepository: BadgeRepository
private lateinit var controlState: ControlState
private enum class ControlState {
FEATURE,
DISPLAY
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.thanks_for_your_support_bottom_sheet_dialog_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
badgeRepository = BadgeRepository(requireContext())
val badgeView: BadgeImageView = view.findViewById(R.id.thanks_bottom_sheet_badge)
val lottie: LottieAnimationView = view.findViewById(R.id.thanks_bottom_sheet_lottie)
val badgeName: TextView = view.findViewById(R.id.thanks_bottom_sheet_badge_name)
val done: MaterialButton = view.findViewById(R.id.thanks_bottom_sheet_done)
val controlText: TextView = view.findViewById(R.id.thanks_bottom_sheet_control_text)
val controlNote: View = view.findViewById(R.id.thanks_bottom_sheet_featured_note)
heading = view.findViewById(R.id.thanks_bottom_sheet_heading)
displayOnProfileSwitch = view.findViewById(R.id.thanks_bottom_sheet_display_on_profile)
switch = view.findViewById(R.id.thanks_bottom_sheet_switch)
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
badgeView.setBadge(args.badge)
badgeName.text = args.badge.name
displayOnProfileSwitch.isChecked = true
val otherBadges = Recipient.self().badges.filterNot { it.id == args.badge.id }
val hasOtherBadges = otherBadges.isNotEmpty()
val displayingBadges = otherBadges.all { it.visible }
if (hasOtherBadges && displayingBadges) {
switch.isChecked = false
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge)
controlNote.visible = true
controlState = ControlState.FEATURE
} else if (hasOtherBadges && !displayingBadges) {
switch.isChecked = false
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
controlNote.visible = false
controlState = ControlState.DISPLAY
} else {
switch.isChecked = true
controlText.setText(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
controlNote.visible = false
controlState = ControlState.DISPLAY
}
if (args.isBoost) {
presentBoostCopy()
lottie.visible = true
lottie.playAnimation()
lottie.addAnimatorListener(object : AnimationCompleteListener() {
override fun onAnimationEnd(animation: Animator?) {
lottie.removeAnimatorListener(this)
lottie.setMinAndMaxFrame(30, 91)
lottie.repeatMode = LottieDrawable.RESTART
lottie.repeatCount = LottieDrawable.INFINITE
lottie.frame = 30
lottie.playAnimation()
}
})
} else {
presentSubscriptionCopy()
lottie.visible = false
}
done.setOnClickListener { dismissAllowingStateLoss() }
}
override fun onDismiss(dialog: DialogInterface) {
val isDisplayOnProfile = displayOnProfileSwitch.isChecked
// TODO [alex] -- Not sure what state we're in with regards to submitting the token.
val controlChecked = switch.isChecked
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
if (controlState == ControlState.DISPLAY) {
badgeRepository.setVisibilityForAllBadges(controlChecked).subscribe()
} else {
badgeRepository.setFeaturedBadge(args.badge).subscribe()
}
if (args.isBoost) {
findNavController().popBackStack()
} else {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
}
private fun presentBoostCopy() {
@@ -194,7 +194,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
val recipientId = args.recipientId
if (recipientId != null) {
Badge.register(adapter) { badge, _ ->
Badge.register(adapter) { badge, _, _ ->
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, recipientId, badge)
}
}
@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object IndeterminateLoadingCircle : PreferenceModel<IndeterminateLoadingCircle>() {
override fun areItemsTheSame(newItem: IndeterminateLoadingCircle): Boolean = true
private class ViewHolder(itemView: View) : MappingViewHolder<IndeterminateLoadingCircle>(itemView) {
override fun bind(model: IndeterminateLoadingCircle) = Unit
}
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(IndeterminateLoadingCircle::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.indeterminate_loading_circle_pref))
}
}
@@ -6,6 +6,7 @@ import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import org.signal.core.util.concurrent.DeadlockDetector;
import org.signal.zkgroup.receipts.ClientZkReceiptOperations;
import org.thoughtcrime.securesms.KbsEnclave;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender;
@@ -108,6 +109,7 @@ public class ApplicationDependencies {
private static volatile AudioManagerCompat audioManagerCompat;
private static volatile DonationsService donationsService;
private static volatile DeadlockDetector deadlockDetector;
private static volatile ClientZkReceiptOperations clientZkReceiptOperations;
@MainThread
public static void init(@NonNull Application application, @NonNull Provider provider) {
@@ -605,6 +607,17 @@ public class ApplicationDependencies {
return donationsService;
}
public static @NonNull ClientZkReceiptOperations getClientZkReceiptOperations() {
if (clientZkReceiptOperations == null) {
synchronized (LOCK) {
if (clientZkReceiptOperations == null) {
clientZkReceiptOperations = provider.provideClientZkReceiptOperations();
}
}
}
return clientZkReceiptOperations;
}
public static @NonNull DeadlockDetector getDeadlockDetector() {
if (deadlockDetector == null) {
synchronized (LOCK) {
@@ -653,5 +666,6 @@ public class ApplicationDependencies {
@NonNull AudioManagerCompat provideAndroidCallAudioManager();
@NonNull DonationsService provideDonationsService();
@NonNull DeadlockDetector provideDeadlockDetector();
@NonNull ClientZkReceiptOperations provideClientZkReceiptOperations();
}
}
@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
import org.signal.core.util.concurrent.DeadlockDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.zkgroup.receipts.ClientZkReceiptOperations;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender;
@@ -322,6 +323,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
return new DeadlockDetector(new Handler(handlerThread.getLooper()), TimeUnit.SECONDS.toMillis(5));
}
@Override
public @NonNull ClientZkReceiptOperations provideClientZkReceiptOperations() {
return provideClientZkOperations().getReceiptOperations();
}
private @NonNull WebSocketFactory provideWebSocketFactory(@NonNull SignalWebSocketHealthMonitor healthMonitor) {
return new WebSocketFactory() {
@Override
@@ -21,6 +21,7 @@ import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@@ -60,7 +61,7 @@ public final class GroupCandidateHelper {
if (profileKey != null) {
Log.i(TAG, String.format("No profile key credential on recipient %s, fetching", recipient.getId()));
Optional<ProfileKeyCredential> profileKeyCredentialOptional = signalServiceAccountManager.resolveProfileKeyCredential(uuid, profileKey);
Optional<ProfileKeyCredential> profileKeyCredentialOptional = signalServiceAccountManager.resolveProfileKeyCredential(uuid, profileKey, Locale.getDefault());
if (profileKeyCredentialOptional.isPresent()) {
boolean updatedProfileKey = recipientDatabase.setProfileKeyCredential(recipient.getId(), profileKey, profileKeyCredentialOptional.get());
@@ -38,7 +38,8 @@ import java.util.List;
public class HelpFragment extends LoggingFragment {
public static final String START_CATEGORY_INDEX = "start_category_index";
public static final int PAYMENT_INDEX = 5;
public static final int PAYMENT_INDEX = 6;
public static final int DONATION_INDEX = 7;
private EditText problem;
private CheckBox includeDebugLogs;
@@ -92,7 +93,7 @@ public class HelpFragment extends LoggingFragment {
emoji.add(view.findViewById(feeling.getViewId()));
}
categoryAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.HelpFragment__categories, android.R.layout.simple_spinner_item);
categoryAdapter = ArrayAdapter.createFromResource(requireContext(), R.array.HelpFragment__categories_2, android.R.layout.simple_spinner_item);
categoryAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
categorySpinner.setAdapter(categoryAdapter);
@@ -208,7 +209,7 @@ public class HelpFragment extends LoggingFragment {
suffix.append(getString(feeling.getStringId()));
}
String[] englishCategories = ResourceUtil.getEnglishResources(getContext()).getStringArray(R.array.HelpFragment__categories);
String[] englishCategories = ResourceUtil.getEnglishResources(requireContext()).getStringArray(R.array.HelpFragment__categories_2);
String category = (helpViewModel.getCategoryIndex() >= 0 && helpViewModel.getCategoryIndex() < englishCategories.length) ? englishCategories[helpViewModel.getCategoryIndex()]
: categoryAdapter.getItem(helpViewModel.getCategoryIndex()).toString();
@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* Job to redeem a verified donation receipt. It is up to the Job prior in the chain to specify a valid
* presentation object via setOutputData. This is expected to be the byte[] blob of a ReceiptCredentialPresentation object.
*/
public class DonationReceiptRedemptionJob extends BaseJob {
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
public static final String KEY = "DonationReceiptRedemptionJob";
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
public static DonationReceiptRedemptionJob createJob() {
return new DonationReceiptRedemptionJob(
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("ReceiptRedemption")
.setMaxAttempts(Parameters.UNLIMITED)
.setMaxInstancesForQueue(1)
.setLifespan(TimeUnit.DAYS.toMillis(7))
.build());
}
private DonationReceiptRedemptionJob(@NonNull Job.Parameters parameters) {
super(parameters);
}
@Override
public @NonNull Data serialize() {
return Data.EMPTY;
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
}
@Override
protected void onRun() throws Exception {
Data inputData = getInputData();
if (inputData == null) {
Log.w(TAG, "No input data. Failing.");
throw new IllegalStateException("Expected a presentation object in input data.");
}
byte[] presentationBytes = inputData.getStringAsBlob(INPUT_RECEIPT_CREDENTIAL_PRESENTATION);
if (presentationBytes == null) {
Log.d(TAG, "No response data. Exiting.");
return;
}
ReceiptCredentialPresentation presentation = new ReceiptCredentialPresentation(presentationBytes);
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
.redeemReceipt(presentation, false, false)
.blockingGet();
if (response.getApplicationError().isPresent()) {
Log.w(TAG, "Encountered a non-recoverable exception", response.getApplicationError().get());
throw new IOException(response.getApplicationError().get());
} else if (response.getExecutionError().isPresent()) {
Log.w(TAG, "Encountered a retryable exception", response.getExecutionError().get());
throw new RetryableException();
}
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof RetryableException;
}
private final static class RetryableException extends Exception {
}
public static class Factory implements Job.Factory<DonationReceiptRedemptionJob> {
@Override
public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new DonationReceiptRedemptionJob(parameters);
}
}
}
@@ -167,6 +167,9 @@ public final class JobManagerFactories {
put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
put(TypingSendJob.KEY, new TypingSendJob.Factory());
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory());
put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory());
put(DonationReceiptRedemptionJob.KEY, new DonationReceiptRedemptionJob.Factory());
// Migrations
put(AccountRecordMigrationJob.KEY, new AccountRecordMigrationJob.Factory());
@@ -193,7 +196,7 @@ public final class JobManagerFactories {
put(StickerLaunchMigrationJob.KEY, new StickerLaunchMigrationJob.Factory());
put(StickerAdditionMigrationJob.KEY, new StickerAdditionMigrationJob.Factory());
put(StickerDayByDayMigrationJob.KEY, new StickerDayByDayMigrationJob.Factory());
put(StickerMyDailyLifeMigrationJob.KEY, new StickerMyDailyLifeMigrationJob.Factory());
put(StickerMyDailyLifeMigrationJob.KEY, new StickerMyDailyLifeMigrationJob.Factory());
put(StorageCapabilityMigrationJob.KEY, new StorageCapabilityMigrationJob.Factory());
put(StorageServiceMigrationJob.KEY, new StorageServiceMigrationJob.Factory());
put(TrimByLengthSettingsMigrationJob.KEY, new TrimByLengthSettingsMigrationJob.Factory());
@@ -10,6 +10,7 @@ import org.signal.core.util.logging.Log;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.badges.Badges;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -176,25 +177,7 @@ public class RefreshOwnProfileJob extends BaseJob {
DatabaseFactory.getRecipientDatabase(context)
.setBadges(Recipient.self().getId(),
badges.stream().map(RefreshOwnProfileJob::adaptFromServiceBadge).collect(Collectors.toList()));
}
private static Badge adaptFromServiceBadge(@NonNull SignalServiceProfile.Badge serviceBadge) {
Pair<Uri, String> uriAndDensity = RetrieveProfileJob.getBestBadgeImageUriForDevice(serviceBadge);
return new Badge(
serviceBadge.getId(),
Badge.Category.Companion.fromCode(serviceBadge.getCategory()),
serviceBadge.getName(),
serviceBadge.getDescription(),
uriAndDensity.first(),
uriAndDensity.second(),
getTimestamp(serviceBadge.getExpiration()),
serviceBadge.isVisible()
);
}
private static long getTimestamp(@NonNull BigDecimal bigDecimal) {
return new Timestamp(bigDecimal.longValue() * 1000).getTime();
badges.stream().map(Badges::fromServiceBadge).collect(Collectors.toList()));
}
public static final class Factory implements Job.Factory<RefreshOwnProfileJob> {
@@ -17,6 +17,7 @@ import org.signal.core.util.logging.Log;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.badges.Badges;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -354,47 +355,7 @@ public class RetrieveProfileJob extends BaseJob {
DatabaseFactory.getRecipientDatabase(context)
.setBadges(recipient.getId(),
badges.stream().map(RetrieveProfileJob::adaptFromServiceBadge).collect(java.util.stream.Collectors.toList()));
}
private static Badge adaptFromServiceBadge(@NonNull SignalServiceProfile.Badge serviceBadge) {
Pair<Uri, String> uriAndDensity = RetrieveProfileJob.getBestBadgeImageUriForDevice(serviceBadge);
return new Badge(
serviceBadge.getId(),
Badge.Category.Companion.fromCode(serviceBadge.getCategory()),
serviceBadge.getName(),
serviceBadge.getDescription(),
uriAndDensity.first(),
uriAndDensity.second(),
0L,
true
);
}
public static @NonNull Pair<Uri, String> getBestBadgeImageUriForDevice(@NonNull SignalServiceProfile.Badge serviceBadge) {
String bestDensity = ScreenDensity.getBestDensityBucketForDevice();
switch (bestDensity) {
case "ldpi":
return new Pair<>(getBadgeImageUri(serviceBadge.getLdpiUri()), "ldpi");
case "mdpi":
return new Pair<>(getBadgeImageUri(serviceBadge.getMdpiUri()), "mdpi");
case "hdpi":
return new Pair<>(getBadgeImageUri(serviceBadge.getHdpiUri()), "hdpi");
case "xxhdpi":
return new Pair<>(getBadgeImageUri(serviceBadge.getXxhdpiUri()), "xxhdpi");
case "xxxhdpi":
return new Pair<>(getBadgeImageUri(serviceBadge.getXxxhdpiUri()), "xxxhdpi");
default:
return new Pair<>(getBadgeImageUri(serviceBadge.getXhdpiUri()), "xdpi");
}
}
private static @NonNull Uri getBadgeImageUri(@NonNull String densityPath) {
return Uri.parse(BuildConfig.BADGE_STATIC_ROOT).buildUpon()
.appendPath(densityPath)
.build();
badges.stream().map(Badges::fromServiceBadge).collect(java.util.stream.Collectors.toList()));
}
private void setProfileKeyCredential(@NonNull Recipient recipient,
@@ -0,0 +1,111 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.subscription.Subscriber;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* Job that, once there is a valid local subscriber id, should be run every 3 days
* to ensure that a user's subscription does not lapse.
*/
public class SubscriptionKeepAliveJob extends BaseJob {
public static final String KEY = "SubscriptionKeepAliveJob";
private static final String TAG = Log.tag(SubscriptionKeepAliveJob.class);
private static final long JOB_TIMEOUT = TimeUnit.DAYS.toMillis(3);
public SubscriptionKeepAliveJob() {
this(new Parameters.Builder()
.setQueue(KEY)
.addConstraint(NetworkConstraint.KEY)
.setMaxInstancesForQueue(1)
.setLifespan(JOB_TIMEOUT)
.build());
}
private SubscriptionKeepAliveJob(@NonNull Parameters parameters) {
super(parameters);
}
@Override
public @NonNull Data serialize() {
return Data.EMPTY;
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
}
@Override
protected void onRun() throws Exception {
Subscriber subscriber = SignalStore.donationsValues().getSubscriber();
if (subscriber == null) {
return;
}
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
.putSubscription(subscriber.getSubscriberId())
.blockingGet();
if (!response.getResult().isPresent()) {
if (response.getStatus() == 403) {
Log.w(TAG, "Response code 403, possibly corrupted subscription id.");
// TODO [alex] - Probably need some UX around this, or some kind of protocol.
}
throw new IOException("Failed to ping subscription service.");
}
ServiceResponse<ActiveSubscription> activeSubscriptionResponse = ApplicationDependencies.getDonationsService()
.getSubscription(subscriber.getSubscriberId())
.blockingGet();
if (!response.getResult().isPresent()) {
throw new IOException("Failed to perform active subscription check");
}
ActiveSubscription activeSubscription = activeSubscriptionResponse.getResult().get();
if (activeSubscription.getActiveSubscription() == null || !activeSubscription.getActiveSubscription().isActive()) {
Log.i(TAG, "User does not have an active subscription. Exiting.");
return;
}
if (activeSubscription.getActiveSubscription().getEndOfCurrentPeriod() > SignalStore.donationsValues().getLastEndOfPeriod()) {
Log.i(TAG, "Last end of period change. Requesting receipt refresh.");
SubscriptionReceiptRequestResponseJob.enqueueSubscriptionContinuation();
}
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return true;
}
public static class Factory implements Job.Factory<SubscriptionKeepAliveJob> {
@Override
public @NonNull SubscriptionKeepAliveJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new SubscriptionKeepAliveJob(parameters);
}
}
}
@@ -0,0 +1,247 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.logging.Log;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.zkgroup.receipts.ReceiptCredential;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.zkgroup.receipts.ReceiptCredentialRequestContext;
import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.subscription.Subscriber;
import org.thoughtcrime.securesms.subscription.SubscriptionNotification;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey;
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
* we get a response.
*/
public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private static final String TAG = Log.tag(SubscriptionReceiptRequestResponseJob.class);
public static final String KEY = "SubscriptionReceiptCredentialsSubmissionJob";
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
private static final String DATA_SUBSCRIBER_ID = "data.subscriber.id";
private ReceiptCredentialRequestContext requestContext;
private final SubscriberId subscriberId;
static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId) {
return new SubscriptionReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("ReceiptRedemption")
.setMaxInstancesForQueue(1)
.setLifespan(TimeUnit.DAYS.toMillis(7))
.build(),
null,
subscriberId
);
}
public static Pair<String, String> enqueueSubscriptionContinuation() {
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId());
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJob();
ApplicationDependencies.getJobManager()
.startChain(requestReceiptJob)
.then(redeemReceiptJob)
.enqueue();
return new Pair<>(requestReceiptJob.getId(), redeemReceiptJob.getId());
}
private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters,
@Nullable ReceiptCredentialRequestContext requestContext,
@NonNull SubscriberId subscriberId)
{
super(parameters);
this.requestContext = requestContext;
this.subscriberId = subscriberId;
}
@Override
public @NonNull Data serialize() {
Data.Builder builder = new Data.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes());
if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
}
return builder.build();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
SubscriptionNotification.VerificationFailed.INSTANCE.show(context);
}
@Override
protected void onRun() throws Exception {
ActiveSubscription.Subscription subscription = getLatestSubscriptionInformation();
if (subscription == null || !subscription.isActive()) {
Log.d(TAG, "User does not have an active subscription. Exiting.");
return;
} else {
Log.i(TAG, "Recording end of period from active subscription.");
SignalStore.donationsValues().setLastEndOfPeriod(subscription.getEndOfCurrentPeriod());
}
if (requestContext == null) {
SecureRandom secureRandom = new SecureRandom();
byte[] randomBytes = new byte[ReceiptSerial.SIZE];
secureRandom.nextBytes(randomBytes);
ReceiptSerial receiptSerial = new ReceiptSerial(randomBytes);
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
requestContext = operations.createReceiptCredentialRequestContext(secureRandom, receiptSerial);
}
ServiceResponse<ReceiptCredentialResponse> response = ApplicationDependencies.getDonationsService()
.submitReceiptCredentialRequest(subscriberId, requestContext.getRequest())
.blockingGet();
if (response.getApplicationError().isPresent()) {
if (response.getStatus() == 204) {
Log.w(TAG, "User does not have receipts available to exchange. Exiting.", response.getApplicationError().get());
} else {
Log.w(TAG, "Encountered a permanent failure: " + response.getStatus(), response.getApplicationError().get());
throw new Exception(response.getApplicationError().get());
}
} else if (response.getResult().isPresent()) {
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
if (!isCredentialValid(subscription, receiptCredential)) {
throw new IOException("Could not validate receipt credential");
}
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
setOutputData(new Data.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
receiptCredentialPresentation.serialize())
.build());
} else {
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orNull());
throw new RetryableException();
}
}
private @Nullable ActiveSubscription.Subscription getLatestSubscriptionInformation() throws Exception {
ServiceResponse<ActiveSubscription> activeSubscription = ApplicationDependencies.getDonationsService()
.getSubscription(subscriberId)
.blockingGet();
if (activeSubscription.getResult().isPresent()) {
return activeSubscription.getResult().get().getActiveSubscription();
} else if (activeSubscription.getApplicationError().isPresent()) {
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.");
throw new IOException(activeSubscription.getApplicationError().get());
} else {
throw new RetryableException();
}
}
private ReceiptCredentialPresentation getReceiptCredentialPresentation(@NonNull ReceiptCredential receiptCredential) throws RetryableException {
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
try {
return operations.createReceiptCredentialPresentation(receiptCredential);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredentialPresentation: encountered a verification failure in zk", e);
requestContext = null;
throw new RetryableException();
}
}
private ReceiptCredential getReceiptCredential(@NonNull ReceiptCredentialResponse response) throws RetryableException {
ClientZkReceiptOperations operations = ApplicationDependencies.getClientZkReceiptOperations();
try {
return operations.receiveReceiptCredential(requestContext, response);
} catch (VerificationFailedException e) {
Log.w(TAG, "getReceiptCredential: encountered a verification failure in zk", e);
requestContext = null;
throw new RetryableException();
}
}
/**
* Checks that the generated Receipt Credential has the following characteristics
* - level should match the current subscription level and be the same level you signed up for at the time the subscription was last updated
* - expiration time should have the following characteristics:
* - expiration_time mod 86400 == 0
* - expiration_time is between now and 60 days from now
*/
private boolean isCredentialValid(@NonNull ActiveSubscription.Subscription subscription, @NonNull ReceiptCredential receiptCredential) {
long now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
long monthFromNow = now + TimeUnit.DAYS.toSeconds(60);
boolean isSameLevel = subscription.getLevel() == receiptCredential.getReceiptLevel();
boolean isExpirationAfterSub = subscription.getEndOfCurrentPeriod() < receiptCredential.getReceiptExpirationTime();
boolean isExpiration86400 = receiptCredential.getReceiptExpirationTime() % 86400 == 0;
boolean isExpirationInTheFuture = receiptCredential.getReceiptExpirationTime() > now;
boolean isExpirationWithinAMonth = receiptCredential.getReceiptExpirationTime() < monthFromNow;
return isSameLevel && isExpirationAfterSub && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinAMonth;
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof RetryableException;
}
@VisibleForTesting
final static class RetryableException extends Exception {
}
public static class Factory implements Job.Factory<SubscriptionReceiptRequestResponseJob> {
@Override
public @NonNull SubscriptionReceiptRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) {
SubscriberId subscriberId = SubscriberId.fromBytes(data.getStringAsBlob(DATA_SUBSCRIBER_ID));
try {
if (data.hasString(DATA_REQUEST_BYTES)) {
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
return new SubscriptionReceiptRequestResponseJob(parameters, requestContext, subscriberId);
} else {
return new SubscriptionReceiptRequestResponseJob(parameters, null, subscriberId);
}
} catch (InvalidInputException e) {
throw new IllegalStateException(e);
}
}
}
}
@@ -6,25 +6,42 @@ import io.reactivex.rxjava3.subjects.Subject
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.util.Currency
import java.util.Locale
internal class DonationsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
companion object {
private const val KEY_CURRENCY_CODE = "donation.currency.code"
private const val KEY_SUBSCRIPTION_CURRENCY_CODE = "donation.currency.code"
private const val KEY_CURRENCY_CODE_BOOST = "donation.currency.code.boost"
private const val KEY_SUBSCRIBER_ID_PREFIX = "donation.subscriber.id."
private const val KEY_IDEMPOTENCY = "donation.idempotency.key"
private const val KEY_LEVEL = "donation.level"
private const val KEY_LAST_KEEP_ALIVE_LAUNCH = "donation.last.successful.ping"
private const val KEY_LAST_END_OF_PERIOD = "donation.last.end.of.period"
}
override fun onFirstEverAppLaunch() = Unit
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(KEY_CURRENCY_CODE)
override fun getKeysToIncludeInBackup(): MutableList<String> = mutableListOf(KEY_SUBSCRIPTION_CURRENCY_CODE, KEY_LAST_KEEP_ALIVE_LAUNCH)
private val currencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getCurrency()) }
val observableCurrency: Observable<Currency> by lazy { currencyPublisher }
private val subscriptionCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) }
val observableSubscriptionCurrency: Observable<Currency> by lazy { subscriptionCurrencyPublisher }
fun getCurrency(): Currency {
val currencyCode = getString(KEY_CURRENCY_CODE, null)
private val boostCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getBoostCurrency()) }
val observableBoostCurrency: Observable<Currency> by lazy { boostCurrencyPublisher }
private val levelUpdateOperationPublisher: Subject<Optional<LevelUpdateOperation>> by lazy { BehaviorSubject.createDefault(Optional.fromNullable(getLevelOperation())) }
val levelUpdateOperationObservable: Observable<Optional<LevelUpdateOperation>> by lazy { levelUpdateOperationPublisher }
fun getSubscriptionCurrency(): Currency {
val currencyCode = getString(KEY_SUBSCRIPTION_CURRENCY_CODE, null)
val currency: Currency? = if (currencyCode == null) {
val localeCurrency = CurrencyUtil.getCurrencyByLocale(Locale.getDefault())
if (localeCurrency == null) {
@@ -48,8 +65,105 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
}
}
fun setCurrency(currency: Currency) {
putString(KEY_CURRENCY_CODE, currency.currencyCode)
currencyPublisher.onNext(currency)
fun getBoostCurrency(): Currency {
val boostCurrencyCode = getString(KEY_CURRENCY_CODE_BOOST, null)
return if (boostCurrencyCode == null) {
val currency = getSubscriptionCurrency()
setBoostCurrency(currency)
currency
} else {
Currency.getInstance(boostCurrencyCode)
}
}
fun setSubscriptionCurrency(currency: Currency) {
putString(KEY_SUBSCRIPTION_CURRENCY_CODE, currency.currencyCode)
subscriptionCurrencyPublisher.onNext(currency)
}
fun setBoostCurrency(currency: Currency) {
putString(KEY_CURRENCY_CODE_BOOST, currency.currencyCode)
boostCurrencyPublisher.onNext(currency)
}
fun getSubscriber(): Subscriber? {
val currencyCode = getSubscriptionCurrency().currencyCode
val subscriberIdBytes = getBlob("$KEY_SUBSCRIBER_ID_PREFIX$currencyCode", null)
return if (subscriberIdBytes == null) {
null
} else {
Subscriber(SubscriberId.fromBytes(subscriberIdBytes), currencyCode)
}
}
fun requireSubscriber(): Subscriber {
return getSubscriber() ?: throw Exception("Subscriber ID is not set.")
}
fun setSubscriber(subscriber: Subscriber) {
val currencyCode = subscriber.currencyCode
putBlob("$KEY_SUBSCRIBER_ID_PREFIX$currencyCode", subscriber.subscriberId.bytes)
}
fun getLevelOperation(): LevelUpdateOperation? {
val level = getString(KEY_LEVEL, null)
val idempotencyKey = getIdempotencyKey()
return if (level == null || idempotencyKey == null) {
null
} else {
LevelUpdateOperation(idempotencyKey, level)
}
}
fun setLevelOperation(levelUpdateOperation: LevelUpdateOperation) {
putString(KEY_LEVEL, levelUpdateOperation.level)
setIdempotencyKey(levelUpdateOperation.idempotencyKey)
dispatchLevelOperation()
}
fun clearLevelOperation(levelUpdateOperation: LevelUpdateOperation): Boolean {
val currentKey = getIdempotencyKey()
return if (currentKey == levelUpdateOperation.idempotencyKey) {
clearLevelOperation()
true
} else {
false
}
}
private fun clearLevelOperation() {
remove(KEY_IDEMPOTENCY)
remove(KEY_LEVEL)
dispatchLevelOperation()
}
private fun getIdempotencyKey(): IdempotencyKey? {
return getBlob(KEY_IDEMPOTENCY, null)?.let { IdempotencyKey.fromBytes(it) }
}
private fun setIdempotencyKey(key: IdempotencyKey) {
putBlob(KEY_IDEMPOTENCY, key.bytes)
}
fun getLastKeepAliveLaunchTime(): Long {
return getLong(KEY_LAST_KEEP_ALIVE_LAUNCH, 0L)
}
fun setLastKeepAliveLaunchTime(timestamp: Long) {
putLong(KEY_LAST_KEEP_ALIVE_LAUNCH, timestamp)
}
fun getLastEndOfPeriod(): Long {
return getLong(KEY_LAST_END_OF_PERIOD, 0L)
}
fun setLastEndOfPeriod(timestamp: Long) {
putLong(KEY_LAST_END_OF_PERIOD, timestamp)
}
private fun dispatchLevelOperation() {
levelUpdateOperationPublisher.onNext(Optional.fromNullable(getLevelOperation()))
}
}
@@ -13,6 +13,7 @@ public final class NotificationIds {
public static final int LEGACY_SQLCIPHER_MIGRATION = 494949;
public static final int USER_NOTIFICATION_MIGRATION = 525600;
public static final int DEVICE_TRANSFER = 625420;
public static final int SUBSCRIPTION_VERIFY_FAILED = 630001;
private NotificationIds() { }
@@ -432,7 +432,7 @@ public class Recipient {
this.systemContactName = details.systemContactName;
this.extras = details.extras;
this.hasGroupsInCommon = details.hasGroupsInCommon;
this.badges = FeatureFlags.donorBadges() ? details.badges : Collections.emptyList();
this.badges = details.badges;
}
public @NonNull RecipientId getId() {
@@ -1028,14 +1028,14 @@ public class Recipient {
}
public @NonNull List<Badge> getBadges() {
return badges;
return FeatureFlags.donorBadges() ? badges : Collections.emptyList();
}
public @Nullable Badge getFeaturedBadge() {
if (badges.isEmpty()) {
if (getBadges().isEmpty()) {
return null;
} else {
return badges.get(0);
return getBadges().get(0);
}
}
@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.service;
import android.content.Context;
import android.content.Intent;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import java.util.concurrent.TimeUnit;
/**
* Manages the scheduling of jobs for keeping a subscription id alive.
*/
public class SubscriberIdKeepAliveListener extends PersistentAlarmManagerListener {
private static final long INTERVAL = TimeUnit.DAYS.toMillis(3);
@Override
protected long getNextScheduledExecutionTime(Context context) {
return SignalStore.donationsValues().getLastKeepAliveLaunchTime() + INTERVAL;
}
@Override
protected long onAlarm(Context context, long scheduledTime) {
if (SignalStore.donationsValues().getSubscriber() != null) {
ApplicationDependencies.getJobManager().add(new SubscriptionKeepAliveJob());
}
long now = System.currentTimeMillis();
SignalStore.donationsValues().setLastKeepAliveLaunchTime(now);
return now + INTERVAL;
}
public static void schedule(Context context) {
new SubscriberIdKeepAliveListener().onReceive(context, new Intent());
}
}
@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.subscription
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
/**
* Binds a Subscription level update with an idempotency key.
*
* We are to use the same idempotency key whenever we want to retry updating to a particular level.
*/
data class LevelUpdateOperation(
val idempotencyKey: IdempotencyKey,
val level: String
)
@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.subscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
data class Subscriber(
val subscriberId: SubscriberId,
val currencyCode: String
)
@@ -23,10 +23,9 @@ data class Subscription(
val title: String,
val badge: Badge,
val price: FiatMoney,
val level: Int,
) {
val renewalTimestamp = badge.expirationTimestamp
companion object {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_preference))
@@ -37,8 +36,10 @@ data class Subscription(
val subscription: Subscription,
val isSelected: Boolean,
val isActive: Boolean,
val willRenew: Boolean,
override val isEnabled: Boolean,
val onClick: () -> Unit
val onClick: () -> Unit,
val renewalTimestamp: Long
) : PreferenceModel<Model>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: Model): Boolean {
@@ -49,7 +50,17 @@ data class Subscription(
return super.areContentsTheSame(newItem) &&
newItem.subscription == subscription &&
newItem.isSelected == isSelected &&
newItem.isActive == isActive
newItem.isActive == isActive &&
newItem.renewalTimestamp == renewalTimestamp &&
newItem.willRenew == willRenew
}
override fun getChangePayload(newItem: Model): Any? {
return if (newItem.subscription.badge == subscription.badge) {
Unit
} else {
null
}
}
}
@@ -65,9 +76,13 @@ data class Subscription(
itemView.isEnabled = model.isEnabled
itemView.setOnClickListener { model.onClick() }
itemView.isSelected = model.isSelected
badge.setBadge(model.subscription.badge)
if (payload.isEmpty()) {
badge.setBadge(model.subscription.badge)
}
title.text = model.subscription.title
tagline.text = model.subscription.id
tagline.text = context.getString(R.string.Subscription__earn_a_s_badge, model.subscription.badge.name)
val formattedPrice = FiatMoneyUtil.format(
context.resources,
@@ -75,11 +90,17 @@ data class Subscription(
FiatMoneyUtil.formatOptions()
)
if (model.isActive) {
if (model.isActive && model.willRenew) {
price.text = context.getString(
R.string.Subscription__s_per_month_dot_renews_s,
formattedPrice,
DateUtils.formatDate(Locale.getDefault(), model.subscription.renewalTimestamp)
DateUtils.formatDateWithYear(Locale.getDefault(), model.renewalTimestamp)
)
} else if (model.isActive) {
price.text = context.getString(
R.string.Subscription__s_per_month_dot_expires_s,
formattedPrice,
DateUtils.formatDateWithYear(Locale.getDefault(), model.renewalTimestamp)
)
} else {
price.text = context.getString(
@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.subscription
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
sealed class SubscriptionNotification {
object VerificationFailed : SubscriptionNotification() {
override fun show(context: Context) {
val notification = NotificationCompat.Builder(context, NotificationChannels.FAILURES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.Subscription__verification_failed))
.setContentText(context.getString(R.string.Subscription__please_contact_support_for_more_information))
.addAction(
NotificationCompat.Action.Builder(
null,
context.getString(R.string.Subscription__contact_support),
PendingIntent.getActivity(
context,
0,
AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX),
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_ONE_SHOT else 0
)
).build()
)
.build()
NotificationManagerCompat
.from(context)
.notify(NotificationIds.SUBSCRIPTION_VERIFY_FAILED, notification)
}
}
abstract fun show(context: Context)
}
@@ -1,7 +1,20 @@
package org.thoughtcrime.securesms.util
import com.google.android.gms.wallet.WalletConstants
import org.signal.donations.GooglePayApi
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.BuildConfig
object Environment {
const val IS_STAGING: Boolean = BuildConfig.BUILD_ENVIRONMENT_TYPE == "Staging"
object Donations {
val GOOGLE_PAY_CONFIGURATION = GooglePayApi.Configuration(
walletEnvironment = if (IS_STAGING) WalletConstants.ENVIRONMENT_TEST else WalletConstants.ENVIRONMENT_PRODUCTION
)
val STRIPE_CONFIGURATION = StripeApi.Configuration(
publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY
)
}
}
@@ -397,12 +397,17 @@ public final class FeatureFlags {
return getBoolean(CHANGE_NUMBER_ENABLED, false);
}
/** Whether or not to show donor badges in the UI. */
/** Whether or not to show donor badges in the UI.
*
* WARNING: Donor Badges is an unfinished feature and should not be enabled in production builds.
* Enabling this flag in a custom build can result in crashes and could result in your Google Pay
* account being charged real money.
*/
public static boolean donorBadges() {
if (Environment.IS_STAGING) {
return true;
} else {
return getBoolean(DONOR_BADGES, false);
return getBoolean(DONOR_BADGES, false ) || SignalStore.donationsValues().getSubscriber() != null;
}
}
@@ -44,6 +44,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.core.Single;
@@ -100,7 +101,7 @@ public final class ProfileUtil {
Optional<ProfileKey> profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey());
return Single.fromCallable(() -> toSignalServiceAddress(context, recipient))
.flatMap(address -> profileService.getProfile(address, profileKey, unidentifiedAccess, requestType).map(p -> new Pair<>(recipient, p)))
.flatMap(address -> profileService.getProfile(address, profileKey, unidentifiedAccess, requestType, Locale.getDefault()).map(p -> new Pair<>(recipient, p)))
.onErrorReturn(t -> new Pair<>(recipient, ServiceResponse.forUnknownError(t)));
}
@@ -16,6 +16,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import java.io.IOException;
import java.util.Locale;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -67,7 +68,7 @@ public class UsernameUtil {
try {
Log.d(TAG, "No local user with this username. Searching remotely.");
SignalServiceProfile profile = ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfileByUsername(username, Optional.absent());
SignalServiceProfile profile = ApplicationDependencies.getSignalServiceMessageReceiver().retrieveProfileByUsername(username, Optional.absent(), Locale.getDefault());
return Optional.fromNullable(profile.getUuid());
} catch (IOException e) {
return Optional.absent();
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:importantForAccessibility="no"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/bottom_sheet_handle" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/handle" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:indeterminate="true" />
</FrameLayout>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="80dp"
android:layout_marginEnd="80dp"
app:cardCornerRadius="12dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="64dp"
android:paddingBottom="64dp">
<ProgressBar
android:id="@+id/processing_card_progress"
style="?android:attr/progressBarStyleLarge"
android:layout_width="48dp"
android:layout_height="48dp"
android:indeterminate="true"
app:layout_constraintBottom_toTopOf="@id/processing_card_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/processing_card_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:text="@string/SubscribeFragment__processing_payment"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/processing_card_progress" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/dsl_settings_toolbar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -26,6 +26,7 @@
android:layout_width="33dp"
android:layout_height="33dp"
android:contentDescription="@string/BadgesOverviewFragment__featured_badge"
app:badge_size="large"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="@id/avatar" />
@@ -55,6 +55,16 @@
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_subhead"
tools:src="@drawable/test_gradient" />
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/thanks_bottom_sheet_lottie"
android:layout_width="112dp"
android:layout_height="112dp"
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_badge"
app:layout_constraintEnd_toEndOf="@id/thanks_bottom_sheet_badge"
app:layout_constraintStart_toStartOf="@id/thanks_bottom_sheet_badge"
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_badge"
app:lottie_rawRes="@raw/lottie_rocket" />
<TextView
android:id="@+id/thanks_bottom_sheet_badge_name"
android:layout_width="0dp"
@@ -74,13 +84,13 @@
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/rounded_outline"
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_display_on_profile"
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_control_text"
app:layout_constraintEnd_toEndOf="@id/thanks_bottom_sheet_switch"
app:layout_constraintStart_toStartOf="@id/thanks_bottom_sheet_display_on_profile"
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_display_on_profile" />
app:layout_constraintStart_toStartOf="@id/thanks_bottom_sheet_control_text"
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_control_text" />
<TextView
android:id="@+id/thanks_bottom_sheet_display_on_profile"
android:id="@+id/thanks_bottom_sheet_control_text"
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
@@ -103,10 +113,24 @@
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_display_on_profile"
app:layout_constraintBottom_toBottomOf="@id/thanks_bottom_sheet_control_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/thanks_bottom_sheet_display_on_profile"
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_display_on_profile" />
app:layout_constraintStart_toEndOf="@id/thanks_bottom_sheet_control_text"
app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_control_text" />
<TextView
android:id="@+id/thanks_bottom_sheet_featured_note"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="40dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="40dp"
android:text="@string/ThanksForYourSupportBottomSheetFragment__when_you_have_more"
android:textAppearance="@style/Signal.Text.Caption"
android:textColor="@color/signal_text_secondary"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_control_outline"
tools:visibility="visible" />
<com.google.android.material.button.MaterialButton
android:id="@+id/thanks_bottom_sheet_done"
@@ -114,11 +138,12 @@
android:layout_width="0dp"
android:layout_height="48sp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="36dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:text="@string/SubscribeThanksForYourSupportBottomSheetDialogFragment__done"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_display_on_profile" />
app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_featured_note"
app:layout_goneMarginTop="36dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -452,6 +452,14 @@
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_direct_to_subscriptions"
app:destination="@id/subscriptions"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<!-- endregion -->
<!-- Internal Settings -->
+6 -1
View File
@@ -26,7 +26,12 @@
android:id="@+id/setDonationCurrencyFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.currency.SetCurrencyFragment"
android:label="set_currency_fragment"
tools:layout="@layout/dsl_settings_fragment" />
tools:layout="@layout/dsl_settings_fragment">
<argument
android:name="isBoost"
app:argType="boolean" />
</dialog>
<dialog
android:id="@+id/boostThanksForYourSupportBottomSheetDialog"
@@ -67,7 +67,13 @@
android:id="@+id/setDonationCurrencyFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.currency.SetCurrencyFragment"
android:label="set_currency_fragment"
tools:layout="@layout/dsl_settings_fragment" />
tools:layout="@layout/dsl_settings_fragment" >
<argument
android:name="isBoost"
app:argType="boolean" />
</dialog>
<dialog
android:id="@+id/subscribeLearnMoreBottomSheetDialog"
File diff suppressed because one or more lines are too long
+23 -2
View File
@@ -2251,14 +2251,15 @@
<string name="HelpFragment__debug_log">Debug Log:</string>
<string name="HelpFragment__could_not_upload_logs">Could not upload logs</string>
<string name="HelpFragment__please_be_as_descriptive_as_possible">Please be as descriptive as possible to help us understand the issue.</string>
<string-array name="HelpFragment__categories">
<string-array name="HelpFragment__categories_2">
<item>-- Please select an option --</item>
<item>Something\'s Not Working</item>
<item>Feature Request</item>
<item>Question</item>
<item>Feedback</item>
<item>Other</item>
<item>Payments</item>
<item>Payments (MobileCoin)</item>
<item>Donations (Sustainers &amp; Signal Boost)</item>
</string-array>
<!-- ReactWithAnyEmojiBottomSheetDialogFragment -->
@@ -3893,9 +3894,13 @@
<string name="SubscribeFragment__confirm">Confirm</string>
<string name="SubscribeFragment__update_subscription">Update Subscription</string>
<string name="SubscribeFragment__your_subscription_has_been_cancelled">Your subscription has been cancelled.</string>
<string name="SubscribeFragment__update_subscription_question">Update subscription?</string>
<string name="SubscribeFragment__update">Update</string>
<string name="SubscribeFragment__you_will_be_charged_the_full_amount">You will be charged the full amount of the new subscription price today. Your subscription will renew %1$s.</string>
<string name="Subscription__s_per_month">%s/month</string>
<string name="Subscription__s_per_month_dot_renews_s">%1$s/month · Renews %2$s</string>
<string name="Subscription__s_per_month_dot_expires_s">%1$s/month · Expires %2$s</string>
<string name="SubscribeLearnMoreBottomSheetDialogFragment__signal_is_a_non_profit_with_no">Signal is a non-profit with no advertisers or investors, sustained only by the people who use and value it. Make a recurring monthly contribution and receive a profile badge to share your support.</string>
<string name="SubscribeLearnMoreBottomSheetDialogFragment__why_contribute">Why Contribute?</string>
@@ -3908,6 +3913,7 @@
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile">Display on Profile</string>
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge">Make featured badge</string>
<string name="SubscribeThanksForYourSupportBottomSheetDialogFragment__done">Done</string>
<string name="ThanksForYourSupportBottomSheetFragment__when_you_have_more">When you have more than one badge, you can choose one to feature for others to see on your profile.</string>
<string name="ManageDonationsFragment__my_support">My support</string>
<string name="ManageDonationsFragment__manage_subscription">Manage subscription</string>
@@ -3931,6 +3937,21 @@
<string name="ExpiredBadgeBottomSheetDialogFragment__become_a_subscriber">Become a subscriber</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__not_now">Not now</string>
<string name="Subscription__verification_failed">Subscription Verification Failed</string>
<string name="Subscription__please_contact_support_for_more_information">Please contact support for more information.</string>
<string name="Subscription__contact_support">Contact Support</string>
<string name="Subscription__earn_a_s_badge">Earn a %1$s badge</string>
<string name="SubscribeFragment__processing_payment">Processing payment...</string>
<string name="DonationsErrors__payment_failed">Payment failed</string>
<string name="DonationsErrors__your_payment">Your payment couldn\'t be processed and you have not been charged. Please try again.</string>
<string name="DonationsErrors__redemption_still_pending">Redemption still pending</string>
<string name="DonationsErrors__you_might_not_see_your_badge_right_away">You may not see your badge right away, but we\'re working on it!</string>
<string name="DonationsErrors__google_pay_unavailable">Google Pay Unavailable</string>
<string name="DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app">You have to set up Google Pay to donate in-app.</string>
<string name="DonationsErrors__failed_to_cancel_subscription">Failed to cancel subscription</string>
<string name="DonationsErrors__subscription_cancellation_requires_an_internet_connection">Subscription cancellation requires an internet connection.</string>
<!-- EOF -->
</resources>
@@ -1,134 +0,0 @@
package org.thoughtcrime.securesms.badges.glide
import android.app.Application
import android.graphics.Rect
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@Suppress("ClassName")
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class BadgeSpriteTransformationTest__mdpi {
@Test
fun `Given request for large mdpi in light theme, when I getInBounds, then I expect 18x18@1,1`() {
// GIVEN
val density = "mdpi"
val size = BadgeSpriteTransformation.Size.LARGE
val isDarkTheme = false
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 1, 1, 18, 18)
}
@Test
fun `Given request for large mdpi in dark theme, when I getInBounds, then I expect 18x18@21,1`() {
// GIVEN
val density = "mdpi"
val size = BadgeSpriteTransformation.Size.LARGE
val isDarkTheme = true
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 21, 1, 18, 18)
}
@Test
fun `Given request for medium mdpi in light theme, when I getInBounds, then I expect 12x12@41,1`() {
// GIVEN
val density = "mdpi"
val size = BadgeSpriteTransformation.Size.MEDIUM
val isDarkTheme = false
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 41, 1, 12, 12)
}
@Test
fun `Given request for medium mdpi in dark theme, when I getInBounds, then I expect 12x12@55,1`() {
// GIVEN
val density = "mdpi"
val size = BadgeSpriteTransformation.Size.MEDIUM
val isDarkTheme = true
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 55, 1, 12, 12)
}
@Test
fun `Given request for small mdpi in light theme, when I getInBounds, then I expect 8x8@69,1`() {
// GIVEN
val density = "mdpi"
val size = BadgeSpriteTransformation.Size.SMALL
val isDarkTheme = false
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 69, 1, 8, 8)
}
@Test
fun `Given request for small mdpi in dark theme, when I getInBounds, then I expect 8x8@79,1`() {
// GIVEN
val density = "mdpi"
val size = BadgeSpriteTransformation.Size.SMALL
val isDarkTheme = true
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 79, 1, 8, 8)
}
@Test
fun `Given request for xlarge mdpi in light theme, when I getInBounds, then I expect 80x80@89,1`() {
// GIVEN
val density = "mdpi"
val size = BadgeSpriteTransformation.Size.XLARGE
val isDarkTheme = false
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 89, 1, 80, 80)
}
@Test
fun `Given request for xlarge mdpi in dark theme, when I getInBounds, then I expect 80x80@89,1`() {
// GIVEN
val density = "mdpi"
val size = BadgeSpriteTransformation.Size.XLARGE
val isDarkTheme = true
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 89, 1, 80, 80)
}
private fun assertRectMatches(rect: Rect, x: Int, y: Int, width: Int, height: Int) {
assertEquals("Rect has wrong x value", x, rect.left)
assertEquals("Rect has wrong y value", rect.top, y)
assertEquals("Rect has wrong width", width, rect.width())
assertEquals("Rect has wrong height", height, rect.height())
}
}
@@ -1,134 +0,0 @@
package org.thoughtcrime.securesms.badges.glide
import android.app.Application
import android.graphics.Rect
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@Suppress("ClassName")
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class BadgeSpriteTransformationTest__xxxhdpi {
@Test
fun `Given request for large xxxhdpi in light theme, when I getInBounds, then I expect 72x72@1,1`() {
// GIVEN
val density = "xxxhdpi"
val size = BadgeSpriteTransformation.Size.LARGE
val isDarkTheme = false
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 1, 1, 72, 72)
}
@Test
fun `Given request for large xxxhdpi in dark theme, when I getInBounds, then I expect 72x72@21,1`() {
// GIVEN
val density = "xxxhdpi"
val size = BadgeSpriteTransformation.Size.LARGE
val isDarkTheme = true
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 75, 1, 72, 72)
}
@Test
fun `Given request for medium xxxhdpi in light theme, when I getInBounds, then I expect 48x48@149,1`() {
// GIVEN
val density = "xxxhdpi"
val size = BadgeSpriteTransformation.Size.MEDIUM
val isDarkTheme = false
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 149, 1, 48, 48)
}
@Test
fun `Given request for medium xxxhdpi in dark theme, when I getInBounds, then I expect 48x48@199,1`() {
// GIVEN
val density = "xxxhdpi"
val size = BadgeSpriteTransformation.Size.MEDIUM
val isDarkTheme = true
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 199, 1, 48, 48)
}
@Test
fun `Given request for small xxxhdpi in light theme, when I getInBounds, then I expect 32x32@249,1`() {
// GIVEN
val density = "xxxhdpi"
val size = BadgeSpriteTransformation.Size.SMALL
val isDarkTheme = false
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 249, 1, 32, 32)
}
@Test
fun `Given request for small xxxhdpi in dark theme, when I getInBounds, then I expect 32x32@283,1`() {
// GIVEN
val density = "xxxhdpi"
val size = BadgeSpriteTransformation.Size.SMALL
val isDarkTheme = true
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 283, 1, 32, 32)
}
@Test
fun `Given request for xlarge xxxhdpi in light theme, when I getInBounds, then I expect 320x320@317,1`() {
// GIVEN
val density = "xxxhdpi"
val size = BadgeSpriteTransformation.Size.XLARGE
val isDarkTheme = false
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 317, 1, 320, 320)
}
@Test
fun `Given request for xlarge xxxhdpi in dark theme, when I getInBounds, then I expect 320x320@317,1`() {
// GIVEN
val density = "xxxhdpi"
val size = BadgeSpriteTransformation.Size.XLARGE
val isDarkTheme = true
// WHEN
val inBounds = BadgeSpriteTransformation.getInBounds(density, size, isDarkTheme)
// THEN
assertRectMatches(inBounds, 317, 1, 320, 320)
}
private fun assertRectMatches(rect: Rect, x: Int, y: Int, width: Int, height: Int) {
assertEquals("Rect has wrong x value", x, rect.left)
assertEquals("Rect has wrong y value", rect.top, y)
assertEquals("Rect has wrong width", width, rect.width())
assertEquals("Rect has wrong height", height, rect.height())
}
}
@@ -10,6 +10,7 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.gms.wallet.PaymentData;
import com.google.android.gms.wallet.WalletConstants;
import org.signal.core.util.logging.Log;
import org.signal.core.util.money.FiatMoney;
@@ -39,7 +40,7 @@ public class MainActivity extends AppCompatActivity implements GooglePayApi.Paym
donateButton.setVisibility(View.GONE);
donateButton.setOnClickListener(v -> requestPayment());
payApi = new GooglePayApi(this, TestUtil.INSTANCE);
payApi = new GooglePayApi(this, TestUtil.INSTANCE, new GooglePayApi.Configuration(WalletConstants.ENVIRONMENT_TEST));
isReadyToPayDisposable = payApi.queryIsReadyToPay().subscribe(this::presentGooglePayButton, this::presentException);
}
@@ -10,9 +10,7 @@ import com.google.android.gms.wallet.PaymentData
import com.google.android.gms.wallet.PaymentDataRequest
import com.google.android.gms.wallet.PaymentsClient
import com.google.android.gms.wallet.Wallet
import com.google.android.gms.wallet.WalletConstants
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.json.JSONArray
import org.json.JSONException
@@ -26,13 +24,17 @@ import org.signal.core.util.money.FiatMoney
* @param activity The activity the Pay Client will attach itself to
* @param gateway The payment gateway (Such as Stripe)
*/
class GooglePayApi(private val activity: Activity, private val gateway: Gateway) {
class GooglePayApi(
private val activity: Activity,
private val gateway: Gateway,
configuration: Configuration
) {
private val paymentsClient: PaymentsClient
init {
val walletOptions = Wallet.WalletOptions.Builder()
.setEnvironment(PAYMENT_ENVIRONMENT)
.setEnvironment(configuration.walletEnvironment)
.build()
paymentsClient = Wallet.getPaymentsClient(activity, walletOptions)
@@ -166,8 +168,7 @@ class GooglePayApi(private val activity: Activity, private val gateway: Gateway)
companion object {
private val TAG = Log.tag(GooglePayApi::class.java)
private const val PAYMENT_ENVIRONMENT: Int = WalletConstants.ENVIRONMENT_TEST
private const val MERCHANT_NAME = "Signal Foundation"
private const val MERCHANT_NAME = "Signal"
private val merchantInfo: JSONObject =
JSONObject().put("merchantName", MERCHANT_NAME)
@@ -180,6 +181,13 @@ class GooglePayApi(private val activity: Activity, private val gateway: Gateway)
}
}
/**
* @param walletEnvironment From WalletConstants
*/
data class Configuration(
val walletEnvironment: Int
)
interface Gateway {
fun getTokenizationSpecificationParameters(): Map<String, String>
val allowedCardNetworks: List<String>
@@ -14,7 +14,12 @@ import java.io.IOException
import java.math.BigDecimal
import java.util.Locale
class StripeApi(private val configuration: Configuration, private val paymentIntentFetcher: PaymentIntentFetcher, private val okHttpClient: OkHttpClient) {
class StripeApi(
private val configuration: Configuration,
private val paymentIntentFetcher: PaymentIntentFetcher,
private val setupIntentHelper: SetupIntentHelper,
private val okHttpClient: OkHttpClient
) {
sealed class CreatePaymentIntentResult {
data class AmountIsTooSmall(val amount: FiatMoney) : CreatePaymentIntentResult()
@@ -23,6 +28,29 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
data class Success(val paymentIntent: PaymentIntent) : CreatePaymentIntentResult()
}
data class CreateSetupIntentResult(val setupIntent: SetupIntent)
fun createSetupIntent(): Single<CreateSetupIntentResult> {
return setupIntentHelper
.fetchSetupIntent()
.map { CreateSetupIntentResult(it) }
.subscribeOn(Schedulers.io())
}
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Completable = Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mapOf(
"client_secret" to setupIntent.clientSecret,
"payment_method" to paymentMethodId
)
postForm("setup_intents/${setupIntent.id}/confirm", parameters)
paymentMethodId
}.flatMapCompletable {
setupIntentHelper.setDefaultPaymentMethod(it)
}
fun createPaymentIntent(price: FiatMoney, description: String? = null): Single<CreatePaymentIntentResult> {
@Suppress("CascadeIf")
return if (Validation.isAmountTooSmall(price)) {
@@ -39,15 +67,7 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
}
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Completable = Completable.fromAction {
val paymentMethodId = createPaymentMethod(paymentSource).use { response ->
val body = response.body()
if (body != null) {
val paymentMethodObject = body.string().replace("\n", "").let { JSONObject(it) }
paymentMethodObject.getString("id")
} else {
throw IOException("Failed to parse payment method response")
}
}
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mapOf(
"client_secret" to paymentIntent.clientSecret,
@@ -57,6 +77,18 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
postForm("payment_intents/${paymentIntent.id}/confirm", parameters)
}.subscribeOn(Schedulers.io())
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
return createPaymentMethod(paymentSource).use { response ->
val body = response.body()
if (body != null) {
val paymentMethodObject = body.string().replace("\n", "").let { JSONObject(it) }
paymentMethodObject.getString("id")
} else {
throw IOException("Failed to parse payment method response")
}
}
}
private fun createPaymentMethod(paymentSource: PaymentSource): Response {
val tokenizationData = paymentSource.parameterize()
val parameters = mapOf(
@@ -92,11 +124,11 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
private val MAX_AMOUNT = BigDecimal(99_999_999)
fun isAmountTooLarge(fiatMoney: FiatMoney): Boolean {
return fiatMoney.amount > MAX_AMOUNT
return fiatMoney.minimumUnitPrecisionString.toBigDecimal() > MAX_AMOUNT
}
fun isAmountTooSmall(fiatMoney: FiatMoney): Boolean {
return fiatMoney.amount < BigDecimal(minimumIntegralChargePerCurrencyCode[fiatMoney.currency.currencyCode] ?: 50)
return fiatMoney.minimumUnitPrecisionString.toBigDecimal() < BigDecimal(minimumIntegralChargePerCurrencyCode[fiatMoney.currency.currencyCode] ?: 50)
}
private val minimumIntegralChargePerCurrencyCode: Map<String, Int> = mapOf(
@@ -295,11 +327,21 @@ class StripeApi(private val configuration: Configuration, private val paymentInt
): Single<PaymentIntent>
}
interface SetupIntentHelper {
fun fetchSetupIntent(): Single<SetupIntent>
fun setDefaultPaymentMethod(paymentMethodId: String): Completable
}
data class PaymentIntent(
val id: String,
val clientSecret: String
)
data class SetupIntent(
val id: String,
val clientSecret: String
)
interface PaymentSource {
fun parameterize(): JSONObject
}
+1
View File
@@ -47,6 +47,7 @@ dependencies {
testImplementation testLibs.junit.junit
testImplementation testLibs.assertj.core
testImplementation testLibs.conscrypt.openjdk.uber
testImplementation testLibs.mockito.core
testFixturesImplementation libs.signal.client.java
testFixturesImplementation testLibs.junit.junit
@@ -785,11 +785,11 @@ public class SignalServiceAccountManager {
profileAvatarData);
}
public Optional<ProfileKeyCredential> resolveProfileKeyCredential(UUID uuid, ProfileKey profileKey)
public Optional<ProfileKeyCredential> resolveProfileKeyCredential(UUID uuid, ProfileKey profileKey, Locale locale)
throws NonSuccessfulResponseCodeException, PushNetworkException
{
try {
ProfileAndCredential credential = this.pushServiceSocket.retrieveVersionedProfileAndCredential(uuid, profileKey, Optional.absent()).get(10, TimeUnit.SECONDS);
ProfileAndCredential credential = this.pushServiceSocket.retrieveVersionedProfileAndCredential(uuid, profileKey, Optional.absent(), locale).get(10, TimeUnit.SECONDS);
return credential.getProfileKeyCredential();
} catch (InterruptedException | TimeoutException e) {
throw new PushNetworkException(e);
@@ -41,6 +41,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
/**
@@ -84,24 +85,25 @@ public class SignalServiceMessageReceiver {
}
public ListenableFuture<ProfileAndCredential> retrieveProfile(SignalServiceAddress address,
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType)
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType,
Locale locale)
{
UUID uuid = address.getUuid();
if (profileKey.isPresent()) {
if (requestType == SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL) {
return socket.retrieveVersionedProfileAndCredential(uuid, profileKey.get(), unidentifiedAccess);
return socket.retrieveVersionedProfileAndCredential(uuid, profileKey.get(), unidentifiedAccess, locale);
} else {
return FutureTransformers.map(socket.retrieveVersionedProfile(uuid, profileKey.get(), unidentifiedAccess), profile -> {
return FutureTransformers.map(socket.retrieveVersionedProfile(uuid, profileKey.get(), unidentifiedAccess, locale), profile -> {
return new ProfileAndCredential(profile,
SignalServiceProfile.RequestType.PROFILE,
Optional.absent());
});
}
} else {
return FutureTransformers.map(socket.retrieveProfile(address, unidentifiedAccess), profile -> {
return FutureTransformers.map(socket.retrieveProfile(address, unidentifiedAccess, locale), profile -> {
return new ProfileAndCredential(profile,
SignalServiceProfile.RequestType.PROFILE,
Optional.absent());
@@ -109,10 +111,10 @@ public class SignalServiceMessageReceiver {
}
}
public SignalServiceProfile retrieveProfileByUsername(String username, Optional<UnidentifiedAccess> unidentifiedAccess)
public SignalServiceProfile retrieveProfileByUsername(String username, Optional<UnidentifiedAccess> unidentifiedAccess, Locale locale)
throws IOException
{
return socket.retrieveProfileByUsername(username, unidentifiedAccess);
return socket.retrieveProfileByUsername(username, unidentifiedAccess, locale);
}
public InputStream retrieveProfileAvatar(String path, File destination, ProfileKey profileKey, long maxSizeBytes)
@@ -3,6 +3,7 @@ package org.whispersystems.signalservice.api.groupsv2;
import org.signal.zkgroup.ServerPublicParams;
import org.signal.zkgroup.auth.ClientZkAuthOperations;
import org.signal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.zkgroup.receipts.ClientZkReceiptOperations;
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
/**
@@ -14,12 +15,14 @@ public final class ClientZkOperations {
private final ClientZkAuthOperations clientZkAuthOperations;
private final ClientZkProfileOperations clientZkProfileOperations;
private final ClientZkReceiptOperations clientZkReceiptOperations;
private final ServerPublicParams serverPublicParams;
public ClientZkOperations(ServerPublicParams serverPublicParams) {
this.serverPublicParams = serverPublicParams;
this.clientZkAuthOperations = new ClientZkAuthOperations (serverPublicParams);
this.clientZkProfileOperations = new ClientZkProfileOperations(serverPublicParams);
this.clientZkReceiptOperations = new ClientZkReceiptOperations(serverPublicParams);
}
public static ClientZkOperations create(SignalServiceConfiguration configuration) {
@@ -34,6 +37,10 @@ public final class ClientZkOperations {
return clientZkProfileOperations;
}
public ClientZkReceiptOperations getReceiptOperations() {
return clientZkReceiptOperations;
}
public ServerPublicParams getServerPublicParams() {
return serverPublicParams;
}
@@ -135,22 +135,7 @@ public class SignalServiceProfile {
private String description;
@JsonProperty
private String ldpi;
@JsonProperty
private String mdpi;
@JsonProperty
private String hdpi;
@JsonProperty
private String xhdpi;
@JsonProperty
private String xxhdpi;
@JsonProperty
private String xxxhdpi;
private List<String> sprites6;
@JsonProperty
private BigDecimal expiration;
@@ -174,28 +159,8 @@ public class SignalServiceProfile {
return description;
}
public String getLdpiUri() {
return ldpi;
}
public String getMdpiUri() {
return mdpi;
}
public String getHdpiUri() {
return hdpi;
}
public String getXhdpiUri() {
return xhdpi;
}
public String getXxhdpiUri() {
return xxhdpi;
}
public String getXxxhdpiUri() {
return xxxhdpi;
public List<String> getSprites6() {
return sprites6;
}
public BigDecimal getExpiration() {
@@ -1,7 +1,16 @@
package org.whispersystems.signalservice.api.services;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
import org.whispersystems.libsignal.logging.Log;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret;
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
@@ -11,7 +20,7 @@ import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import java.io.IOException;
import io.reactivex.rxjava3.core.Scheduler;
import io.reactivex.rxjava3.annotations.NonNull;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -19,6 +28,9 @@ import io.reactivex.rxjava3.schedulers.Schedulers;
* One-stop shop for Signal service calls related to donations.
*/
public class DonationsService {
private static final String TAG = DonationsService.class.getSimpleName();
private final PushServiceSocket pushServiceSocket;
public DonationsService(
@@ -28,7 +40,12 @@ public class DonationsService {
GroupsV2Operations groupsV2Operations,
boolean automaticNetworkRetry
) {
this.pushServiceSocket = new PushServiceSocket(configuration, credentialsProvider, signalAgent, groupsV2Operations.getProfileOperations(), automaticNetworkRetry);
this(new PushServiceSocket(configuration, credentialsProvider, signalAgent, groupsV2Operations.getProfileOperations(), automaticNetworkRetry));
}
// Visible for testing.
DonationsService(@NonNull PushServiceSocket pushServiceSocket) {
this.pushServiceSocket = pushServiceSocket;
}
/**
@@ -57,12 +74,117 @@ public class DonationsService {
* @return A ServiceResponse containing a DonationIntentResult with details given to us by the payment gateway.
*/
public Single<ServiceResponse<DonationIntentResult>> createDonationIntentWithAmount(String amount, String currencyCode) {
return createServiceResponse(() -> new Pair<>(pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200));
}
/**
* Returns the subscription levels that are available for the client to choose from along with currencies and current prices
*/
public Single<ServiceResponse<SubscriptionLevels>> getSubscriptionLevels() {
return createServiceResponse(() -> new Pair<>(pushServiceSocket.getSubscriptionLevels(), 200));
}
/**
* Updates the current subscription to the given level and currency. The idempotency key should be a randomly generated 16-byte value that's
* url-safe-base64-encoded by the client for each user-operation. That is, if the user is updating from level 500 to level 1000 and the client has to retry
* the request, the idempotency key should remain the same. However, if the user updates from level 500 to level 1000, then updates from level 1000 to
* level 500, then updates from level 500 to level 1000 again all three of these operations should have separate idempotency keys. Think of this value as an
* indicator of user-intention. It should be the same for retries, but any new user-intention to update the subscription should produce a unique value.
*
* @param subscriberId The subscriber ID for the user changing their subscription level
* @param level The new level to subscribe to
* @param currencyCode The currencyCode the user is using for payment
* @param idempotencyKey url-safe-base64-encoded random 16-byte value (see description)
*/
public Single<ServiceResponse<EmptyResponse>> updateSubscriptionLevel(SubscriberId subscriberId, String level, String currencyCode, String idempotencyKey) {
return createServiceResponse(() -> {
pushServiceSocket.updateSubscriptionLevel(subscriberId.serialize(), level, currencyCode, idempotencyKey);
return new Pair<>(EmptyResponse.INSTANCE, 200);
});
}
/**
* Returns information about the current subscription if one exists.
*/
public Single<ServiceResponse<ActiveSubscription>> getSubscription(SubscriberId subscriberId) {
return createServiceResponse(() -> {
ActiveSubscription response = pushServiceSocket.getSubscription(subscriberId.serialize());
return new Pair<>(response, 200);
});
}
/**
* Creates a subscriber record on the signal server and stripe. Can be called idempotently as-is. After receiving 200 from this endpoint,
* clients should save subscriberId locally and to storage service for the account. If you get a 403 from this endpoint and you did not
* use an account authenticated connection, then the subscriberId has been corrupted in some way.
*
* Clients MUST periodically hit this endpoint to update the access time on the subscription record. Recommend trying to call it approximately
* every 3 days. Not accessing this endpoint for an extended period of time will result in the subscription being canceled.
*
* @param subscriberId The subscriber ID for the user polling their subscription
*/
public Single<ServiceResponse<EmptyResponse>> putSubscription(SubscriberId subscriberId) {
return createServiceResponse(() -> {
pushServiceSocket.putSubscription(subscriberId.serialize());
return new Pair<>(EmptyResponse.INSTANCE, 200);
});
}
/**
* Cancels any current subscription at the end of the current subscription period.
*
* @param subscriberId The subscriber ID for the user cancelling their subscription
*/
public Single<ServiceResponse<EmptyResponse>> cancelSubscription(SubscriberId subscriberId) {
return createServiceResponse(() -> {
pushServiceSocket.deleteSubscription(subscriberId.serialize());
return new Pair<>(EmptyResponse.INSTANCE, 200);
});
}
public Single<ServiceResponse<EmptyResponse>> setDefaultPaymentMethodId(SubscriberId subscriberId, String paymentMethodId) {
return createServiceResponse(() -> {
pushServiceSocket.setDefaultSubscriptionPaymentMethod(subscriberId.serialize(), paymentMethodId);
return new Pair<>(EmptyResponse.INSTANCE, 200);
});
}
/**
*
* @param subscriberId The subscriber ID to create a payment method for.
* @return Client secret for a SetupIntent. It should not be used with the PaymentIntent stripe APIs
* but instead with the SetupIntent stripe APIs.
*/
public Single<ServiceResponse<SubscriptionClientSecret>> createSubscriptionPaymentMethod(SubscriberId subscriberId) {
return createServiceResponse(() -> {
SubscriptionClientSecret clientSecret = pushServiceSocket.createSubscriptionPaymentMethod(subscriberId.serialize());
return new Pair<>(clientSecret, 200);
});
}
public Single<ServiceResponse<ReceiptCredentialResponse>> submitReceiptCredentialRequest(SubscriberId subscriberId, ReceiptCredentialRequest receiptCredentialRequest) {
return createServiceResponse(() -> {
ReceiptCredentialResponse response = pushServiceSocket.submitReceiptCredentials(subscriberId.serialize(), receiptCredentialRequest);
return new Pair<>(response, 200);
});
}
private <T> Single<ServiceResponse<T>> createServiceResponse(Producer<T> producer) {
return Single.fromCallable(() -> {
try {
return ServiceResponse.forResult(this.pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200, null);
Pair<T, Integer> responseAndCode = producer.produce();
return ServiceResponse.forResult(responseAndCode.first(), responseAndCode.second(), null);
} catch (NonSuccessfulResponseCodeException e) {
Log.w(TAG, "Bad response code from server.", e);
return ServiceResponse.<T>forApplicationError(e, e.getCode(), e.getMessage());
} catch (IOException e) {
return ServiceResponse.<DonationIntentResult>forUnknownError(e);
Log.w(TAG, "An unknown error occurred.", e);
return ServiceResponse.<T>forUnknownError(e);
}
}).subscribeOn(Schedulers.io());
}
private interface Producer<T> {
Pair<T, Integer> produce() throws IOException;
}
}
@@ -19,6 +19,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.ServiceResponseProcessor;
import org.whispersystems.signalservice.internal.push.http.AcceptLanguagesUtil;
import org.whispersystems.signalservice.internal.util.Hex;
import org.whispersystems.signalservice.internal.util.JsonUtil;
import org.whispersystems.signalservice.internal.websocket.DefaultResponseMapper;
@@ -26,6 +27,7 @@ import org.whispersystems.signalservice.internal.websocket.ResponseMapper;
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos;
import java.security.SecureRandom;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -55,7 +57,8 @@ public final class ProfileService {
public Single<ServiceResponse<ProfileAndCredential>> getProfile(SignalServiceAddress address,
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType)
SignalServiceProfile.RequestType requestType,
Locale locale)
{
UUID uuid = address.getUuid();
SecureRandom random = new SecureRandom();
@@ -83,6 +86,8 @@ public final class ProfileService {
builder.setPath(String.format("/v1/profile/%s", address.getIdentifier()));
}
builder.addHeaders(AcceptLanguagesUtil.getAcceptLanguageHeader(locale));
WebSocketProtos.WebSocketRequestMessage requestMessage = builder.build();
ResponseMapper<ProfileAndCredential> responseMapper = DefaultResponseMapper.extend(ProfileAndCredential.class)
@@ -91,17 +96,18 @@ public final class ProfileService {
return signalWebSocket.request(requestMessage, unidentifiedAccess)
.map(responseMapper::map)
.onErrorResumeNext(t -> restFallback(address, profileKey, unidentifiedAccess, requestType))
.onErrorResumeNext(t -> restFallback(address, profileKey, unidentifiedAccess, requestType, locale))
.onErrorReturn(ServiceResponse::forUnknownError);
}
private Single<ServiceResponse<ProfileAndCredential>> restFallback(SignalServiceAddress address,
Optional<ProfileKey> profileKey,
Optional<UnidentifiedAccess> unidentifiedAccess,
SignalServiceProfile.RequestType requestType)
SignalServiceProfile.RequestType requestType,
Locale locale)
{
return Single.fromFuture(receiver.retrieveProfile(address, profileKey, unidentifiedAccess, requestType), 10, TimeUnit.SECONDS)
.onErrorResumeNext(t -> Single.fromFuture(receiver.retrieveProfile(address, profileKey, Optional.absent(), requestType), 10, TimeUnit.SECONDS))
return Single.fromFuture(receiver.retrieveProfile(address, profileKey, unidentifiedAccess, requestType, locale), 10, TimeUnit.SECONDS)
.onErrorResumeNext(t -> Single.fromFuture(receiver.retrieveProfile(address, profileKey, Optional.absent(), requestType, locale), 10, TimeUnit.SECONDS))
.map(p -> ServiceResponse.forResult(p, 0, null));
}
@@ -0,0 +1,93 @@
package org.whispersystems.signalservice.api.subscriptions;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
public final class ActiveSubscription {
private final Subscription activeSubscription;
@JsonCreator
public ActiveSubscription(@JsonProperty("subscription") Subscription activeSubscription) {
this.activeSubscription = activeSubscription;
}
public Subscription getActiveSubscription() {
return activeSubscription;
}
public boolean isActive() {
return activeSubscription != null && activeSubscription.isActive();
}
public static final class Subscription {
private final int level;
private final String currency;
private final BigDecimal amount;
private final long endOfCurrentPeriod;
private final boolean isActive;
private final long billingCycleAnchor;
private final boolean willCancelAtPeriodEnd;
@JsonCreator
public Subscription(@JsonProperty("level") int level,
@JsonProperty("currency") String currency,
@JsonProperty("amount") BigDecimal amount,
@JsonProperty("endOfCurrentPeriod") long endOfCurrentPeriod,
@JsonProperty("active") boolean isActive,
@JsonProperty("billingCycleAnchor") long billingCycleAnchor,
@JsonProperty("cancelAtPeriodEnd") boolean willCancelAtPeriodEnd)
{
this.level = level;
this.currency = currency;
this.amount = amount;
this.endOfCurrentPeriod = endOfCurrentPeriod;
this.isActive = isActive;
this.billingCycleAnchor = billingCycleAnchor;
this.willCancelAtPeriodEnd = willCancelAtPeriodEnd;
}
public int getLevel() {
return level;
}
public String getCurrency() {
return currency;
}
public BigDecimal getAmount() {
return amount;
}
/**
* UNIX Epoch Timestamp in seconds, can be used to calculate next billing date per
* https://stripe.com/docs/billing/subscriptions/billing-cycle
*/
public long getBillingCycleAnchor() {
return billingCycleAnchor;
}
/**
* Whether this subscription is currently active.
*/
public boolean isActive() {
return isActive;
}
/**
* UNIX Epoch Timestamp in seconds
*/
public long getEndOfCurrentPeriod() {
return endOfCurrentPeriod;
}
/**
* Whether this subscription is set to end at the end of the current period.
*/
public boolean willCancelAtPeriodEnd() {
return willCancelAtPeriodEnd;
}
}
}
@@ -0,0 +1,56 @@
package org.whispersystems.signalservice.api.subscriptions;
import org.whispersystems.libsignal.util.guava.Preconditions;
import org.whispersystems.util.Base64;
import org.whispersystems.util.Base64UrlSafe;
import java.security.SecureRandom;
import java.util.Arrays;
/**
* Key representing an atomic operation against the Subscriptions API
*/
public final class IdempotencyKey {
private static final int SIZE = 16;
private final byte[] bytes;
private IdempotencyKey(byte[] bytes) {
this.bytes = bytes;
}
public byte[] getBytes() {
return bytes;
}
public String serialize() {
return Base64UrlSafe.encodeBytes(bytes);
}
public static IdempotencyKey fromBytes(byte[] bytes) {
Preconditions.checkArgument(bytes.length == SIZE);
return new IdempotencyKey(bytes);
}
public static IdempotencyKey generate() {
byte[] bytes = new byte[SIZE];
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(bytes);
return new IdempotencyKey(bytes);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final IdempotencyKey that = (IdempotencyKey) o;
return Arrays.equals(bytes, that.bytes);
}
@Override
public int hashCode() {
return Arrays.hashCode(bytes);
}
}
@@ -0,0 +1,58 @@
package org.whispersystems.signalservice.api.subscriptions;
import org.whispersystems.libsignal.util.guava.Preconditions;
import org.whispersystems.util.Base64;
import org.whispersystems.util.Base64UrlSafe;
import java.security.SecureRandom;
import java.util.Arrays;
import io.reactivex.rxjava3.annotations.NonNull;
/**
* Id representing a single subscriber.
*/
public final class SubscriberId {
private static final int SIZE = 32;
private final byte[] bytes;
private SubscriberId(byte[] bytes) {
this.bytes = bytes;
}
public byte[] getBytes() {
return bytes;
}
public @NonNull String serialize() {
return Base64UrlSafe.encodeBytes(bytes);
}
public static SubscriberId fromBytes(byte[] bytes) {
Preconditions.checkArgument(bytes.length == SIZE);
return new SubscriberId(bytes);
}
public static SubscriberId generate() {
byte[] bytes = new byte[SIZE];
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(bytes);
return new SubscriberId(bytes);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final SubscriberId that = (SubscriberId) o;
return Arrays.equals(bytes, that.bytes);
}
@Override
public int hashCode() {
return Arrays.hashCode(bytes);
}
}
@@ -0,0 +1,24 @@
package org.whispersystems.signalservice.api.subscriptions;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public final class SubscriptionClientSecret {
private final String id;
private final String clientSecret;
@JsonCreator
public SubscriptionClientSecret(@JsonProperty("clientSecret") String clientSecret) {
this.id = clientSecret.replaceFirst("_secret.*", "");
this.clientSecret = clientSecret;
}
public String getId() {
return id;
}
public String getClientSecret() {
return clientSecret;
}
}
@@ -0,0 +1,50 @@
package org.whispersystems.signalservice.api.subscriptions;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import java.math.BigDecimal;
import java.util.Map;
/**
* Available subscription levels.
*/
public final class SubscriptionLevels {
private final Map<String, Level> levels;
@JsonCreator
public SubscriptionLevels(@JsonProperty("levels") Map<String, Level> levels) {
this.levels = levels;
}
public Map<String, Level> getLevels() {
return levels;
}
/**
* An available subscription level
*/
public static final class Level {
private final SignalServiceProfile.Badge badge;
private final Map<String, BigDecimal> currencies;
@JsonCreator
public Level(@JsonProperty("badge") SignalServiceProfile.Badge badge,
@JsonProperty("currencies") Map<String, BigDecimal> currencies)
{
this.badge = badge;
this.currencies = currencies;
}
public SignalServiceProfile.Badge getBadge() {
return badge;
}
public Map<String, BigDecimal> getCurrencies() {
return currencies;
}
}
}
@@ -26,6 +26,8 @@ import org.signal.zkgroup.profiles.ProfileKeyCredentialRequestContext;
import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse;
import org.signal.zkgroup.profiles.ProfileKeyVersion;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.ecc.ECPublicKey;
import org.whispersystems.libsignal.logging.Log;
@@ -74,6 +76,9 @@ import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserExce
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.api.storage.StorageAuthResponse;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.SubscriptionClientSecret;
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.Tls12SocketFactory;
import org.whispersystems.signalservice.api.util.TlsProxySocketFactory;
@@ -98,6 +103,7 @@ import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevic
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
import org.whispersystems.signalservice.internal.push.exceptions.PaymentsRegionException;
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException;
import org.whispersystems.signalservice.internal.push.http.AcceptLanguagesUtil;
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody;
import org.whispersystems.signalservice.internal.push.http.NoCipherOutputStreamFactory;
@@ -135,6 +141,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Future;
@@ -237,6 +244,13 @@ public class PushServiceSocket {
private static final String DONATION_INTENT = "/v1/donation/authorize-apple-pay";
private static final String DONATION_REDEEM_RECEIPT = "/v1/donation/redeem-receipt";
private static final String SUBSCRIPTION_LEVELS = "/v1/subscription/levels";
private static final String UPDATE_SUBSCRIPTION_LEVEL = "/v1/subscription/%s/level/%s/%s/%s";
private static final String SUBSCRIPTION = "/v1/subscription/%s";
private static final String CREATE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method";
private static final String DEFAULT_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/%s";
private static final String SUBSCRIPTION_RECEIPT_CREDENTIALS = "/v1/subscription/%s/receipt_credentials";
private static final String REPORT_SPAM = "/v1/messages/report/%s/%s";
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
@@ -707,8 +721,8 @@ public class PushServiceSocket {
return output.toByteArray();
}
public ListenableFuture<SignalServiceProfile> retrieveProfile(SignalServiceAddress target, Optional<UnidentifiedAccess> unidentifiedAccess) {
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, target.getIdentifier()), "GET", null, NO_HEADERS, unidentifiedAccess);
public ListenableFuture<SignalServiceProfile> retrieveProfile(SignalServiceAddress target, Optional<UnidentifiedAccess> unidentifiedAccess, Locale locale) {
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, target.getIdentifier()), "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), unidentifiedAccess);
return FutureTransformers.map(response, body -> {
try {
@@ -720,10 +734,10 @@ public class PushServiceSocket {
});
}
public SignalServiceProfile retrieveProfileByUsername(String username, Optional<UnidentifiedAccess> unidentifiedAccess)
public SignalServiceProfile retrieveProfileByUsername(String username, Optional<UnidentifiedAccess> unidentifiedAccess, Locale locale)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
String response = makeServiceRequest(String.format(PROFILE_USERNAME_PATH, username), "GET", null, NO_HEADERS, unidentifiedAccess);
String response = makeServiceRequest(String.format(PROFILE_USERNAME_PATH, username), "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), unidentifiedAccess);
try {
return JsonUtil.fromJson(response, SignalServiceProfile.class);
@@ -733,7 +747,7 @@ public class PushServiceSocket {
}
}
public ListenableFuture<ProfileAndCredential> retrieveVersionedProfileAndCredential(UUID target, ProfileKey profileKey, Optional<UnidentifiedAccess> unidentifiedAccess) {
public ListenableFuture<ProfileAndCredential> retrieveVersionedProfileAndCredential(UUID target, ProfileKey profileKey, Optional<UnidentifiedAccess> unidentifiedAccess, Locale locale) {
ProfileKeyVersion profileKeyIdentifier = profileKey.getProfileKeyVersion(target);
ProfileKeyCredentialRequestContext requestContext = clientZkProfileOperations.createProfileKeyCredentialRequestContext(random, target, profileKey);
ProfileKeyCredentialRequest request = requestContext.getRequest();
@@ -742,7 +756,8 @@ public class PushServiceSocket {
String credentialRequest = Hex.toStringCondensed(request.serialize());
String subPath = String.format("%s/%s/%s", target, version, credentialRequest);
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, subPath), "GET", null, NO_HEADERS, unidentifiedAccess);
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, subPath), "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), unidentifiedAccess);
return FutureTransformers.map(response, body -> formatProfileAndCredentialBody(requestContext, body));
}
@@ -768,12 +783,12 @@ public class PushServiceSocket {
}
}
public ListenableFuture<SignalServiceProfile> retrieveVersionedProfile(UUID target, ProfileKey profileKey, Optional<UnidentifiedAccess> unidentifiedAccess) {
public ListenableFuture<SignalServiceProfile> retrieveVersionedProfile(UUID target, ProfileKey profileKey, Optional<UnidentifiedAccess> unidentifiedAccess, Locale locale) {
ProfileKeyVersion profileKeyIdentifier = profileKey.getProfileKeyVersion(target);
String version = profileKeyIdentifier.serialize();
String subPath = String.format("%s/%s", target, version);
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, subPath), "GET", null, NO_HEADERS, unidentifiedAccess);
ListenableFuture<String> response = submitServiceRequest(String.format(PROFILE_PATH, subPath), "GET", null, AcceptLanguagesUtil.getHeadersWithAcceptLanguage(locale), unidentifiedAccess);
return FutureTransformers.map(response, body -> {
try {
@@ -867,8 +882,8 @@ public class PushServiceSocket {
}
public void redeemDonationReceipt(ReceiptCredentialPresentation receiptCredentialPresentation, boolean visible, boolean primary) throws IOException {
String payload = JsonUtil.toJson(new RedeemReceiptRequest(Base64.encodeBytesToBytes(receiptCredentialPresentation.serialize()), visible, primary));
makeServiceRequest(DONATION_REDEEM_RECEIPT, "PUT", payload);
String payload = JsonUtil.toJson(new RedeemReceiptRequest(Base64.encodeBytes(receiptCredentialPresentation.serialize()), visible, primary));
makeServiceRequest(DONATION_REDEEM_RECEIPT, "POST", payload);
}
/**
@@ -880,6 +895,55 @@ public class PushServiceSocket {
return JsonUtil.fromJsonResponse(result, DonationIntentResult.class);
}
public SubscriptionLevels getSubscriptionLevels() throws IOException {
String result = makeServiceRequestWithoutAuthentication(SUBSCRIPTION_LEVELS, "GET", null);
return JsonUtil.fromJsonResponse(result, SubscriptionLevels.class);
}
public void updateSubscriptionLevel(String subscriberId, String level, String currencyCode, String idempotencyKey) throws IOException {
makeServiceRequestWithoutAuthentication(String.format(UPDATE_SUBSCRIPTION_LEVEL, subscriberId, level, currencyCode, idempotencyKey), "PUT", "");
}
public ActiveSubscription getSubscription(String subscriberId) throws IOException {
String response = makeServiceRequestWithoutAuthentication(String.format(SUBSCRIPTION, subscriberId), "GET", null);
return JsonUtil.fromJson(response, ActiveSubscription.class);
}
public void putSubscription(String subscriberId) throws IOException {
makeServiceRequestWithoutAuthentication(String.format(SUBSCRIPTION, subscriberId), "PUT", "");
}
public void deleteSubscription(String subscriberId) throws IOException {
makeServiceRequestWithoutAuthentication(String.format(SUBSCRIPTION, subscriberId), "DELETE", null);
}
public SubscriptionClientSecret createSubscriptionPaymentMethod(String subscriberId) throws IOException {
String response = makeServiceRequestWithoutAuthentication(String.format(CREATE_SUBSCRIPTION_PAYMENT_METHOD, subscriberId), "POST", "");
return JsonUtil.fromJson(response, SubscriptionClientSecret.class);
}
public void setDefaultSubscriptionPaymentMethod(String subscriberId, String paymentMethodId) throws IOException {
makeServiceRequestWithoutAuthentication(String.format(DEFAULT_SUBSCRIPTION_PAYMENT_METHOD, subscriberId, paymentMethodId), "POST", "");
}
public ReceiptCredentialResponse submitReceiptCredentials(String subscriptionId, ReceiptCredentialRequest receiptCredentialRequest) throws IOException {
String payload = JsonUtil.toJson(new ReceiptCredentialRequestJson(receiptCredentialRequest));
String response = makeServiceRequestWithoutAuthentication(
String.format(SUBSCRIPTION_RECEIPT_CREDENTIALS, subscriptionId),
"POST",
payload,
code -> {
if (code == 204) throw new NonSuccessfulResponseCodeException(204);
});
ReceiptCredentialResponseJson responseJson = JsonUtil.fromJson(response, ReceiptCredentialResponseJson.class);
if (responseJson.getReceiptCredentialResponse() != null) {
return responseJson.getReceiptCredentialResponse();
} else {
throw new MalformedResponseException("Unable to parse response");
}
}
public List<ContactTokenDetails> retrieveDirectory(Set<String> contactTokens)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
@@ -1440,6 +1504,23 @@ public class PushServiceSocket {
.build();
}
private String makeServiceRequestWithoutAuthentication(String urlFragment, String method, String jsonBody)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
return makeServiceRequestWithoutAuthentication(urlFragment, method, jsonBody, NO_HANDLER);
}
private String makeServiceRequestWithoutAuthentication(String urlFragment, String method, String jsonBody, ResponseCodeHandler responseCodeHandler)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
ResponseBody responseBody = makeServiceRequest(urlFragment, method, jsonRequestBody(jsonBody), NO_HEADERS, responseCodeHandler, Optional.absent(), true).body();
try {
return responseBody.string();
} catch (IOException e) {
throw new PushNetworkException(e);
}
}
private String makeServiceRequest(String urlFragment, String method, String jsonBody)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
@@ -1488,9 +1569,14 @@ public class PushServiceSocket {
}
private ListenableFuture<String> submitServiceRequest(String urlFragment, String method, String jsonBody, Map<String, String> headers, Optional<UnidentifiedAccess> unidentifiedAccessKey) {
private ListenableFuture<String> submitServiceRequest(String urlFragment,
String method,
String jsonBody,
Map<String, String> headers,
Optional<UnidentifiedAccess> unidentifiedAccessKey)
{
OkHttpClient okHttpClient = buildOkHttpClient(unidentifiedAccessKey.isPresent());
Call call = okHttpClient.newCall(buildServiceRequest(urlFragment, method, jsonRequestBody(jsonBody), headers, unidentifiedAccessKey));
Call call = okHttpClient.newCall(buildServiceRequest(urlFragment, method, jsonRequestBody(jsonBody), headers, unidentifiedAccessKey, false));
synchronized (connections) {
connections.add(call);
@@ -1536,7 +1622,19 @@ public class PushServiceSocket {
Optional<UnidentifiedAccess> unidentifiedAccessKey)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
Response response = getServiceConnection(urlFragment, method, body, headers, unidentifiedAccessKey);
return makeServiceRequest(urlFragment, method, body, headers, responseCodeHandler, unidentifiedAccessKey, false);
}
private Response makeServiceRequest(String urlFragment,
String method,
RequestBody body,
Map<String, String> headers,
ResponseCodeHandler responseCodeHandler,
Optional<UnidentifiedAccess> unidentifiedAccessKey,
boolean doNotAddAuthenticationOrUnidentifiedAccessKey)
throws NonSuccessfulResponseCodeException, PushNetworkException, MalformedResponseException
{
Response response = getServiceConnection(urlFragment, method, body, headers, unidentifiedAccessKey, doNotAddAuthenticationOrUnidentifiedAccessKey);
responseCodeHandler.handle(response.code());
@@ -1599,12 +1697,17 @@ public class PushServiceSocket {
return response;
}
private Response getServiceConnection(String urlFragment, String method, RequestBody body, Map<String, String> headers, Optional<UnidentifiedAccess> unidentifiedAccess)
private Response getServiceConnection(String urlFragment,
String method,
RequestBody body,
Map<String, String> headers,
Optional<UnidentifiedAccess> unidentifiedAccess,
boolean doNotAddAuthenticationOrUnidentifiedAccessKey)
throws PushNetworkException
{
try {
OkHttpClient okHttpClient = buildOkHttpClient(unidentifiedAccess.isPresent());
Call call = okHttpClient.newCall(buildServiceRequest(urlFragment, method, body, headers, unidentifiedAccess));
Call call = okHttpClient.newCall(buildServiceRequest(urlFragment, method, body, headers, unidentifiedAccess, doNotAddAuthenticationOrUnidentifiedAccessKey));
synchronized (connections) {
connections.add(call);
@@ -1633,7 +1736,13 @@ public class PushServiceSocket {
.build();
}
private Request buildServiceRequest(String urlFragment, String method, RequestBody body, Map<String, String> headers, Optional<UnidentifiedAccess> unidentifiedAccess) {
private Request buildServiceRequest(String urlFragment,
String method,
RequestBody body,
Map<String, String> headers,
Optional<UnidentifiedAccess> unidentifiedAccess,
boolean doNotAddAuthenticationOrUnidentifiedAccessKey) {
ServiceConnectionHolder connectionHolder = (ServiceConnectionHolder) getRandom(serviceClients, random);
// Log.d(TAG, "Push service URL: " + connectionHolder.getUrl());
@@ -1647,7 +1756,7 @@ public class PushServiceSocket {
request.addHeader(header.getKey(), header.getValue());
}
if (!headers.containsKey("Authorization")) {
if (!headers.containsKey("Authorization") && !doNotAddAuthenticationOrUnidentifiedAccessKey) {
if (unidentifiedAccess.isPresent()) {
request.addHeader("Unidentified-Access-Key", Base64.encodeBytes(unidentifiedAccess.get().getUnidentifiedAccessKey()));
} else if (credentialsProvider.getPassword() != null) {
@@ -0,0 +1,15 @@
package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.signal.zkgroup.receipts.ReceiptCredentialRequest;
import org.whispersystems.util.Base64;
class ReceiptCredentialRequestJson {
@JsonProperty("receiptCredentialRequest")
private final String receiptCredentialRequest;
ReceiptCredentialRequestJson(ReceiptCredentialRequest receiptCredentialRequest) {
this.receiptCredentialRequest = Base64.encodeBytes(receiptCredentialRequest.serialize());
}
}
@@ -0,0 +1,30 @@
package org.whispersystems.signalservice.internal.push;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
import org.whispersystems.util.Base64;
import java.io.IOException;
class ReceiptCredentialResponseJson {
private final ReceiptCredentialResponse receiptCredentialResponse;
ReceiptCredentialResponseJson(@JsonProperty("receiptCredentialResponse") String receiptCredentialResponse) {
ReceiptCredentialResponse response;
try {
response = new ReceiptCredentialResponse(Base64.decode(receiptCredentialResponse));
} catch (IOException | InvalidInputException e) {
response = null;
}
this.receiptCredentialResponse = response;
}
public ReceiptCredentialResponse getReceiptCredentialResponse() {
return receiptCredentialResponse;
}
}
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.signal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.whispersystems.libsignal.util.guava.Preconditions;
/**
* POST /v1/donation/redeem-receipt
@@ -13,7 +12,7 @@ import org.whispersystems.libsignal.util.guava.Preconditions;
*/
class RedeemReceiptRequest {
private final byte[] receiptCredentialPresentation;
private final String receiptCredentialPresentation;
private final boolean visible;
private final boolean primary;
@@ -24,18 +23,16 @@ class RedeemReceiptRequest {
*/
@JsonCreator
RedeemReceiptRequest(
@JsonProperty("receiptCredentialPresentation") byte[] receiptCredentialPresentation,
@JsonProperty("receiptCredentialPresentation") String receiptCredentialPresentation,
@JsonProperty("visible") boolean visible,
@JsonProperty("primary") boolean primary) {
Preconditions.checkArgument(receiptCredentialPresentation.length == ReceiptCredentialPresentation.SIZE);
this.receiptCredentialPresentation = receiptCredentialPresentation;
this.visible = visible;
this.primary = primary;
}
public byte[] getReceiptCredentialPresentation() {
public String getReceiptCredentialPresentation() {
return receiptCredentialPresentation;
}
@@ -0,0 +1,27 @@
package org.whispersystems.signalservice.internal.push.http;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
public class AcceptLanguagesUtil {
public static Map<String, String> getHeadersWithAcceptLanguage(Locale locale) {
Map<String, String> headers = new HashMap<>();
headers.put("Accept-Language", formatLanguages(locale.getLanguage(), Locale.US.getLanguage()));
return headers;
}
public static String getAcceptLanguageHeader(Locale locale) {
return "Accept-Language:" + formatLanguages(locale.getLanguage(), Locale.US.getLanguage());
}
private static String formatLanguages(String language, String fallback) {
if (Objects.equals(language, fallback)) {
return language + ";q=1";
} else {
return language + ";q=1," + fallback + ";q=0.5";
}
}
}
@@ -0,0 +1,69 @@
package org.whispersystems.signalservice.api.services;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.Mockito;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import io.reactivex.rxjava3.observers.TestObserver;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import io.reactivex.rxjava3.schedulers.TestScheduler;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@RunWith(JUnit4.class)
public class DonationsServiceTest {
private static final TestScheduler TEST_SCHEDULER = new TestScheduler();
private final PushServiceSocket pushServiceSocket = Mockito.mock(PushServiceSocket.class);
private final DonationsService testSubject = new DonationsService(pushServiceSocket);
@BeforeClass
public static void setUpClass() {
RxJavaPlugins.setIoSchedulerHandler(scheduler -> TEST_SCHEDULER);
}
@Test
public void givenASubscriberId_whenIGetASuccessfulResponse_thenItIsMappedWithTheCorrectStatusCodeAndNonEmptyObject() throws Exception {
// GIVEN
SubscriberId subscriberId = SubscriberId.generate();
when(pushServiceSocket.getSubscription(subscriberId.serialize()))
.thenReturn(getActiveSubscription());
// WHEN
TestObserver<ServiceResponse<ActiveSubscription>> testObserver = testSubject.getSubscription(subscriberId).test();
// THEN
TEST_SCHEDULER.triggerActions();
verify(pushServiceSocket).getSubscription(subscriberId.serialize());
testObserver.assertComplete().assertValue(value -> value.getStatus() == 200 && value.getResult().isPresent());
}
@Test
public void givenASubscriberId_whenIGetAnUnsuccessfulResponse_thenItIsMappedWithTheCorrectStatusCodeAndEmptyObject() throws Exception {
// GIVEN
SubscriberId subscriberId = SubscriberId.generate();
when(pushServiceSocket.getSubscription(subscriberId.serialize()))
.thenThrow(new NonSuccessfulResponseCodeException(403));
// WHEN
TestObserver<ServiceResponse<ActiveSubscription>> testObserver = testSubject.getSubscription(subscriberId).test();
// THEN
TEST_SCHEDULER.triggerActions();
verify(pushServiceSocket).getSubscription(subscriberId.serialize());
testObserver.assertComplete().assertValue(value -> value.getStatus() == 403 && !value.getResult().isPresent());
}
private ActiveSubscription getActiveSubscription() {
return new ActiveSubscription(null);
}
}