From 7b499f96be9d2fff6a30246b3bdb471d0b7d5c6c Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 22 Feb 2022 13:41:36 -0400 Subject: [PATCH] Implement donation receipts. --- .../subscription/DonationPaymentRepository.kt | 8 +- .../manage/ManageDonationsFragment.kt | 8 + .../detail/DonationReceiptDetailFragment.kt | 161 ++++++++++++++++++ .../detail/DonationReceiptDetailRepository.kt | 26 +++ .../detail/DonationReceiptDetailState.kt | 8 + .../detail/DonationReceiptDetailViewModel.kt | 70 ++++++++ .../receipts/list/DonationReceiptBadge.kt | 10 ++ .../list/DonationReceiptListAdapter.kt | 37 ++++ .../list/DonationReceiptListFragment.kt | 40 +++++ .../receipts/list/DonationReceiptListItem.kt | 52 ++++++ .../list/DonationReceiptListPageAdapter.kt | 18 ++ .../list/DonationReceiptListPageFragment.kt | 82 +++++++++ .../list/DonationReceiptListPageRepository.kt | 14 ++ .../list/DonationReceiptListPageViewModel.kt | 34 ++++ .../list/DonationReceiptListRepository.kt | 38 +++++ .../list/DonationReceiptListViewModel.kt | 56 ++++++ .../database/DonationReceiptDatabase.kt | 95 +++++++++++ .../securesms/database/SignalDatabase.kt | 8 + .../helpers/SignalDatabaseMigrations.kt | 25 ++- .../database/model/DonationReceiptRecord.kt | 50 ++++++ ...SubscriptionReceiptRequestResponseJob.java | 6 +- .../main/res/drawable-night/ic_receipt_24.xml | 9 + .../drawable-night/ic_signal_logo_type.xml | 27 +++ app/src/main/res/drawable/ic_receipt_24.xml | 9 + .../main/res/drawable/ic_signal_logo_type.xml | 27 +++ .../drawable/ic_signal_logo_type_light.xml | 27 +++ .../donation_receipt_detail_fragment.xml | 35 ++++ .../layout/donation_receipt_list_fragment.xml | 37 ++++ .../res/layout/donation_receipt_list_item.xml | 57 +++++++ .../donation_receipt_list_page_fragment.xml | 7 + .../main/res/layout/donation_receipt_png.xml | 140 +++++++++++++++ app/src/main/res/layout/splash_image.xml | 2 +- app/src/main/res/navigation/app_settings.xml | 36 +++- app/src/main/res/values/strings.xml | 32 ++++ app/src/main/res/values/text_styles.xml | 6 + 35 files changed, 1286 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptBadge.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListAdapter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListItem.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageAdapter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptDatabase.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/DonationReceiptRecord.kt create mode 100644 app/src/main/res/drawable-night/ic_receipt_24.xml create mode 100644 app/src/main/res/drawable-night/ic_signal_logo_type.xml create mode 100644 app/src/main/res/drawable/ic_receipt_24.xml create mode 100644 app/src/main/res/drawable/ic_signal_logo_type.xml create mode 100644 app/src/main/res/drawable/ic_signal_logo_type_light.xml create mode 100644 app/src/main/res/layout/donation_receipt_detail_fragment.xml create mode 100644 app/src/main/res/layout/donation_receipt_list_fragment.xml create mode 100644 app/src/main/res/layout/donation_receipt_list_item.xml create mode 100644 app/src/main/res/layout/donation_receipt_list_page_fragment.xml create mode 100644 app/src/main/res/layout/donation_receipt_png.xml 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 ee85c6703..9a2c9b1be 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 @@ -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 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 b00b46f2e..2482366a3 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 @@ -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()) + } + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt new file mode 100644 index 000000000..a7fe4bbdf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailFragment.kt @@ -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(R.id.date).text = today + view.findViewById(R.id.amount).text = amount + view.findViewById(R.id.donation_type).text = type + view.findViewById(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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailRepository.kt new file mode 100644 index 000000000..86b57dabf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailRepository.kt @@ -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 { + 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 { + return Single.fromCallable { + SignalDatabase.donationReceipts.getReceipt(id) + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailState.kt new file mode 100644 index 000000000..4f33b592f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailState.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailViewModel.kt new file mode 100644 index 000000000..6915085a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/detail/DonationReceiptDetailViewModel.kt @@ -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 = repository.getDonationReceiptRecord(id).cache() + + val state: LiveData = 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 create(modelClass: Class): T { + return modelClass.cast(DonationReceiptDetailViewModel(id, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptBadge.kt new file mode 100644 index 000000000..e706e23cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptBadge.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListAdapter.kt new file mode 100644 index 000000000..eb10334ae --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListAdapter.kt @@ -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 { + + 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()))) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListFragment.kt new file mode 100644 index 000000000..135ac3b26 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListFragment.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListItem.kt new file mode 100644 index 000000000..ea702e991 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListItem.kt @@ -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 { + 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(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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageAdapter.kt new file mode 100644 index 000000000..d205f184d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageAdapter.kt @@ -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") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageFragment.kt new file mode 100644 index 000000000..ebdb99b8e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageFragment.kt @@ -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(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): 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) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageRepository.kt new file mode 100644 index 000000000..5a72ac62a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageRepository.kt @@ -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> { + return Single.fromCallable { + SignalDatabase.donationReceipts.getReceipts(type) + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageViewModel.kt new file mode 100644 index 000000000..ef6c1b82e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListPageViewModel.kt @@ -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>() + + val state: LiveData> = 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 create(modelClass: Class): T { + return modelClass.cast(DonationReceiptListPageViewModel(type, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListRepository.kt new file mode 100644 index 000000000..a444136c6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListRepository.kt @@ -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> { + val boostBadges: Single> = 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> = 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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListViewModel.kt new file mode 100644 index 000000000..83a6a1f6d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/receipts/list/DonationReceiptListViewModel.kt @@ -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>(emptyList()) + private var networkDisposable: Disposable + + val state: LiveData> = 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 create(modelClass: Class): T { + return modelClass.cast(DonationReceiptListViewModel(repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptDatabase.kt new file mode 100644 index 000000000..eaef91f91 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptDatabase.kt @@ -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 { + 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(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) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 879d39746..8a22a9809 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 0e8f998e5..b3dece3f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DonationReceiptRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DonationReceiptRecord.kt new file mode 100644 index 000000000..ad525fdf4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DonationReceiptRecord.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 9f57b08d8..d6ad89703 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -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()) diff --git a/app/src/main/res/drawable-night/ic_receipt_24.xml b/app/src/main/res/drawable-night/ic_receipt_24.xml new file mode 100644 index 000000000..c01573c87 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_receipt_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_signal_logo_type.xml b/app/src/main/res/drawable-night/ic_signal_logo_type.xml new file mode 100644 index 000000000..7e34a10e9 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_signal_logo_type.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_receipt_24.xml b/app/src/main/res/drawable/ic_receipt_24.xml new file mode 100644 index 000000000..d002b6c4b --- /dev/null +++ b/app/src/main/res/drawable/ic_receipt_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_signal_logo_type.xml b/app/src/main/res/drawable/ic_signal_logo_type.xml new file mode 100644 index 000000000..7b6165cd5 --- /dev/null +++ b/app/src/main/res/drawable/ic_signal_logo_type.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_signal_logo_type_light.xml b/app/src/main/res/drawable/ic_signal_logo_type_light.xml new file mode 100644 index 000000000..7b6165cd5 --- /dev/null +++ b/app/src/main/res/drawable/ic_signal_logo_type_light.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/donation_receipt_detail_fragment.xml b/app/src/main/res/layout/donation_receipt_detail_fragment.xml new file mode 100644 index 000000000..d6c575454 --- /dev/null +++ b/app/src/main/res/layout/donation_receipt_detail_fragment.xml @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/donation_receipt_list_fragment.xml b/app/src/main/res/layout/donation_receipt_list_fragment.xml new file mode 100644 index 000000000..208690c0d --- /dev/null +++ b/app/src/main/res/layout/donation_receipt_list_fragment.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/donation_receipt_list_item.xml b/app/src/main/res/layout/donation_receipt_list_item.xml new file mode 100644 index 000000000..9b77f970e --- /dev/null +++ b/app/src/main/res/layout/donation_receipt_list_item.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/donation_receipt_list_page_fragment.xml b/app/src/main/res/layout/donation_receipt_list_page_fragment.xml new file mode 100644 index 000000000..b35d250c5 --- /dev/null +++ b/app/src/main/res/layout/donation_receipt_list_page_fragment.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/donation_receipt_png.xml b/app/src/main/res/layout/donation_receipt_png.xml new file mode 100644 index 000000000..a13761c15 --- /dev/null +++ b/app/src/main/res/layout/donation_receipt_png.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/splash_image.xml b/app/src/main/res/layout/splash_image.xml index 512f0ce71..3524641f1 100644 --- a/app/src/main/res/layout/splash_image.xml +++ b/app/src/main/res/layout/splash_image.xml @@ -3,4 +3,4 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - tools:srcCompat="@drawable/ic_card_process" /> \ No newline at end of file + tools:srcCompat="@drawable/ic_card_process" /> diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 457492006..65deaf375 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -100,14 +100,14 @@ app:enterAnim="@anim/fragment_open_enter" app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" - app:popExitAnim="@anim/fragment_close_exit"> + app:popExitAnim="@anim/fragment_close_exit" /> + app:popExitAnim="@anim/fragment_close_exit" /> + + + + + + + + + + My support Manage subscription + + Tax Receipts Badges Subscription FAQ Error getting subscription. @@ -4324,6 +4326,36 @@ Signal Release Notes & News + + All activity + + All + + Recurring + + One time + + Boost + + Details + + Donation type + + Date paid + + Share receipt + + If you have reinstalled Signal, receipts from previous donations will not be available. + + Donation receipt + + Amount + + Thank you for supporting Signal. Your contribution helps fuel the mission of developing open source privacy technology that protects free expression and enables secure global communication for millions around the world. If you’re a resident of the United States, please retain this receipt for your tax records. Signal Technology Foundation is a tax–exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82–4506840. + + %1$s - %2$s + + diff --git a/app/src/main/res/values/text_styles.xml b/app/src/main/res/values/text_styles.xml index ae588cab9..7c8a5de74 100644 --- a/app/src/main/res/values/text_styles.xml +++ b/app/src/main/res/values/text_styles.xml @@ -65,6 +65,12 @@ @color/core_white + +