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:
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) {