diff --git a/app/build.gradle b/app/build.gradle index b330fbdab3..602eeb6236 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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\"" } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index da651921d4..df189ed8c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt index 8d1e583b3b..3b8d578f1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/BadgeImageView.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt index c6e54b3ba8..f19aac8555 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/Badges.kt @@ -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, selectedBadge: Badge? = null) { + fun DSLConfiguration.displayBadges( + context: Context, + badges: List, + 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 { + 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 = 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 + ) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformation.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformation.kt index dda7aa503c..4309ce2d11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformation.kt @@ -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) { + 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() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt index ce98dba774..3e35a1b0df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt @@ -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() { 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 - } - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt index 5c849c1ed5..7cbf340ae8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/BadgePreview.kt @@ -22,22 +22,30 @@ object BadgePreview { data class Model(override val badge: Badge?) : BadgeModel() { 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() { 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>(itemView: View) : MappingViewHolder(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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt index 9aed2a745d..05b06dfaba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt @@ -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()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt index 0b3dcbc31e..6bb2ddf25b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt index ef8288ba56..4b49ec8700 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewState.kt @@ -7,6 +7,7 @@ data class BadgesOverviewState( val allUnlockedBadges: List = listOf(), val featuredBadge: Badge? = null, val displayBadgesOnProfile: Boolean = false, + val fadedBadgeId: String? = null ) { val hasUnexpiredBadges = allUnlockedBadges.any { it.expirationTimestamp > System.currentTimeMillis() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt index d5ec8c9778..a16d8a4eb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt @@ -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() @@ -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(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 create(modelClass: Class): T { - return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository))) + return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository))) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index 192f19eebd..3dac5eb60d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index b1e4a0f30f..3e79628c3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -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() { + 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(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() { override fun areContentsTheSame(newItem: BioPreference): Boolean { return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt index 84c077a5c3..5ede59b97e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index d8abc7d213..0e1688fbaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -2,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.self().live().liveData + private val store = Store(AppSettingsState(Recipient.self(), 0, false)) - val state: LiveData = LiveDataUtil.combineLatest(unreadPaymentsLiveData, selfLiveData) { payments, self -> - val unreadPaymentsCount = payments.transform { it.unreadCount }.or(0) + private val unreadPaymentsLiveData = UnreadPaymentsLiveData() + private val selfLiveData: LiveData = Recipient.self().live().liveData - AppSettingsState(self, unreadPaymentsCount) + val state: LiveData = 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 create(modelClass: Class): T { + return modelClass.cast(AppSettingsViewModel(subscriptionsRepository)) as T + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt index dec04beaae..e0f1691aa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationEvent.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationExceptions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationExceptions.kt new file mode 100644 index 0000000000..5186309ac8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationExceptions.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription + +class DonationExceptions { + object TimedOutWaitingForTokenRedemption : Exception() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt index e6c9353379..048dfbda68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt @@ -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 = 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 { 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 { + 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")) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt index d02723109f..09c8fb84f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/SubscriptionsRepository.kt @@ -1,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 = Maybe.empty() + fun getActiveSubscription(): Single { + 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> = Single.fromCallable { - listOf() + fun getSubscriptions(currency: Currency): Single> = 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()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt index c8cdb43450..b9725dbbab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostFragment.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt index 02c957940e..1e592acd55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostState.kt @@ -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, } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt index e420a4cb7d..fb3428b6f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt @@ -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 = store.stateLiveData - val events: Observable = eventPublisher + val events: Observable = 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) } } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt index f80cb4975c..c7569f2a9c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt @@ -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 -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt index 64f03101c4..6b7ec5e9ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt @@ -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 = 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 create(modelClass: Class): T { + return modelClass.cast(SetCurrencyViewModel(isBoost))!! + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt index 388c01735a..e2c9b6ea6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt @@ -21,14 +21,17 @@ object ActiveSubscriptionPreference { class Model( val subscription: Subscription, - val onAddBoostClick: () -> Unit + val onAddBoostClick: () -> Unit, + val renewalTimestamp: Long = -1L ) : PreferenceModel() { 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 ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index ce614c0e43..644bf1223c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt index d95ffe644b..20d8397f0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt @@ -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 = emptyList() +) { + sealed class TransactionState { + object Init : TransactionState() + object InTransaction : TransactionState() + class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt index eb3d07ed63..64e81e5e6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt @@ -3,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 = store.stateLiveData - val events: Observable = eventPublisher + val events: Observable = 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> = SignalStore.donationsValues().levelUpdateOperationObservable.distinctUntilChanged() + val activeSubscription: Single = 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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt index 81dde6f888..95f1be1ff8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/CurrencySelection.kt @@ -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() } + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt index 5cec8e030a..117b46fe25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeFragment.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt index 34d3c0b1ef..b03dd4d434 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeState.kt @@ -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 = 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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt index ff320c9573..9e0cada203 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/subscribe/SubscribeViewModel.kt @@ -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 = store.stateLiveData - val events: Observable = eventPublisher + val events: Observable = eventPublisher.observeOn(AndroidSchedulers.mainThread()) private var subscriptionToPurchase: Subscription? = null + private val activeSubscriptionSubject = PublishSubject.create() override fun onCleared() { disposables.clear() } - fun refresh() { - disposables.clear() + init { + val currency: Observable = SignalStore.donationsValues().observableSubscriptionCurrency + val allSubscriptions: Observable> = 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? { + 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt index 5639da748a..42a7c16e71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/thanks/ThanksForYourSupportBottomSheetDialogFragment.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index ce1d087d0d..f80bcfb57c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/IndeterminateLoadingCircle.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/IndeterminateLoadingCircle.kt new file mode 100644 index 0000000000..25968d70fc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/IndeterminateLoadingCircle.kt @@ -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() { + override fun areItemsTheSame(newItem: IndeterminateLoadingCircle): Boolean = true + + private class ViewHolder(itemView: View) : MappingViewHolder(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)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index 6a51f67199..cf860d7ea1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -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(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index cb5d710685..de4f013b3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java index f4f5d65542..4118771c51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java @@ -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 profileKeyCredentialOptional = signalServiceAccountManager.resolveProfileKeyCredential(uuid, profileKey); + Optional profileKeyCredentialOptional = signalServiceAccountManager.resolveProfileKeyCredential(uuid, profileKey, Locale.getDefault()); if (profileKeyCredentialOptional.isPresent()) { boolean updatedProfileKey = recipientDatabase.setProfileKeyCredential(recipient.getId(), profileKey, profileKeyCredentialOptional.get()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java index 4b3ca66b64..1ee9a103c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/help/HelpFragment.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java new file mode 100644 index 0000000000..e2ddf958ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -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 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 { + @Override + public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new DonationReceiptRedemptionJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index e8ede06810..7b414b5b89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 08546783ef..4a3cf8de66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -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 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java index b26b99bb56..2af90a0e43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RetrieveProfileJob.java @@ -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 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 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java new file mode 100644 index 0000000000..d76e93f4bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java @@ -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 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 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 { + @Override + public @NonNull SubscriptionKeepAliveJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new SubscriptionKeepAliveJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java new file mode 100644 index 0000000000..1f92523aa7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -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 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 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 = 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 { + @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); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index bbb16f1b56..5a3dca7c70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -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 = mutableListOf(KEY_CURRENCY_CODE) + override fun getKeysToIncludeInBackup(): MutableList = mutableListOf(KEY_SUBSCRIPTION_CURRENCY_CODE, KEY_LAST_KEEP_ALIVE_LAUNCH) - private val currencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getCurrency()) } - val observableCurrency: Observable by lazy { currencyPublisher } + private val subscriptionCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) } + val observableSubscriptionCurrency: Observable by lazy { subscriptionCurrencyPublisher } - fun getCurrency(): Currency { - val currencyCode = getString(KEY_CURRENCY_CODE, null) + private val boostCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getBoostCurrency()) } + val observableBoostCurrency: Observable by lazy { boostCurrencyPublisher } + + private val levelUpdateOperationPublisher: Subject> by lazy { BehaviorSubject.createDefault(Optional.fromNullable(getLevelOperation())) } + val levelUpdateOperationObservable: Observable> 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())) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index b822ca22a6..c5c6db2a00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -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() { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 267f01d88b..726192ef7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -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 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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/SubscriberIdKeepAliveListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/SubscriberIdKeepAliveListener.java new file mode 100644 index 0000000000..365032ef06 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/SubscriberIdKeepAliveListener.java @@ -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()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/LevelUpdateOperation.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/LevelUpdateOperation.kt new file mode 100644 index 0000000000..13e9c5e381 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/subscription/LevelUpdateOperation.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscriber.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscriber.kt new file mode 100644 index 0000000000..184a88d862 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscriber.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt index fe906fa264..ec398e9617 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscription.kt @@ -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(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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/SubscriptionNotification.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/SubscriptionNotification.kt new file mode 100644 index 0000000000..017a8c68c6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/subscription/SubscriptionNotification.kt @@ -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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index d188ea0b83..e4e26c6a80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -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 + ) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 439fad1b03..adf5ae4745 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java index 17427b363d..bbc753d041 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java @@ -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 = 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))); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java index 481836bf24..ff61bd33b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UsernameUtil.java @@ -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(); diff --git a/app/src/main/res/layout/boost_bottom_sheet.xml b/app/src/main/res/layout/boost_bottom_sheet.xml new file mode 100644 index 0000000000..74bbcac15f --- /dev/null +++ b/app/src/main/res/layout/boost_bottom_sheet.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/indeterminate_loading_circle_pref.xml b/app/src/main/res/layout/indeterminate_loading_circle_pref.xml new file mode 100644 index 0000000000..219689e6f6 --- /dev/null +++ b/app/src/main/res/layout/indeterminate_loading_circle_pref.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/processing_payment_dialog.xml b/app/src/main/res/layout/processing_payment_dialog.xml new file mode 100644 index 0000000000..609483bddd --- /dev/null +++ b/app/src/main/res/layout/processing_payment_dialog.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/subscribe_fragment.xml b/app/src/main/res/layout/subscribe_fragment.xml new file mode 100644 index 0000000000..94760a3c7e --- /dev/null +++ b/app/src/main/res/layout/subscribe_fragment.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/subscription_flow_badge_preview_preference.xml b/app/src/main/res/layout/subscription_flow_badge_preview_preference.xml index 01da4b0c31..efcdbb4d15 100644 --- a/app/src/main/res/layout/subscription_flow_badge_preview_preference.xml +++ b/app/src/main/res/layout/subscription_flow_badge_preview_preference.xml @@ -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" /> diff --git a/app/src/main/res/layout/thanks_for_your_support_bottom_sheet_dialog_fragment.xml b/app/src/main/res/layout/thanks_for_your_support_bottom_sheet_dialog_fragment.xml index 8b75da38cf..d18c8f2092 100644 --- a/app/src/main/res/layout/thanks_for_your_support_bottom_sheet_dialog_fragment.xml +++ b/app/src/main/res/layout/thanks_for_your_support_bottom_sheet_dialog_fragment.xml @@ -55,6 +55,16 @@ app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_subhead" tools:src="@drawable/test_gradient" /> + + + app:layout_constraintStart_toStartOf="@id/thanks_bottom_sheet_control_text" + app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_control_text" /> + app:layout_constraintStart_toEndOf="@id/thanks_bottom_sheet_control_text" + app:layout_constraintTop_toTopOf="@id/thanks_bottom_sheet_control_text" /> + + + app:layout_constraintTop_toBottomOf="@id/thanks_bottom_sheet_featured_note" + app:layout_goneMarginTop="36dp" /> \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 7933d27707..b0c3b2ae59 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -452,6 +452,14 @@ app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + diff --git a/app/src/main/res/navigation/boosts.xml b/app/src/main/res/navigation/boosts.xml index edf56b68e2..e106180bf4 100644 --- a/app/src/main/res/navigation/boosts.xml +++ b/app/src/main/res/navigation/boosts.xml @@ -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"> + + + + tools:layout="@layout/dsl_settings_fragment" > + + + + Debug Log: Could not upload logs Please be as descriptive as possible to help us understand the issue. - + -- Please select an option -- Something\'s Not Working Feature Request Question Feedback Other - Payments + Payments (MobileCoin) + Donations (Sustainers & Signal Boost) @@ -3893,9 +3894,13 @@ Confirm Update Subscription Your subscription has been cancelled. + Update subscription? + Update + You will be charged the full amount of the new subscription price today. Your subscription will renew %1$s. %s/month %1$s/month · Renews %2$s + %1$s/month · Expires %2$s 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. Why Contribute? @@ -3908,6 +3913,7 @@ Display on Profile Make featured badge Done + When you have more than one badge, you can choose one to feature for others to see on your profile. My support Manage subscription @@ -3931,6 +3937,21 @@ Become a subscriber Not now + Subscription Verification Failed + Please contact support for more information. + Contact Support + Earn a %1$s badge + + Processing payment... + Payment failed + Your payment couldn\'t be processed and you have not been charged. Please try again. + Redemption still pending + You may not see your badge right away, but we\'re working on it! + Google Pay Unavailable + You have to set up Google Pay to donate in-app. + Failed to cancel subscription + Subscription cancellation requires an internet connection. + diff --git a/app/src/test/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformationTest__mdpi.kt b/app/src/test/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformationTest__mdpi.kt deleted file mode 100644 index 38cce0d7b9..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformationTest__mdpi.kt +++ /dev/null @@ -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()) - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformationTest__xxxhdpi.kt b/app/src/test/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformationTest__xxxhdpi.kt deleted file mode 100644 index cfa9911473..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/badges/glide/BadgeSpriteTransformationTest__xxxhdpi.kt +++ /dev/null @@ -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()) - } -} diff --git a/donations/app/src/main/java/org/signal/donations/app/MainActivity.java b/donations/app/src/main/java/org/signal/donations/app/MainActivity.java index 3734f7dddd..5d3da39869 100644 --- a/donations/app/src/main/java/org/signal/donations/app/MainActivity.java +++ b/donations/app/src/main/java/org/signal/donations/app/MainActivity.java @@ -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); } diff --git a/donations/lib/src/main/java/org/signal/donations/GooglePayApi.kt b/donations/lib/src/main/java/org/signal/donations/GooglePayApi.kt index d8fbe24ebc..86d1f7cc93 100644 --- a/donations/lib/src/main/java/org/signal/donations/GooglePayApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/GooglePayApi.kt @@ -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 val allowedCardNetworks: List diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 7411f25e16..7ee9860fbf 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -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 { + 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 { @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 = mapOf( @@ -295,11 +327,21 @@ class StripeApi(private val configuration: Configuration, private val paymentInt ): Single } + interface SetupIntentHelper { + fun fetchSetupIntent(): Single + 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 } diff --git a/libsignal/service/build.gradle b/libsignal/service/build.gradle index 16f5fbec67..e88224c63c 100644 --- a/libsignal/service/build.gradle +++ b/libsignal/service/build.gradle @@ -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 diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index 0e4cee7792..0ecf00e7a8 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -785,11 +785,11 @@ public class SignalServiceAccountManager { profileAvatarData); } - public Optional resolveProfileKeyCredential(UUID uuid, ProfileKey profileKey) + public Optional 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); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java index 3f33231ca3..f16862923d 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageReceiver.java @@ -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 retrieveProfile(SignalServiceAddress address, - Optional profileKey, - Optional unidentifiedAccess, - SignalServiceProfile.RequestType requestType) + Optional profileKey, + Optional 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) + public SignalServiceProfile retrieveProfileByUsername(String username, Optional 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) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ClientZkOperations.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ClientZkOperations.java index c9be5286ae..63636436cf 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ClientZkOperations.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/ClientZkOperations.java @@ -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; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java index 6ec6919b75..1e8de5bf2a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java @@ -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 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 getSprites6() { + return sprites6; } public BigDecimal getExpiration() { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index 80b951b61c..a194c12542 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -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> 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> 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> 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> 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> 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> cancelSubscription(SubscriberId subscriberId) { + return createServiceResponse(() -> { + pushServiceSocket.deleteSubscription(subscriberId.serialize()); + return new Pair<>(EmptyResponse.INSTANCE, 200); + }); + } + + public Single> 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> createSubscriptionPaymentMethod(SubscriberId subscriberId) { + return createServiceResponse(() -> { + SubscriptionClientSecret clientSecret = pushServiceSocket.createSubscriptionPaymentMethod(subscriberId.serialize()); + return new Pair<>(clientSecret, 200); + }); + } + + public Single> submitReceiptCredentialRequest(SubscriberId subscriberId, ReceiptCredentialRequest receiptCredentialRequest) { + return createServiceResponse(() -> { + ReceiptCredentialResponse response = pushServiceSocket.submitReceiptCredentials(subscriberId.serialize(), receiptCredentialRequest); + return new Pair<>(response, 200); + }); + } + + private Single> createServiceResponse(Producer producer) { return Single.fromCallable(() -> { try { - return ServiceResponse.forResult(this.pushServiceSocket.createDonationIntentWithAmount(amount, currencyCode), 200, null); + Pair 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.forApplicationError(e, e.getCode(), e.getMessage()); } catch (IOException e) { - return ServiceResponse.forUnknownError(e); + Log.w(TAG, "An unknown error occurred.", e); + return ServiceResponse.forUnknownError(e); } }).subscribeOn(Schedulers.io()); } + + private interface Producer { + Pair produce() throws IOException; + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/ProfileService.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/ProfileService.java index 02af01784d..b7744e3415 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/ProfileService.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/ProfileService.java @@ -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> getProfile(SignalServiceAddress address, Optional profileKey, Optional 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 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> restFallback(SignalServiceAddress address, Optional profileKey, Optional 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)); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java new file mode 100644 index 0000000000..c92451119f --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java @@ -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; + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/IdempotencyKey.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/IdempotencyKey.java new file mode 100644 index 0000000000..96a028d924 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/IdempotencyKey.java @@ -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); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriberId.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriberId.java new file mode 100644 index 0000000000..09501ee100 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriberId.java @@ -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); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriptionClientSecret.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriptionClientSecret.java new file mode 100644 index 0000000000..f77218fc07 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriptionClientSecret.java @@ -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; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriptionLevels.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriptionLevels.java new file mode 100644 index 0000000000..fc8237974a --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriptionLevels.java @@ -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 levels; + + @JsonCreator + public SubscriptionLevels(@JsonProperty("levels") Map levels) { + this.levels = levels; + } + + public Map getLevels() { + return levels; + } + + /** + * An available subscription level + */ + public static final class Level { + private final SignalServiceProfile.Badge badge; + private final Map currencies; + + @JsonCreator + public Level(@JsonProperty("badge") SignalServiceProfile.Badge badge, + @JsonProperty("currencies") Map currencies) + { + this.badge = badge; + this.currencies = currencies; + } + + public SignalServiceProfile.Badge getBadge() { + return badge; + } + + public Map getCurrencies() { + return currencies; + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 51fb9008ed..48e8aa6bb7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -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 retrieveProfile(SignalServiceAddress target, Optional unidentifiedAccess) { - ListenableFuture response = submitServiceRequest(String.format(PROFILE_PATH, target.getIdentifier()), "GET", null, NO_HEADERS, unidentifiedAccess); + public ListenableFuture retrieveProfile(SignalServiceAddress target, Optional unidentifiedAccess, Locale locale) { + ListenableFuture 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) + public SignalServiceProfile retrieveProfileByUsername(String username, Optional 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 retrieveVersionedProfileAndCredential(UUID target, ProfileKey profileKey, Optional unidentifiedAccess) { + public ListenableFuture retrieveVersionedProfileAndCredential(UUID target, ProfileKey profileKey, Optional 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 response = submitServiceRequest(String.format(PROFILE_PATH, subPath), "GET", null, NO_HEADERS, unidentifiedAccess); + + ListenableFuture 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 retrieveVersionedProfile(UUID target, ProfileKey profileKey, Optional unidentifiedAccess) { + public ListenableFuture retrieveVersionedProfile(UUID target, ProfileKey profileKey, Optional unidentifiedAccess, Locale locale) { ProfileKeyVersion profileKeyIdentifier = profileKey.getProfileKeyVersion(target); String version = profileKeyIdentifier.serialize(); String subPath = String.format("%s/%s", target, version); - ListenableFuture response = submitServiceRequest(String.format(PROFILE_PATH, subPath), "GET", null, NO_HEADERS, unidentifiedAccess); + ListenableFuture 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 retrieveDirectory(Set 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 submitServiceRequest(String urlFragment, String method, String jsonBody, Map headers, Optional unidentifiedAccessKey) { + private ListenableFuture submitServiceRequest(String urlFragment, + String method, + String jsonBody, + Map headers, + Optional 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 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 headers, + ResponseCodeHandler responseCodeHandler, + Optional 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 headers, Optional unidentifiedAccess) + private Response getServiceConnection(String urlFragment, + String method, + RequestBody body, + Map headers, + Optional 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 headers, Optional unidentifiedAccess) { + private Request buildServiceRequest(String urlFragment, + String method, + RequestBody body, + Map headers, + Optional 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) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReceiptCredentialRequestJson.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReceiptCredentialRequestJson.java new file mode 100644 index 0000000000..2064b15415 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReceiptCredentialRequestJson.java @@ -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()); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReceiptCredentialResponseJson.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReceiptCredentialResponseJson.java new file mode 100644 index 0000000000..1ae020ab0f --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/ReceiptCredentialResponseJson.java @@ -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; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RedeemReceiptRequest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RedeemReceiptRequest.java index fcf36fb1b7..683bd0dc48 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RedeemReceiptRequest.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RedeemReceiptRequest.java @@ -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; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/AcceptLanguagesUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/AcceptLanguagesUtil.java new file mode 100644 index 0000000000..d9a8f1ed1e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/AcceptLanguagesUtil.java @@ -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 getHeadersWithAcceptLanguage(Locale locale) { + Map 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"; + } + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.java new file mode 100644 index 0000000000..fc2ad501f3 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/services/DonationsServiceTest.java @@ -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> 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> 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); + } +}