Implement donation receipts.

This commit is contained in:
Alex Hart
2022-02-22 13:41:36 -04:00
committed by Greyson Parrelli
parent 63dab3f4b0
commit 7b499f96be
35 changed files with 1286 additions and 11 deletions

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
@@ -95,7 +96,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall())
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge())
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost())
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(paymentData, result.paymentIntent)
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentData, result.paymentIntent)
}
}
}
@@ -139,14 +140,15 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
}
}
private fun confirmPayment(paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
private fun confirmPayment(price: FiatMoney, paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
Log.d(TAG, "Confirming payment intent...", true)
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext {
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it))
}
val waitOnRedemption = Completable.create {
Log.d(TAG, "Confirmed payment intent.", true)
Log.d(TAG, "Confirmed payment intent. Recording boost receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(DonationReceiptRecord.createForBoost(price))
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null

View File

@@ -147,6 +147,14 @@ class ManageDonationsFragment : DSLSettingsFragment() {
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
linkId = R.string.donate_url
)
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__tax_receipts),
icon = DSLSettingsIcon.from(R.drawable.ic_receipt_24),
onClick = {
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonationReceiptListFragment())
}
)
}
}

View File

@@ -0,0 +1,161 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
import android.app.ProgressDialog
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.view.drawToBitmap
import androidx.fragment.app.viewModels
import com.google.android.material.button.MaterialButton
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.SplashImage
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.concurrent.SimpleTask
import java.io.ByteArrayOutputStream
import java.util.Locale
class DonationReceiptDetailFragment : DSLSettingsFragment(layoutId = R.layout.donation_receipt_detail_fragment) {
private lateinit var progressDialog: ProgressDialog
private val viewModel: DonationReceiptDetailViewModel by viewModels(
factoryProducer = {
DonationReceiptDetailViewModel.Factory(
DonationReceiptDetailFragmentArgs.fromBundle(requireArguments()).id,
DonationReceiptDetailRepository()
)
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
SplashImage.register(adapter)
val sharePngButton: MaterialButton = requireView().findViewById(R.id.share_png)
sharePngButton.isEnabled = false
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.donationReceiptRecord != null) {
adapter.submitList(getConfiguration(state.donationReceiptRecord, state.subscriptionName).toMappingModelList())
}
if (state.donationReceiptRecord != null && state.subscriptionName != null) {
sharePngButton.isEnabled = true
sharePngButton.setOnClickListener {
renderPng(state.donationReceiptRecord, state.subscriptionName)
}
}
}
}
private fun renderPng(record: DonationReceiptRecord, subscriptionName: String) {
progressDialog = ProgressDialog(requireContext())
progressDialog.show()
val today: String = DateUtils.formatDateWithDayOfWeek(Locale.getDefault(), System.currentTimeMillis())
val amount: String = FiatMoneyUtil.format(resources, record.amount)
val type: String = when (record.type) {
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
}
val datePaid: String = DateUtils.formatDate(Locale.getDefault(), record.timestamp)
SimpleTask.run(viewLifecycleOwner.lifecycle, {
val outputStream = ByteArrayOutputStream()
val view = LayoutInflater
.from(requireContext())
.inflate(R.layout.donation_receipt_png, null)
view.findViewById<TextView>(R.id.date).text = today
view.findViewById<TextView>(R.id.amount).text = amount
view.findViewById<TextView>(R.id.donation_type).text = type
view.findViewById<TextView>(R.id.date_paid).text = datePaid
view.measure(View.MeasureSpec.makeMeasureSpec(DONATION_RECEIPT_WIDTH, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
val bitmap = view.drawToBitmap()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, outputStream)
BlobProvider.getInstance()
.forData(outputStream.toByteArray())
.withMimeType("image/png")
.withFileName("Signal-Donation-Receipt.png")
.createForSingleSessionInMemory()
}, {
progressDialog.dismiss()
openShareSheet(it)
})
}
private fun openShareSheet(uri: Uri) {
val mimeType = Intent.normalizeMimeType("image/png")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setStream(uri)
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "No activity existed to share the media.", e)
Toast.makeText(requireContext(), R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show()
}
}
private fun getConfiguration(record: DonationReceiptRecord, subscriptionName: String?): DSLConfiguration {
return configure {
customPref(
SplashImage.Model(
splashImageResId = R.drawable.ic_signal_logo_type
)
)
textPref(
title = DSLSettingsText.from(
charSequence = FiatMoneyUtil.format(resources, record.amount),
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_Giant),
DSLSettingsText.CenterModifier
)
)
dividerPref()
textPref(
title = DSLSettingsText.from(R.string.DonationReceiptDetailsFragment__donation_type),
summary = DSLSettingsText.from(
when (record.type) {
DonationReceiptRecord.Type.RECURRING -> getString(R.string.DonationReceiptDetailsFragment__s_dash_s, subscriptionName, getString(R.string.DonationReceiptListFragment__recurring))
DonationReceiptRecord.Type.BOOST -> getString(R.string.DonationReceiptListFragment__one_time)
}
)
)
textPref(
title = DSLSettingsText.from(R.string.DonationReceiptDetailsFragment__date_paid),
summary = record.let { DSLSettingsText.from(DateUtils.formatDateWithYear(Locale.getDefault(), it.timestamp)) }
)
}
}
companion object {
private const val DONATION_RECEIPT_WIDTH = 1916
private val TAG = Log.tag(DonationReceiptDetailFragment::class.java)
}
}

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.util.Locale
class DonationReceiptDetailRepository {
fun getSubscriptionLevelName(subscriptionLevel: Int): Single<String> {
return ApplicationDependencies
.getDonationsService()
.getSubscriptionLevels(Locale.getDefault())
.flatMap { it.flattenResult() }
.map { it.levels[subscriptionLevel.toString()] ?: throw Exception("Subscription level $subscriptionLevel not found") }
.map { it.name }
.subscribeOn(Schedulers.io())
}
fun getDonationReceiptRecord(id: Long): Single<DonationReceiptRecord> {
return Single.fromCallable<DonationReceiptRecord> {
SignalDatabase.donationReceipts.getReceipt(id)
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
data class DonationReceiptDetailState(
val donationReceiptRecord: DonationReceiptRecord? = null,
val subscriptionName: String? = null
)

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.livedata.Store
class DonationReceiptDetailViewModel(id: Long, private val repository: DonationReceiptDetailRepository) : ViewModel() {
private val store = Store(DonationReceiptDetailState())
private val disposables = CompositeDisposable()
private var networkDisposable: Disposable
private val cachedRecord: Single<DonationReceiptRecord> = repository.getDonationReceiptRecord(id).cache()
val state: LiveData<DonationReceiptDetailState> = store.stateLiveData
init {
networkDisposable = InternetConnectionObserver
.observe()
.distinctUntilChanged()
.subscribe { isConnected ->
if (isConnected) {
retry()
}
}
refresh()
}
private fun retry() {
if (store.state.subscriptionName == null) {
refresh()
}
}
private fun refresh() {
disposables.clear()
disposables += cachedRecord.subscribe { record ->
store.update { it.copy(donationReceiptRecord = record) }
}
disposables += cachedRecord.flatMap {
if (it.subscriptionLevel > 0) {
repository.getSubscriptionLevelName(it.subscriptionLevel)
} else {
Single.just("")
}
}.subscribe { name ->
store.update { it.copy(subscriptionName = name) }
}
}
override fun onCleared() {
disposables.clear()
networkDisposable.dispose()
}
class Factory(private val id: Long, private val repository: DonationReceiptDetailRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(DonationReceiptDetailViewModel(id, repository)) as T
}
}
}

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
data class DonationReceiptBadge(
val type: DonationReceiptRecord.Type,
val level: Int,
val badge: Badge
)

View File

@@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import android.view.LayoutInflater
import android.view.ViewGroup
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.SectionHeaderPreference
import org.thoughtcrime.securesms.components.settings.SectionHeaderPreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.TextPreference
import org.thoughtcrime.securesms.components.settings.TextPreferenceViewHolder
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.toLocalDateTime
class DonationReceiptListAdapter(onModelClick: (DonationReceiptListItem.Model) -> Unit) : MappingAdapter(), StickyHeaderDecoration.StickyHeaderAdapter<SectionHeaderPreferenceViewHolder> {
init {
registerFactory(TextPreference::class.java, LayoutFactory({ TextPreferenceViewHolder(it) }, R.layout.dsl_preference_item))
DonationReceiptListItem.register(this, onModelClick)
}
override fun getHeaderId(position: Int): Long {
return when (val item = getItem(position)) {
is DonationReceiptListItem.Model -> item.record.timestamp.toLocalDateTime().year.toLong()
else -> StickyHeaderDecoration.StickyHeaderAdapter.NO_HEADER_ID
}
}
override fun onCreateHeaderViewHolder(parent: ViewGroup?, position: Int, type: Int): SectionHeaderPreferenceViewHolder {
return SectionHeaderPreferenceViewHolder(LayoutInflater.from(parent!!.context).inflate(R.layout.dsl_section_header, parent, false))
}
override fun onBindHeaderViewHolder(viewHolder: SectionHeaderPreferenceViewHolder?, position: Int, type: Int) {
viewHolder?.bind(SectionHeaderPreference(DSLSettingsText.from(getHeaderId(position).toString())))
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.BoldSelectionTabItem
import org.thoughtcrime.securesms.components.ControllableTabLayout
class DonationReceiptListFragment : Fragment(R.layout.donation_receipt_list_fragment) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val pager: ViewPager2 = view.findViewById(R.id.pager)
val tabs: ControllableTabLayout = view.findViewById(R.id.tabs)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
pager.adapter = DonationReceiptListPageAdapter(this)
BoldSelectionTabItem.registerListeners(tabs)
TabLayoutMediator(tabs, pager) { tab, position ->
tab.setText(
when (position) {
0 -> R.string.DonationReceiptListFragment__all
1 -> R.string.DonationReceiptListFragment__recurring
2 -> R.string.DonationReceiptListFragment__one_time
else -> error("Unsupported index $position")
}
)
}.attach()
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import java.util.Locale
object DonationReceiptListItem {
fun register(adapter: MappingAdapter, onClick: (Model) -> Unit) {
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onClick) }, R.layout.donation_receipt_list_item))
}
class Model(
val record: DonationReceiptRecord,
val badge: Badge?
) : MappingModel<Model> {
override fun areContentsTheSame(newItem: Model): Boolean = record == newItem.record && badge == newItem.badge
override fun areItemsTheSame(newItem: Model): Boolean = record.id == newItem.record.id
}
private class ViewHolder(itemView: View, private val onClick: (Model) -> Unit) : MappingViewHolder<Model>(itemView) {
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
private val dateView: TextView = itemView.findViewById(R.id.date)
private val typeView: TextView = itemView.findViewById(R.id.type)
private val moneyView: TextView = itemView.findViewById(R.id.money)
override fun bind(model: Model) {
itemView.setOnClickListener { onClick(model) }
badgeView.setBadge(model.badge)
dateView.text = DateUtils.formatDate(Locale.getDefault(), model.record.timestamp)
typeView.setText(
when (model.record.type) {
DonationReceiptRecord.Type.RECURRING -> R.string.DonationReceiptListFragment__recurring
DonationReceiptRecord.Type.BOOST -> R.string.DonationReceiptListFragment__one_time
}
)
moneyView.text = FiatMoneyUtil.format(context.resources, model.record.amount)
}
}
}

