taler-android

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

commit 339354c5ff17729bc4ff88aecc104f6e66552615
parent 56268671281dae81ebcb5b3a63dd979e732e26d8
Author: Iván Ávalos <avalos@disroot.org>
Date:   Mon, 22 Jul 2024 12:06:00 -0600

[wallet] Add dynamic p2p fee calculation and handling of empty / insufficient balance

bug 0009002

Diffstat:
Mwallet/src/main/java/net/taler/wallet/MainViewModel.kt | 25+++++--------------------
Mwallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt | 26+++++++++++++-------------
Mwallet/src/main/java/net/taler/wallet/SendFundsFragment.kt | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mwallet/src/main/java/net/taler/wallet/Utils.kt | 29+++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt | 16++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt | 3++-
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt | 2+-
Mwallet/src/main/java/net/taler/wallet/peer/PeerManager.kt | 33+++++++++++++++++++++++++++++++++
8 files changed, 179 insertions(+), 59 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/MainViewModel.kt @@ -176,29 +176,14 @@ class MainViewModel( } @UiThread - fun getCurrencies() = balanceManager.balances.value?.map { balanceItem -> - balanceItem.currency - } ?: emptyList() - - @UiThread fun createAmount(amountText: String, currency: String, incoming: Boolean = false): AmountResult { val amount = try { Amount.fromString(currency, amountText) } catch (e: AmountParserException) { return AmountResult.InvalidAmount } - if (incoming || hasSufficientBalance(amount)) return AmountResult.Success(amount) - return AmountResult.InsufficientBalance - } - - @UiThread - fun hasSufficientBalance(amount: Amount): Boolean { - balanceManager.balances.value?.forEach { balanceItem -> - if (balanceItem.currency == amount.currency) { - return balanceItem.available >= amount - } - } - return false + if (incoming || balanceManager.hasSufficientBalance(amount)) return AmountResult.Success(amount) + return AmountResult.InsufficientBalance(amount) } @UiThread @@ -299,7 +284,7 @@ enum class ScanQrContext { } sealed class AmountResult { - class Success(val amount: Amount) : AmountResult() - object InsufficientBalance : AmountResult() - object InvalidAmount : AmountResult() + data class Success(val amount: Amount) : AmountResult() + data class InsufficientBalance(val amount: Amount) : AmountResult() + data object InvalidAmount : AmountResult() } diff --git a/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/ReceiveFundsFragment.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -144,9 +145,10 @@ private fun ReceiveFundsIntro( modifier = Modifier .fillMaxWidth() .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, ) { var text by rememberSaveable { mutableStateOf("0") } - var isError by rememberSaveable { mutableStateOf(false) } + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -158,14 +160,9 @@ private fun ReceiveFundsIntro( .padding(end = 16.dp), value = text, onValueChange = { input -> - isError = false text = input }, label = { Text(stringResource(R.string.amount_receive)) }, - supportingText = { - if (isError) Text(stringResource(R.string.amount_invalid)) - }, - isError = isError, numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, ) Text( @@ -181,13 +178,17 @@ private fun ReceiveFundsIntro( style = MaterialTheme.typography.titleLarge, ) Column(modifier = Modifier.padding(16.dp)) { + val amount: Amount? = remember(currency, text) { + getAmount(currency, text) + } + Button( modifier = Modifier.fillMaxWidth(), + enabled = amount?.isZero() == false, onClick = { - val amount = getAmount(currency, text) - if (amount == null || amount.isZero()) isError = true - else onManualWithdraw(amount) - }) { + amount?.let { onManualWithdraw(it) } + }, + ) { Icon( Icons.Default.AccountBalance, contentDescription = null, @@ -199,10 +200,9 @@ private fun ReceiveFundsIntro( Button( modifier = Modifier.fillMaxWidth(), + enabled = amount?.isZero() == false, onClick = { - val amount = getAmount(currency, text) - if (amount == null || amount.isZero()) isError = true - else onPeerPull(amount) + amount?.let { onPeerPull(it) } }, ) { Icon( diff --git a/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt b/wallet/src/main/java/net/taler/wallet/SendFundsFragment.kt @@ -42,6 +42,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -55,11 +57,13 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.wallet.compose.AmountInputField import net.taler.wallet.compose.DEFAULT_INPUT_DECIMALS import net.taler.wallet.compose.TalerSurface +import net.taler.wallet.peer.CheckFeeResult class SendFundsFragment : Fragment() { private val model: MainViewModel by activityViewModels() @@ -77,7 +81,7 @@ class SendFundsFragment : Fragment() { SendFundsIntro( currency = scopeInfo.currency, spec = balanceManager.getSpecForScopeInfo(scopeInfo), - hasSufficientBalance = model::hasSufficientBalance, + checkFees = this@SendFundsFragment::checkFees, onDeposit = this@SendFundsFragment::onDeposit, onPeerPush = this@SendFundsFragment::onPeerPush, onScanQr = this@SendFundsFragment::onScanQr, @@ -91,6 +95,10 @@ class SendFundsFragment : Fragment() { activity?.setTitle(getString(R.string.transactions_send_funds_title, scopeInfo.currency)) } + private suspend fun checkFees(amount: Amount): CheckFeeResult { + return peerManager.checkPeerPushFees(amount) + } + private fun onDeposit(amount: Amount) { val bundle = bundleOf("amount" to amount.toJSONString()) findNavController().navigate(R.id.action_sendFunds_to_nav_deposit, bundle) @@ -111,24 +119,48 @@ class SendFundsFragment : Fragment() { private fun SendFundsIntro( currency: String, spec: CurrencySpecification?, - hasSufficientBalance: (Amount) -> Boolean, + checkFees: suspend (amount: Amount) -> CheckFeeResult, onDeposit: (Amount) -> Unit, onPeerPush: (Amount) -> Unit, onScanQr: () -> Unit, ) { val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() Column( modifier = Modifier .fillMaxWidth() .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, ) { var text by rememberSaveable { mutableStateOf("0") } - var isError by rememberSaveable { mutableStateOf(false) } - var insufficientBalance by rememberSaveable { mutableStateOf(false) } + + var fees by remember { mutableStateOf<CheckFeeResult>(CheckFeeResult.None) } + + val insufficientBalance: Boolean = remember(fees) { + fees is CheckFeeResult.InsufficientBalance + } + + val calculateFees = { input: String -> + fees = CheckFeeResult.None + getAmount(currency, input)?.let { amount -> + coroutineScope.launch { + checkFees(amount).let { + fees = it + } + } + } + } + + text.useDebounce( + delayMillis = 150L, + coroutineScope = coroutineScope, + ) { + calculateFees(it) + } + Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(16.dp), + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 8.dp), ) { AmountInputField( modifier = Modifier @@ -136,20 +168,18 @@ private fun SendFundsIntro( .padding(end = 16.dp), value = text, onValueChange = { input -> - isError = false - insufficientBalance = false text = input }, label = { Text(stringResource(R.string.amount_send)) }, supportingText = { - if (isError) Text(stringResource(R.string.amount_invalid)) - else if (insufficientBalance) { + if (insufficientBalance) { Text(stringResource(R.string.payment_balance_insufficient)) } }, - isError = isError || insufficientBalance, + isError = insufficientBalance, numberOfDecimals = spec?.numFractionalInputDigits ?: DEFAULT_INPUT_DECIMALS, ) + Text( modifier = Modifier, text = spec?.symbol ?: currency, @@ -157,24 +187,39 @@ private fun SendFundsIntro( style = MaterialTheme.typography.titleLarge, ) } + + // Render fees dynamically + if (fees is CheckFeeResult.Success) { + val success = fees as CheckFeeResult.Success + if (success.amountEffective > success.amountRaw) { + val fee = success.amountEffective - success.amountRaw + if (!fee.isZero()) { + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = stringResource(id = R.string.payment_fee, fee.withSpec(spec)), + softWrap = false, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + Text( - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), text = stringResource(R.string.send_intro), style = MaterialTheme.typography.titleLarge, ) + Column(modifier = Modifier.padding(16.dp)) { - fun onClickButton(block: (Amount) -> Unit) { - val amount = getAmount(currency, text) - if (amount == null || amount.isZero()) isError = true - else if (!hasSufficientBalance(amount)) insufficientBalance = true - else block(amount) + val amount: Amount? = remember(currency, text) { + getAmount(currency, text) } Button( modifier = Modifier.fillMaxWidth(), - onClick = { - onClickButton { amount -> onDeposit(amount) } - }) { + enabled = !insufficientBalance && amount?.isZero() == false, + onClick = { amount?.let { onDeposit(it) } }, + ) { Icon( if (currency == CURRENCY_BTC) { Icons.Default.CurrencyBitcoin @@ -194,9 +239,8 @@ private fun SendFundsIntro( Button( modifier = Modifier.fillMaxWidth(), - onClick = { - onClickButton { amount -> onPeerPush(amount) } - }, + enabled = !insufficientBalance && amount?.isZero() == false, + onClick = { amount?.let { onPeerPush(it) } }, ) { Icon( Icons.Default.AccountBalanceWallet, @@ -240,6 +284,18 @@ private fun SendFundsIntro( @Composable fun PreviewSendFundsIntro() { Surface { - SendFundsIntro("TESTKUDOS", null, { true }, {}, {}) {} + SendFundsIntro( + currency = "TESTKUDOS", + spec = null, + checkFees = { + CheckFeeResult.Success( + amountRaw = Amount.fromJSONString("TESTKUDOS:10"), + amountEffective = Amount.fromJSONString("TESTKUDOS:10.2"), + ) + }, + onDeposit = {}, + onScanQr = {}, + onPeerPush = {}, + ) } } diff --git a/wallet/src/main/java/net/taler/wallet/Utils.kt b/wallet/src/main/java/net/taler/wallet/Utils.kt @@ -31,9 +31,17 @@ import android.widget.Toast import android.widget.Toast.LENGTH_LONG import androidx.annotation.RequiresApi import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.core.content.getSystemService import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import net.taler.common.Amount @@ -144,4 +152,25 @@ fun Context.getThemeColor(attr: Int): Int { val typedValue = TypedValue() theme.resolveAttribute(attr, typedValue, true) return typedValue.data +} + +@Composable +fun <T> T.useDebounce( + delayMillis: Long = 300L, + coroutineScope: CoroutineScope = rememberCoroutineScope(), + onChange: (T) -> Unit +): T{ + val state by rememberUpdatedState(this) + + DisposableEffect(state){ + val job = coroutineScope.launch { + delay(delayMillis) + onChange(state) + } + onDispose { + job.cancel() + } + } + + return state } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt b/wallet/src/main/java/net/taler/wallet/balances/BalanceManager.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import net.taler.common.Amount import net.taler.common.CurrencySpecification import net.taler.wallet.TAG import net.taler.wallet.backend.TalerErrorInfo @@ -131,6 +132,21 @@ class BalanceManager( return state.balances.find { it.scopeInfo == scopeInfo }?.available?.spec } + @UiThread + fun getCurrencies() = balances.value?.map { balanceItem -> + balanceItem.currency + } ?: emptyList() + + @UiThread + fun hasSufficientBalance(amount: Amount): Boolean { + balances.value?.forEach { balanceItem -> + if (balanceItem.currency == amount.currency) { + return balanceItem.available >= amount + } + } + return false + } + fun resetBalances() { mState.value = BalanceState.None } diff --git a/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/PayToUriFragment.kt @@ -71,6 +71,7 @@ import net.taler.wallet.compose.TalerSurface class PayToUriFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val depositManager get() = model.depositManager + private val balanceManager get() = model.balanceManager override fun onCreateView( inflater: LayoutInflater, @@ -83,7 +84,7 @@ class PayToUriFragment : Fragment() { ?.replace('+', ' ') ?: "" val iban = u.pathSegments.last() ?: "" - val currencies = model.getCurrencies() + val currencies = balanceManager.getCurrencies() return ComposeView(requireContext()).apply { setContent { TalerSurface { diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateFragment.kt @@ -38,7 +38,7 @@ class PayTemplateFragment : Fragment() { private val model: MainViewModel by activityViewModels() private lateinit var uriString: String private lateinit var uri: Uri - private val currencies by lazy { model.getCurrencies() } + private val currencies by lazy { model.balanceManager.getCurrencies() } override fun onCreateView( inflater: LayoutInflater, diff --git a/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt b/wallet/src/main/java/net/taler/wallet/peer/PeerManager.kt @@ -28,6 +28,7 @@ import net.taler.common.Amount import net.taler.common.Timestamp import net.taler.wallet.TAG import net.taler.wallet.backend.TalerErrorCode.UNKNOWN +import net.taler.wallet.backend.TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi import net.taler.wallet.exchanges.ExchangeItem @@ -38,6 +39,17 @@ import java.util.concurrent.TimeUnit.HOURS const val MAX_LENGTH_SUBJECT = 100 val DEFAULT_EXPIRY = ExpirationOption.DAYS_1 +sealed class CheckFeeResult { + data object None: CheckFeeResult() + + data object InsufficientBalance: CheckFeeResult() + + data class Success( + val amountRaw: Amount, + val amountEffective: Amount, + ): CheckFeeResult() +} + class PeerManager( private val api: WalletBackendApi, private val exchangeManager: ExchangeManager, @@ -124,6 +136,27 @@ class PeerManager( } } + suspend fun checkPeerPushFees(amount: Amount, exchangeBaseUrl: String? = null): CheckFeeResult { + var response: CheckFeeResult = CheckFeeResult.None + + api.request("checkPeerPushDebit", CheckPeerPushDebitResponse.serializer()) { + exchangeBaseUrl?.let { put("exchangeBaseUrl", it) } + put("amount", amount.toJSONString()) + }.onSuccess { + response = CheckFeeResult.Success( + amountRaw = it.amountRaw, + amountEffective = it.amountEffective, + ) + }.onError { error -> + Log.e(TAG, "got checkPeerPushDebit error result $error") + if (error.code == WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE) { + response = CheckFeeResult.InsufficientBalance + } + } + + return response + } + fun initiatePeerPushDebit(amount: Amount, summary: String, expirationHours: Long) { _outgoingPushState.value = OutgoingCreating scope.launch(Dispatchers.IO) {