taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

commit 374d9f1ac6908d63a3da2b22818a876d0e16575d
parent df2c70169a6dab48ee5b684588f698034fa01366
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 16 May 2024 19:44:28 -0600

[wallet] implement `checkPayForTemplate' request

bug 0008854

Diffstat:
Mwallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt | 2++
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt | 41+++++++++--------------------------------
Awallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt | 35+++++++++--------------------------
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt | 70++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mwallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt | 24+++++++++++++++++++-----
Mwallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt | 1+
7 files changed, 163 insertions(+), 85 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt @@ -53,6 +53,7 @@ fun AmountInputField( keyboardActions: KeyboardActions = KeyboardActions.Default, decimalFormatSymbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols, numberOfDecimals: Int = DEFAULT_INPUT_DECIMALS, + readOnly: Boolean = false, ) { var amountInput by remember { mutableStateOf(value) } @@ -77,6 +78,7 @@ fun AmountInputField( } }, modifier = modifier, + readOnly = readOnly, textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace), label = label, supportingText = supportingText, diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -34,42 +34,27 @@ import net.taler.wallet.R import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface -sealed class AmountFieldStatus { - object FixedAmount : AmountFieldStatus() - class Default( - val amountStr: String? = null, - val currency: String? = null, - ) : AmountFieldStatus() - - object Invalid : AmountFieldStatus() -} - @Composable fun PayTemplateComposable( - defaultSummary: String?, - amountStatus: AmountFieldStatus, currencies: List<String>, payStatus: PayStatus, onCreateAmount: (String, String) -> AmountResult, - onSubmit: (summary: String?, amount: Amount?) -> Unit, + onSubmit: (params: TemplateParams) -> Unit, onError: (resId: Int) -> Unit, ) { // If wallet is empty, there's no way the user can pay something - if (amountStatus is AmountFieldStatus.Invalid) { - PayTemplateError(stringResource(R.string.amount_invalid)) - } else if (currencies.isEmpty()) { + if (currencies.isEmpty()) { PayTemplateError(stringResource(R.string.payment_balance_insufficient)) } else when (val p = payStatus) { - is PayStatus.None -> PayTemplateOrderComposable( + is PayStatus.Checked -> PayTemplateOrderComposable( currencies = currencies, - defaultSummary = defaultSummary, - amountStatus = amountStatus, + templateDetails = p.details, onCreateAmount = onCreateAmount, onError = onError, onSubmit = onSubmit, ) - is PayStatus.Loading -> PayTemplateLoading() + is PayStatus.None, is PayStatus.Loading -> PayTemplateLoading() is PayStatus.AlreadyPaid -> PayTemplateError(stringResource(R.string.payment_already_paid)) is PayStatus.InsufficientBalance -> PayTemplateError(stringResource(R.string.payment_balance_insufficient)) is PayStatus.Pending -> { @@ -109,14 +94,12 @@ fun PayTemplateLoading() { fun PayTemplateLoadingPreview() { TalerSurface { PayTemplateComposable( - defaultSummary = "Donation", - amountStatus = AmountFieldStatus.Default("20", "ARS"), payStatus = PayStatus.Loading, currencies = listOf("KUDOS", "ARS"), onCreateAmount = { text, currency -> AmountResult.Success(amount = Amount.fromString(currency, text)) }, - onSubmit = { _, _ -> }, + onSubmit = { _ -> }, onError = { _ -> }, ) } @@ -127,8 +110,6 @@ fun PayTemplateLoadingPreview() { fun PayTemplateInsufficientBalancePreview() { TalerSurface { PayTemplateComposable( - defaultSummary = "Donation", - amountStatus = AmountFieldStatus.Default("20", "ARS"), payStatus = PayStatus.InsufficientBalance( ContractTerms( "test", @@ -140,7 +121,7 @@ fun PayTemplateInsufficientBalancePreview() { onCreateAmount = { text, currency -> AmountResult.Success(amount = Amount.fromString(currency, text)) }, - onSubmit = { _, _ -> }, + onSubmit = { _ -> }, onError = { _ -> }, ) } @@ -151,14 +132,12 @@ fun PayTemplateInsufficientBalancePreview() { fun PayTemplateAlreadyPaidPreview() { TalerSurface { PayTemplateComposable( - defaultSummary = "Donation", - amountStatus = AmountFieldStatus.Default("20", "ARS"), payStatus = PayStatus.AlreadyPaid(transactionId = "transactionId"), currencies = listOf("KUDOS", "ARS"), onCreateAmount = { text, currency -> AmountResult.Success(amount = Amount.fromString(currency, text)) }, - onSubmit = { _, _ -> }, + onSubmit = { _ -> }, onError = { _ -> }, ) } @@ -170,14 +149,12 @@ fun PayTemplateAlreadyPaidPreview() { fun PayTemplateNoCurrenciesPreview() { TalerSurface { PayTemplateComposable( - defaultSummary = "Donation", - amountStatus = AmountFieldStatus.Default("20", "ARS"), payStatus = PayStatus.None, currencies = emptyList(), onCreateAmount = { text, currency -> AmountResult.Success(amount = Amount.fromString(currency, text)) }, - onSubmit = { _, _ -> }, + onSubmit = { _ -> }, onError = { _ -> }, ) } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateDetails.kt @@ -0,0 +1,74 @@ +/* + * This file is part of GNU Taler + * (C) 2024 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +package net.taler.wallet.payment + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.taler.common.Amount +import net.taler.common.RelativeTime + +@Serializable +data class TemplateContractDetails( + val summary: String? = null, + val currency: String? = null, + val amount: Amount? = null, + @SerialName("minimum_age") + val minimumAge: Int, + @SerialName("pay_duration") + val payDuration: RelativeTime, +) + +@Serializable +data class TemplateContractDetailsDefaults( + val summary: String? = null, + val currency: String? = null, + /** + * Amount *or* a plain currency string. + */ + val amount: String? = null, + @SerialName("minimum_age") + val minimumAge: Int? = null, +) + +fun TemplateContractDetailsDefaults?.isNullOrEmpty() = + this == null || (summary == null + && currency == null + && amount == null + && minimumAge == null) + +@Serializable +class WalletTemplateDetails( + @SerialName("template_contract") + val templateContract: TemplateContractDetails, + @SerialName("editable_defaults") + val editableDefaults: TemplateContractDetailsDefaults? = null, + @SerialName("required_currency") + val requiredCurrency: String? = null, +) + +@Serializable +data class TemplateParams( + val amount: Amount? = null, + val summary: String? = null, +) { + companion object { + fun fromTemplateDetails(details: WalletTemplateDetails) = TemplateParams( + amount = details.templateContract.amount, + summary = details.templateContract.summary, + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt @@ -26,7 +26,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.asFlow import androidx.navigation.fragment.findNavController -import net.taler.common.Amount import net.taler.common.showError import net.taler.wallet.MainViewModel import net.taler.wallet.R @@ -48,10 +47,6 @@ class PayTemplateFragment : Fragment() { uriString = arguments?.getString("uri") ?: error("no amount passed") uri = Uri.parse(uriString) - val defaultSummary = uri.getQueryParameter("summary") - val defaultAmount = uri.getQueryParameter("amount") - val amountFieldStatus = getAmountFieldStatus(defaultAmount) - val payStatusFlow = model.paymentManager.payStatus.asFlow() return ComposeView(requireContext()).apply { @@ -60,8 +55,6 @@ class PayTemplateFragment : Fragment() { TalerSurface { PayTemplateComposable( currencies = model.getCurrencies(), - defaultSummary = defaultSummary, - amountStatus = amountFieldStatus, payStatus = payStatus.value, onCreateAmount = model::createAmount, onSubmit = this@PayTemplateFragment::createOrder, @@ -74,9 +67,7 @@ class PayTemplateFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - if (uri.queryParameterNames?.isEmpty() == true) { - createOrder(null, null) - } + checkTemplate() model.paymentManager.payStatus.observe(viewLifecycleOwner) { payStatus -> when (payStatus) { @@ -88,28 +79,20 @@ class PayTemplateFragment : Fragment() { showError(payStatus.error) } + is PayStatus.Checked -> if (payStatus.details.editableDefaults.isNullOrEmpty()) { + createOrder(TemplateParams.fromTemplateDetails(payStatus.details)) + } + else -> {} } } } - private fun getAmountFieldStatus(defaultAmount: String?): AmountFieldStatus { - return if (defaultAmount == null) { - AmountFieldStatus.FixedAmount - } else if (defaultAmount.isBlank()) { - AmountFieldStatus.Default() - } else { - val parts = defaultAmount.split(":") - when (parts.size) { - 0 -> AmountFieldStatus.Default() - 1 -> AmountFieldStatus.Default(currency = parts[0]) - 2 -> AmountFieldStatus.Default(parts[1], parts[0]) - else -> AmountFieldStatus.Invalid - } - } + private fun checkTemplate() { + model.paymentManager.checkPayForTemplate(uriString) } - private fun createOrder(summary: String?, amount: Amount?) { - model.paymentManager.preparePayForTemplate(uriString, summary, amount) + private fun createOrder(params: TemplateParams) { + model.paymentManager.preparePayForTemplate(uriString, params) } } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateOrderComposable.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.common.Amount +import net.taler.common.RelativeTime import net.taler.wallet.AmountResult import net.taler.wallet.R import net.taler.wallet.compose.AmountInputField @@ -43,17 +44,24 @@ import net.taler.wallet.deposit.CurrencyDropdown @Composable fun PayTemplateOrderComposable( currencies: List<String>, // assumed to have size > 0 - defaultSummary: String? = null, - amountStatus: AmountFieldStatus, + templateDetails: WalletTemplateDetails, onCreateAmount: (String, String) -> AmountResult, onError: (msgRes: Int) -> Unit, - onSubmit: (summary: String?, amount: Amount?) -> Unit, + onSubmit: (params: TemplateParams) -> Unit, ) { - val amountDefault = amountStatus as? AmountFieldStatus.Default + val defaultSummary = templateDetails.editableDefaults?.summary + ?: templateDetails.templateContract.summary + // TODO: also handle “plain currency string” + val defaultAmount = templateDetails.editableDefaults?.amount?.let { + Amount.fromJSONString(it).amountStr + } ?: templateDetails.templateContract.amount?.amountStr + // TODO: also take into account `requiredCurrency' + val defaultCurrency = templateDetails.editableDefaults?.currency + ?: templateDetails.templateContract.currency var summary by remember { mutableStateOf(defaultSummary) } - var currency by remember { mutableStateOf(amountDefault?.currency ?: currencies[0]) } - var amount by remember { mutableStateOf(amountDefault?.amountStr ?: "0") } + var currency by remember { mutableStateOf(defaultCurrency ?: currencies[0]) } + var amount by remember { mutableStateOf(defaultAmount ?: "0") } Column(horizontalAlignment = End) { if (defaultSummary != null) OutlinedTextField( @@ -64,29 +72,36 @@ fun PayTemplateOrderComposable( isError = summary.isNullOrBlank(), onValueChange = { summary = it }, singleLine = true, + readOnly = templateDetails.editableDefaults?.summary == null, label = { Text(stringResource(R.string.withdraw_manual_ready_subject)) }, ) - if (amountDefault != null) AmountField( + + if (defaultAmount != null || defaultCurrency != null) AmountField( modifier = Modifier .padding(16.dp) .fillMaxWidth(), amount = amount, currency = currency, currencies = currencies, - fixedCurrency = (amountStatus as? AmountFieldStatus.Default)?.currency != null, + readOnlyCurrency = templateDetails.editableDefaults?.currency == null, + readOnlyAmount = templateDetails.editableDefaults?.amount == null, onAmountChosen = { a, c -> amount = a currency = c }, ) + Button( modifier = Modifier.padding(16.dp), - enabled = defaultSummary == null || !summary.isNullOrBlank(), + enabled = templateDetails.editableDefaults?.summary == null || !summary.isNullOrBlank(), onClick = { when (val res = onCreateAmount(amount, currency)) { is AmountResult.InsufficientBalance -> onError(R.string.payment_balance_insufficient) is AmountResult.InvalidAmount -> onError(R.string.amount_invalid) - is AmountResult.Success -> onSubmit(summary, res.amount) + is AmountResult.Success -> onSubmit(TemplateParams( + summary = summary, + amount = res.amount, + )) } }, ) { @@ -99,9 +114,10 @@ fun PayTemplateOrderComposable( private fun AmountField( modifier: Modifier = Modifier, currencies: List<String>, - fixedCurrency: Boolean, amount: String, currency: String, + readOnlyAmount: Boolean = false, + readOnlyCurrency: Boolean = false, onAmountChosen: (amount: String, currency: String) -> Unit, ) { Row( @@ -113,30 +129,42 @@ private fun AmountField( .weight(1f), value = amount, onValueChange = { onAmountChosen(it, currency) }, - label = { Text(stringResource(R.string.amount_send)) } + label = { Text(stringResource(R.string.amount_send)) }, + readOnly = readOnlyAmount, ) + CurrencyDropdown( modifier = Modifier.weight(1f), initialCurrency = currency, currencies = currencies, onCurrencyChanged = { onAmountChosen(amount, it) }, - readOnly = fixedCurrency, + readOnly = readOnlyCurrency, ) } } +val defaultTemplateDetails = WalletTemplateDetails( + templateContract = TemplateContractDetails( + minimumAge = 18, + payDuration = RelativeTime.forever(), + ), + editableDefaults = TemplateContractDetailsDefaults( + summary = "Donation", + amount = "KUDOS:10.0", + ), +) + @Preview @Composable fun PayTemplateDefaultPreview() { TalerSurface { PayTemplateOrderComposable( - defaultSummary = "Donation", - amountStatus = AmountFieldStatus.Default("20", "ARS"), + templateDetails = defaultTemplateDetails, currencies = listOf("KUDOS", "ARS"), onCreateAmount = { text, currency -> AmountResult.Success(amount = Amount.fromString(currency, text)) }, - onSubmit = { _, _ -> }, + onSubmit = { _ -> }, onError = { }, ) } @@ -147,13 +175,12 @@ fun PayTemplateDefaultPreview() { fun PayTemplateFixedAmountPreview() { TalerSurface { PayTemplateOrderComposable( - defaultSummary = "default summary", - amountStatus = AmountFieldStatus.FixedAmount, + templateDetails = defaultTemplateDetails, currencies = listOf("KUDOS", "ARS"), onCreateAmount = { text, currency -> AmountResult.Success(amount = Amount.fromString(currency, text)) }, - onSubmit = { _, _ -> }, + onSubmit = { _ -> }, onError = { }, ) } @@ -164,13 +191,12 @@ fun PayTemplateFixedAmountPreview() { fun PayTemplateBlankSubjectPreview() { TalerSurface { PayTemplateOrderComposable( - defaultSummary = "", - amountStatus = AmountFieldStatus.FixedAmount, + templateDetails = defaultTemplateDetails, currencies = listOf("KUDOS", "ARS"), onCreateAmount = { text, currency -> AmountResult.Success(amount = Amount.fromString(currency, text)) }, - onSubmit = { _, _ -> }, + onSubmit = { _ -> }, onError = { }, ) } diff --git a/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt b/wallet/src/main/java/net/taler/wallet/payment/PaymentManager.kt @@ -22,9 +22,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString import net.taler.common.Amount import net.taler.common.ContractTerms import net.taler.wallet.TAG +import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.payment.PayStatus.AlreadyPaid @@ -46,6 +48,10 @@ sealed class PayStatus { val amountEffective: Amount, ) : PayStatus() + data class Checked( + val details: WalletTemplateDetails, + ) : PayStatus() + data class InsufficientBalance( val contractTerms: ContractTerms, val amountRaw: Amount, @@ -113,14 +119,22 @@ class PaymentManager( } } - fun preparePayForTemplate(url: String, summary: String?, amount: Amount?) = scope.launch { + fun checkPayForTemplate(url: String) = scope.launch { + mPayStatus.value = PayStatus.Loading + api.request("checkPayForTemplate", WalletTemplateDetails.serializer()) { + put("talerPayTemplateUri", url) + }.onError { + handleError("checkPayForTemplate", it) + }.onSuccess { response -> + mPayStatus.value = PayStatus.Checked(response) + } + } + + fun preparePayForTemplate(url: String, params: TemplateParams) = scope.launch { mPayStatus.value = PayStatus.Loading api.request("preparePayForTemplate", PreparePayResponse.serializer()) { put("talerPayTemplateUri", url) - put("templateParams", JSONObject().apply { - summary?.let { put("summary", it) } - amount?.let { put("amount", it.toJSONString()) } - }) + put("templateParams", JSONObject(BackendManager.json.encodeToString(params))) }.onError { handleError("preparePayForTemplate", it) }.onSuccess { response -> diff --git a/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PromptPaymentFragment.kt @@ -98,6 +98,7 @@ class PromptPaymentFragment : Fragment(), ProductImageClickListener { private fun onPaymentStatusChanged(payStatus: PayStatus?) { when (payStatus) { null -> {} + is PayStatus.Checked -> {} // does not apply is PayStatus.Prepared -> { showLoading(false) val fees = payStatus.amountEffective - payStatus.amountRaw