View File

@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
class DonationReceiptListPageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = 3
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> DonationReceiptListPageFragment.create(null)
1 -> DonationReceiptListPageFragment.create(DonationReceiptRecord.Type.RECURRING)
2 -> DonationReceiptListPageFragment.create(DonationReceiptRecord.Type.BOOST)
else -> error("Unsupported position $position")
}
}
}

View File

@@ -0,0 +1,82 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.TextPreference
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class DonationReceiptListPageFragment : Fragment(R.layout.donation_receipt_list_page_fragment) {
private val viewModel: DonationReceiptListPageViewModel by viewModels(factoryProducer = {
DonationReceiptListPageViewModel.Factory(type, DonationReceiptListPageRepository())
})
private val sharedViewModel: DonationReceiptListViewModel by viewModels(
ownerProducer = { requireParentFragment() },
factoryProducer = {
DonationReceiptListViewModel.Factory(DonationReceiptListRepository())
}
)
private val type: DonationReceiptRecord.Type?
get() = requireArguments().getString(ARG_TYPE)?.let { DonationReceiptRecord.Type.fromCode(it) }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = DonationReceiptListAdapter { model ->
findNavController().safeNavigate(DonationReceiptListFragmentDirections.actionDonationReceiptListFragmentToDonationReceiptDetailFragment(model.record.id))
}
view.findViewById<RecyclerView>(R.id.recycler).apply {
this.adapter = adapter
addItemDecoration(StickyHeaderDecoration(adapter, false, true, 0))
}
LiveDataUtil.combineLatest(
viewModel.state,
sharedViewModel.state
) { records, badges ->
records.map { DonationReceiptListItem.Model(it, getBadgeForRecord(it, badges)) }
}.observe(viewLifecycleOwner) { records ->
adapter.submitList(
records +
TextPreference(
title = null,
summary = DSLSettingsText.from(
R.string.DonationReceiptListFragment__if_you_have,
DSLSettingsText.TextAppearanceModifier(R.style.TextAppearance_Signal_Subtitle)
)
)
)
}
}
private fun getBadgeForRecord(record: DonationReceiptRecord, badges: List<DonationReceiptBadge>): Badge? {
return when (record.type) {
DonationReceiptRecord.Type.BOOST -> badges.firstOrNull { it.type == DonationReceiptRecord.Type.BOOST }?.badge
else -> badges.firstOrNull { it.level == record.subscriptionLevel }?.badge
}
}
companion object {
private const val ARG_TYPE = "arg_type"
fun create(type: DonationReceiptRecord.Type?): Fragment {
return DonationReceiptListPageFragment().apply {
arguments = Bundle().apply {
putString(ARG_TYPE, type?.code)
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
class DonationReceiptListPageRepository {
fun getRecords(type: DonationReceiptRecord.Type?): Single<List<DonationReceiptRecord>> {
return Single.fromCallable {
SignalDatabase.donationReceipts.getReceipts(type)
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
class DonationReceiptListPageViewModel(type: DonationReceiptRecord.Type?, repository: DonationReceiptListPageRepository) : ViewModel() {
private val disposables = CompositeDisposable()
private val internalState = MutableLiveData<List<DonationReceiptRecord>>()
val state: LiveData<List<DonationReceiptRecord>> = internalState
init {
disposables += repository.getRecords(type)
.subscribe { records ->
internalState.postValue(records)
}
}
override fun onCleared() {
disposables.clear()
}
class Factory(private val type: DonationReceiptRecord.Type?, private val repository: DonationReceiptListPageRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(DonationReceiptListPageViewModel(type, repository)) as T
}
}
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.util.Locale
class DonationReceiptListRepository {
fun getBadges(): Single<List<DonationReceiptBadge>> {
val boostBadges: Single<List<DonationReceiptBadge>> = ApplicationDependencies.getDonationsService().getBoostBadge(Locale.getDefault())
.map { response ->
if (response.result.isPresent) {
listOf(DonationReceiptBadge(DonationReceiptRecord.Type.BOOST, -1, Badges.fromServiceBadge(response.result.get())))
} else {
emptyList()
}
}
val subBadges: Single<List<DonationReceiptBadge>> = ApplicationDependencies.getDonationsService().getSubscriptionLevels(Locale.getDefault())
.map { response ->
if (response.result.isPresent) {
response.result.get().levels.map {
DonationReceiptBadge(
level = it.key.toInt(),
badge = Badges.fromServiceBadge(it.value.badge),
type = DonationReceiptRecord.Type.RECURRING
)
}
} else {
emptyList()
}
}
return boostBadges.zipWith(subBadges) { a, b -> a + b }.subscribeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.receipts.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.util.InternetConnectionObserver
class DonationReceiptListViewModel(private val repository: DonationReceiptListRepository) : ViewModel() {
private val disposables = CompositeDisposable()
private val internalState = MutableLiveData<List<DonationReceiptBadge>>(emptyList())
private var networkDisposable: Disposable
val state: LiveData<List<DonationReceiptBadge>> = internalState
init {
networkDisposable = InternetConnectionObserver
.observe()
.distinctUntilChanged()
.subscribe { isConnected ->
if (isConnected) {
retry()
}
}
refresh()
}
private fun retry() {
if (internalState.value?.isEmpty() == true) {
refresh()
}
}
private fun refresh() {
disposables.clear()
disposables += repository.getBadges().subscribe { badges ->
internalState.postValue(badges)
}
}
override fun onCleared() {
disposables.clear()
networkDisposable.dispose()
}
class Factory(private val repository: DonationReceiptListRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(DonationReceiptListViewModel(repository)) as T
}
}
}

View File

@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.database
import android.content.Context
import android.database.Cursor
import androidx.core.content.contentValuesOf
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.util.CursorUtil
import org.thoughtcrime.securesms.util.SqlUtil
import java.math.BigDecimal
import java.util.Currency
class DonationReceiptDatabase(context: Context, databaseHelper: SignalDatabase) : Database(context, databaseHelper) {
companion object {
private const val TABLE_NAME = "donation_receipt"
private const val ID = "_id"
private const val TYPE = "receipt_type"
private const val DATE = "receipt_date"
private const val AMOUNT = "amount"
private const val CURRENCY = "currency"
private const val SUBSCRIPTION_LEVEL = "subscription_level"
@JvmField
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$TYPE TEXT NOT NULL,
$DATE INTEGER NOT NULL,
$AMOUNT TEXT NOT NULL,
$CURRENCY TEXT NOT NULL,
$SUBSCRIPTION_LEVEL INTEGER NOT NULL
)
""".trimIndent()
val CREATE_INDEXS = arrayOf(
"CREATE INDEX IF NOT EXISTS donation_receipt_type_index ON $TABLE_NAME ($TYPE)",
"CREATE INDEX IF NOT EXISTS donation_receipt_date_index ON $TABLE_NAME ($DATE)"
)
}
fun addReceipt(record: DonationReceiptRecord) {
require(record.id == -1L)
val values = contentValuesOf(
AMOUNT to record.amount.amount.toString(),
CURRENCY to record.amount.currency.currencyCode,
DATE to record.timestamp,
TYPE to record.type.code,
SUBSCRIPTION_LEVEL to record.subscriptionLevel
)
writableDatabase.insert(TABLE_NAME, null, values)
}
fun getReceipt(id: Long): DonationReceiptRecord? {
readableDatabase.query(TABLE_NAME, null, ID_WHERE, SqlUtil.buildArgs(id), null, null, null).use { cursor ->
return if (cursor.moveToNext()) {
readRecord(cursor)
} else {
null
}
}
}
fun getReceipts(type: DonationReceiptRecord.Type?): List<DonationReceiptRecord> {
val (where, whereArgs) = if (type != null) {
"$TYPE = ?" to SqlUtil.buildArgs(type.code)
} else {
null to null
}
readableDatabase.query(TABLE_NAME, null, where, whereArgs, null, null, "$DATE DESC").use { cursor ->
val results = ArrayList<DonationReceiptRecord>(cursor.count)
while (cursor.moveToNext()) {
results.add(readRecord(cursor))
}
return results
}
}
private fun readRecord(cursor: Cursor): DonationReceiptRecord {
return DonationReceiptRecord(
id = CursorUtil.requireLong(cursor, ID),
type = DonationReceiptRecord.Type.fromCode(CursorUtil.requireString(cursor, TYPE)),
amount = FiatMoney(
BigDecimal(CursorUtil.requireString(cursor, AMOUNT)),
Currency.getInstance(CursorUtil.requireString(cursor, CURRENCY))
),
timestamp = CursorUtil.requireLong(cursor, DATE),
subscriptionLevel = CursorUtil.requireInt(cursor, SUBSCRIPTION_LEVEL)
)
}
}

View File

@@ -71,6 +71,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val groupCallRingDatabase: GroupCallRingDatabase = GroupCallRingDatabase(context, this)
val reactionDatabase: ReactionDatabase = ReactionDatabase(context, this)
val notificationProfileDatabase: NotificationProfileDatabase = NotificationProfileDatabase(context, this)
val donationReceiptDatabase: DonationReceiptDatabase = DonationReceiptDatabase(context, this)
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
db.enableWriteAheadLogging()
@@ -103,6 +104,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
db.execSQL(AvatarPickerDatabase.CREATE_TABLE)
db.execSQL(GroupCallRingDatabase.CREATE_TABLE)
db.execSQL(ReactionDatabase.CREATE_TABLE)
db.execSQL(DonationReceiptDatabase.CREATE_TABLE)
executeStatements(db, SearchDatabase.CREATE_TABLE)
executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE)
executeStatements(db, MessageSendLogDatabase.CREATE_TABLE)
@@ -123,6 +125,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
executeStatements(db, MessageSendLogDatabase.CREATE_INDEXES)
executeStatements(db, GroupCallRingDatabase.CREATE_INDEXES)
executeStatements(db, NotificationProfileDatabase.CREATE_INDEXES)
executeStatements(db, DonationReceiptDatabase.CREATE_INDEXS)
executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS)
executeStatements(db, ReactionDatabase.CREATE_TRIGGERS)
@@ -466,5 +469,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
@get:JvmName("notificationProfiles")
val notificationProfiles: NotificationProfileDatabase
get() = instance!!.notificationProfileDatabase
@get:JvmStatic
@get:JvmName("donationReceipts")
val donationReceipts: DonationReceiptDatabase
get() = instance!!.donationReceiptDatabase
}
}

View File

@@ -48,9 +48,6 @@ import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.lang.AssertionError
import java.util.ArrayList
import java.util.HashSet
import java.util.LinkedList
import java.util.Locale
@@ -190,8 +187,9 @@ object SignalDatabaseMigrations {
private const val MESSAGE_RANGES = 128
private const val REACTION_TRIGGER_FIX = 129
private const val PNI_STORES = 130
private const val DONATION_RECEIPTS = 131
const val DATABASE_VERSION = 130
const val DATABASE_VERSION = 131
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -2397,6 +2395,25 @@ object SignalDatabaseMigrations {
db.execSQL("DROP TABLE sessions")
db.execSQL("ALTER TABLE sessions_tmp RENAME TO sessions")
}
if (oldVersion < DONATION_RECEIPTS) {
db.execSQL(
// language=sql
"""
CREATE TABLE donation_receipt (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
receipt_type TEXT NOT NULL,
receipt_date INTEGER NOT NULL,
amount TEXT NOT NULL,
currency TEXT NOT NULL,
subscription_level INTEGER NOT NULL
)
""".trimIndent()
)
db.execSQL("CREATE INDEX IF NOT EXISTS donation_receipt_type_index ON donation_receipt (receipt_type);")
db.execSQL("CREATE INDEX IF NOT EXISTS donation_receipt_date_index ON donation_receipt (receipt_date);")
}
}
@JvmStatic

View File

@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.database.model
import org.signal.core.util.money.FiatMoney
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
data class DonationReceiptRecord(
val id: Long = -1L,
val amount: FiatMoney,
val timestamp: Long,
val type: Type,
val subscriptionLevel: Int
) {
enum class Type(val code: String) {
RECURRING("recurring"),
BOOST("boost");
companion object {
fun fromCode(code: String): Type {
return values().first { it.code == code }
}
}
}
companion object {
@JvmStatic
fun createForSubscription(subscription: ActiveSubscription.Subscription): DonationReceiptRecord {
val activeCurrency = Currency.getInstance(subscription.currency)
val activeAmount = subscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
return DonationReceiptRecord(
id = -1L,
amount = FiatMoney(activeAmount, activeCurrency),
timestamp = System.currentTimeMillis(),
subscriptionLevel = subscription.level,
type = Type.RECURRING
)
}
fun createForBoost(amount: FiatMoney): DonationReceiptRecord {
return DonationReceiptRecord(
id = -1L,
amount = amount,
timestamp = System.currentTimeMillis(),
subscriptionLevel = -1,
type = Type.BOOST
)
}
}
}

View File

@@ -15,6 +15,8 @@ import org.signal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.zkgroup.receipts.ReceiptSerial;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
@@ -168,7 +170,9 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
throw new IOException("Could not validate receipt credential");
}
Log.d(TAG, "Validated credential. Handing off to redemption job.", true);
Log.d(TAG, "Validated credential. Recording receipt and handing off to redemption job.", true);
SignalDatabase.donationReceipts().addReceipt(DonationReceiptRecord.createForSubscription(subscription));
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
setOutputData(new Data.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
receiptCredentialPresentation.serialize())