taler-android

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

commit 28cdacb6e6a3a131abb0dc71e188eb62d57dc3bf
parent fa1bb88a316c56d36735883b1a46bdba1f108ee2
Author: Iván Ávalos <avalos@disroot.org>
Date:   Tue,  3 Sep 2024 22:32:17 +0200

[wallet] WIP: x-taler-bank deposit support

Diffstat:
Mwallet/src/main/java/net/taler/wallet/accounts/KnownBankAccounts.kt | 15++++++++++++++-
Mwallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt | 37++++++++++++++++++++++++++-----------
Mwallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mwallet/src/main/java/net/taler/wallet/deposit/DepositState.kt | 16++++++++--------
Mwallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt | 321++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mwallet/src/main/res/values/strings.xml | 25++++++++++++++-----------
6 files changed, 387 insertions(+), 117 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/accounts/KnownBankAccounts.kt b/wallet/src/main/java/net/taler/wallet/accounts/KnownBankAccounts.kt @@ -82,7 +82,20 @@ class PaytoUriTalerBank( ) : PaytoUri( isKnown = true, targetType = "x-taler-bank", -) +) { + val paytoUri: String + get() = Uri.Builder() + .scheme("payto") + .authority(targetType) + .appendPath(host) + .appendPath(account) + .apply { + params.forEach { (key, value) -> + appendQueryParameter(key, value) + } + } + .build().toString() +} @Serializable @SerialName("bitcoin") diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositFragment.kt @@ -20,11 +20,16 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.common.showError import net.taler.wallet.CURRENCY_BTC @@ -52,27 +57,45 @@ class DepositFragment : Fragment() { val spec = scopeInfo?.let { balanceManager.getSpecForScopeInfo(it) } val receiverName = arguments?.getString("receiverName") val iban = arguments?.getString("IBAN") + if (receiverName != null && iban != null) { - onDepositButtonClicked(amount, receiverName, iban) + depositManager.makeIbanDeposit(amount, receiverName, iban) } + return ComposeView(requireContext()).apply { setContent { TalerSurface { val state = depositManager.depositState.collectAsStateLifecycleAware() + val wireTypes = remember { mutableStateListOf<WireType>() } + val coroutine = rememberCoroutineScope() + if (amount.currency == CURRENCY_BTC) MakeBitcoinDepositComposable( state = state.value, amount = amount.withSpec(spec), bitcoinAddress = null, onMakeDeposit = { amount, bitcoinAddress -> - depositManager.onDepositButtonClicked(amount, bitcoinAddress) + depositManager.makeBitcoinDeposit(amount, bitcoinAddress) }, ) else MakeDepositComposable( state = state.value, + supportedWireTypes = wireTypes, amount = amount.withSpec(spec), presetName = receiverName, presetIban = iban, - onMakeDeposit = this@DepositFragment::onDepositButtonClicked, + validateIban = depositManager::validateIban, + onMakeIbanDeposit = depositManager::makeIbanDeposit, + onMakeTalerBankDeposit = depositManager::makeTalerDeposit, ) + + LaunchedEffect(Unit) { + coroutine.launch { + scopeInfo?.let { + depositManager + .getDepositWireTypesForCurrency(it) + ?.let { types -> wireTypes.addAll(types) } + } + } + } } } } @@ -106,12 +129,4 @@ class DepositFragment : Fragment() { depositManager.resetDepositState() } } - - private fun onDepositButtonClicked( - amount: Amount, - receiverName: String, - iban: String, - ) { - depositManager.onDepositButtonClicked(amount, receiverName, iban) - } } diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositManager.kt @@ -23,12 +23,18 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import net.taler.common.Amount import net.taler.wallet.TAG import net.taler.wallet.accounts.PaytoUriBitcoin import net.taler.wallet.accounts.PaytoUriIban +import net.taler.wallet.accounts.PaytoUriTalerBank +import net.taler.wallet.backend.BackendManager import net.taler.wallet.backend.WalletBackendApi +import net.taler.wallet.balances.ScopeInfo +import org.json.JSONObject class DepositManager( private val api: WalletBackendApi, @@ -46,33 +52,7 @@ class DepositManager( } @UiThread - fun onDepositButtonClicked(amount: Amount, receiverName: String, iban: String) { - if (depositState.value is DepositState.FeesChecked) { - // fees already checked, so IBAN was validated, can make deposit directly - makeIbanDeposit(amount, receiverName, iban) - } else { - // validate IBAN first - mDepositState.value = DepositState.CheckingFees - scope.launch { - api.request("validateIban", ValidateIbanResponse.serializer()) { - put("iban", iban) - }.onError { - Log.e(TAG, "Error validateIban $it") - mDepositState.value = DepositState.Error(it) - }.onSuccess { response -> - if (response.valid) { - // only prepare/make deposit, if IBAN is valid - makeIbanDeposit(amount, receiverName, iban) - } else { - mDepositState.value = DepositState.IbanInvalid - } - } - } - } - } - - @UiThread - private fun makeIbanDeposit(amount: Amount, receiverName: String, iban: String) { + fun makeIbanDeposit(amount: Amount, receiverName: String, iban: String) { val paytoUri: String = PaytoUriIban( iban = iban, bic = null, @@ -83,7 +63,18 @@ class DepositManager( } @UiThread - fun onDepositButtonClicked(amount: Amount, bitcoinAddress: String) { + fun makeTalerDeposit(amount: Amount, receiverName: String, host: String, account: String) { + val paytoUri: String = PaytoUriTalerBank( + host = host, + account = account, + targetPath = "", + params = mapOf("receiver-name" to receiverName), + ).paytoUri + makeDeposit(amount, paytoUri) + } + + @UiThread + fun makeBitcoinDeposit(amount: Amount, bitcoinAddress: String) { val paytoUri: String = PaytoUriBitcoin( segwitAddresses = listOf(bitcoinAddress), targetPath = bitcoinAddress, @@ -149,6 +140,32 @@ class DepositManager( fun resetDepositState() { mDepositState.value = DepositState.Start } + + suspend fun validateIban(iban: String): Boolean { + var response = false + api.request("validateIban", ValidateIbanResponse.serializer()) { + put("iban", iban) + }.onError { + Log.d(TAG, "Error validateIban $it") + response = false + }.onSuccess { + response = it.valid + } + return response + } + + suspend fun getDepositWireTypesForCurrency(scopeInfo: ScopeInfo): List<WireType>? { + var result: List<WireType>? = null + api.request("getDepositWireTypesForCurrency", GetDepositWireTypesForCurrencyResponse.serializer()) { + put("currency", scopeInfo.currency) + put("scopeInfo", JSONObject(BackendManager.json.encodeToString(scopeInfo))) + }.onError { + Log.e(TAG, "Error getDepositWireTypesForCurrency $it") + }.onSuccess { + result = it.wireTypes + } + return result + } } @Serializable @@ -167,3 +184,19 @@ data class CreateDepositGroupResponse( val depositGroupId: String, val transactionId: String, ) + +@Serializable +data class GetDepositWireTypesForCurrencyResponse( + val wireTypes: List<WireType>, +) + +@Serializable +enum class WireType { + Unknown, + + @SerialName("iban") + IBAN, + + @SerialName("x-taler-bank") + TalerBank, +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt b/wallet/src/main/java/net/taler/wallet/deposit/DepositState.kt @@ -20,30 +20,30 @@ import net.taler.common.Amount import net.taler.wallet.backend.TalerErrorInfo sealed class DepositState { - open val showFees: Boolean = false open val totalDepositCost: Amount? = null open val effectiveDepositAmount: Amount? = null - object Start : DepositState() - object CheckingFees : DepositState() - object IbanInvalid : DepositState() - class FeesChecked( + data object Start : DepositState() + + data object CheckingFees : DepositState() + + data class FeesChecked( override val totalDepositCost: Amount, override val effectiveDepositAmount: Amount, ) : DepositState() { override val showFees = true } - class MakingDeposit( + data class MakingDeposit( override val totalDepositCost: Amount, override val effectiveDepositAmount: Amount, ) : DepositState() { override val showFees = true } - object Success : DepositState() + data object Success : DepositState() - class Error(val error: TalerErrorInfo) : DepositState() + data class Error(val error: TalerErrorInfo) : DepositState() } diff --git a/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt b/wallet/src/main/java/net/taler/wallet/deposit/MakeDepositComposable.kt @@ -25,13 +25,16 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Surface +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.Companion.CenterHorizontally @@ -44,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import net.taler.common.Amount import net.taler.wallet.R import net.taler.wallet.transactions.AmountType.Negative @@ -53,11 +57,17 @@ import net.taler.wallet.transactions.TransactionAmountComposable @Composable fun MakeDepositComposable( state: DepositState, + supportedWireTypes: List<WireType>, amount: Amount, presetName: String? = null, presetIban: String? = null, - onMakeDeposit: (Amount, String, String) -> Unit, + validateIban: suspend (iban: String) -> Boolean, + onMakeIbanDeposit: (Amount, String, String) -> Unit, + onMakeTalerBankDeposit: (Amount, String, String, String) -> Unit, ) { + // TODO: show some placeholder + if (supportedWireTypes.isEmpty()) return + val scrollState = rememberScrollState() Column( modifier = Modifier @@ -65,63 +75,67 @@ fun MakeDepositComposable( .verticalScroll(scrollState), horizontalAlignment = CenterHorizontally, ) { - var name by rememberSaveable { mutableStateOf(presetName ?: "") } - var iban by rememberSaveable { mutableStateOf(presetIban ?: "") } - val focusRequester = remember { FocusRequester() } - OutlinedTextField( - modifier = Modifier - .padding(16.dp) - .focusRequester(focusRequester) - .fillMaxWidth(), - value = name, - enabled = !state.showFees, - onValueChange = { input -> - name = input - }, - singleLine = true, - isError = name.isBlank(), - label = { - Text( - stringResource(R.string.send_deposit_name), - color = if (name.isBlank()) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, - ) - } - ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() + var selectedWireType by remember { + mutableStateOf(supportedWireTypes.first()) } - val ibanError = state is DepositState.IbanInvalid - OutlinedTextField( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - value = iban, - singleLine = true, - enabled = !state.showFees, - onValueChange = { input -> - iban = input.uppercase() - }, - isError = ibanError, - supportingText = { - if (ibanError) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.send_deposit_iban_error), - color = MaterialTheme.colorScheme.error - ) + + if (supportedWireTypes.size > 1) { + MakeDepositWireTypeChooser( + supportedWireTypes = supportedWireTypes, + selectedWireType = selectedWireType, + onSelectWireType = { + selectedWireType = it } - }, - label = { - Text( - text = stringResource(R.string.send_deposit_iban), - color = if (ibanError) { - MaterialTheme.colorScheme.error - } else Color.Unspecified, + ) + } + + var formError by rememberSaveable { mutableStateOf(false) } + var ibanName by rememberSaveable { mutableStateOf(presetName ?: "") } + var ibanIban by rememberSaveable { mutableStateOf(presetIban ?: "") } + var talerName by rememberSaveable { mutableStateOf(presetName ?: "") } + var talerHost by rememberSaveable { mutableStateOf("") } + var talerAccount by rememberSaveable { mutableStateOf("") } + + when(selectedWireType) { + WireType.IBAN -> { + var ibanError by rememberSaveable { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + MakeDepositIbanForm( + name = ibanName, + iban = ibanIban, + state = state, + ibanError = ibanError, + onFormEdited = { name, iban -> + ibanName = name + ibanIban = iban + coroutineScope.launch { + val valid = validateIban(iban) + formError = !valid || name.isBlank() + ibanError = !valid + } + } ) } - ) + + WireType.TalerBank -> MakeDepositTalerBankForm( + name = talerName, + host = talerHost, + account = talerAccount, + state = state, + onFormEdited = { name, host, account -> + talerName = name + talerHost = host + talerAccount = account + formError = name.isBlank() + || host.isBlank() + || account.isBlank() + } + ) + + else -> {} + } + TransactionAmountComposable( label = stringResource(R.string.amount_chosen), amount = amount, @@ -151,6 +165,7 @@ fun MakeDepositComposable( ) } } + AnimatedVisibility(visible = state is DepositState.Error) { Text( modifier = Modifier.padding(16.dp), @@ -159,13 +174,18 @@ fun MakeDepositComposable( text = (state as? DepositState.Error)?.error?.userFacingMsg ?: "", ) } + val focusManager = LocalFocusManager.current Button( modifier = Modifier.padding(16.dp), - enabled = iban.isNotBlank(), + enabled = !formError, onClick = { focusManager.clearFocus() - onMakeDeposit(amount, name, iban) + when (selectedWireType) { + WireType.IBAN -> onMakeIbanDeposit(amount, ibanName, ibanIban) + WireType.TalerBank -> onMakeTalerBankDeposit(amount, talerName, talerHost, talerAccount) + else -> {} + } }, ) { Text( @@ -178,6 +198,187 @@ fun MakeDepositComposable( } } +@Composable +fun MakeDepositWireTypeChooser( + modifier: Modifier = Modifier, + supportedWireTypes: List<WireType>, + selectedWireType: WireType, + onSelectWireType: (wireType: WireType) -> Unit, +) { + val selectedIndex = supportedWireTypes.indexOfFirst { + it == selectedWireType + } + + ScrollableTabRow( + selectedTabIndex = selectedIndex, + modifier = modifier, + edgePadding = 8.dp, + ) { + supportedWireTypes.forEach { wireType -> + if (wireType != WireType.Unknown) { + Tab( + selected = selectedWireType == wireType, + onClick = { onSelectWireType(wireType) }, + text = { + Text(when(wireType) { + WireType.IBAN -> stringResource(R.string.send_deposit_iban) + WireType.TalerBank -> stringResource(R.string.send_deposit_taler) + else -> error("unknown method") + }) + } + ) + } + } + } +} + +@Composable +fun MakeDepositIbanForm( + state: DepositState, + name: String, + iban: String, + ibanError: Boolean, + onFormEdited: (name: String, iban: String) -> Unit +) { + val focusRequester = remember { FocusRequester() } + + OutlinedTextField( + modifier = Modifier + .padding(16.dp) + .focusRequester(focusRequester) + .fillMaxWidth(), + value = name, + enabled = !state.showFees, + onValueChange = { input -> + onFormEdited(input, iban) + }, + singleLine = true, + isError = name.isBlank(), + label = { + Text( + stringResource(R.string.send_deposit_name), + color = if (name.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + value = iban, + singleLine = true, + enabled = !state.showFees, + onValueChange = { input -> + onFormEdited(name, input.uppercase()) + + }, + isError = ibanError, + supportingText = { + if (ibanError) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.send_deposit_iban_error), + color = MaterialTheme.colorScheme.error + ) + } + }, + label = { + Text( + text = stringResource(R.string.send_deposit_iban), + color = if (ibanError) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + } + ) +} + +@Composable +fun MakeDepositTalerBankForm( + state: DepositState, + name: String, + host: String, + account: String, + onFormEdited: (name: String, host: String, account: String) -> Unit +) { + val focusRequester = remember { FocusRequester() } + + OutlinedTextField( + modifier = Modifier + .padding(16.dp) + .focusRequester(focusRequester) + .fillMaxWidth(), + value = name, + enabled = !state.showFees, + onValueChange = { input -> + onFormEdited(input, host, account) + }, + singleLine = true, + isError = name.isBlank(), + label = { + Text( + stringResource(R.string.send_deposit_name), + color = if (name.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + } + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + value = host, + enabled = !state.showFees, + onValueChange = { input -> + onFormEdited(name, input, account) + }, + singleLine = true, + isError = host.isBlank(), + label = { + Text( + stringResource(R.string.send_deposit_host), + color = if (host.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + } + ) + + OutlinedTextField( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + value = account, + singleLine = true, + enabled = !state.showFees, + onValueChange = { input -> + onFormEdited(name, host, input) + }, + isError = account.isBlank(), + label = { + Text( + text = stringResource(R.string.send_deposit_account), + color = if (account.isBlank()) { + MaterialTheme.colorScheme.error + } else Color.Unspecified, + ) + } + ) +} + @Preview @Composable fun PreviewMakeDepositComposable() { @@ -188,7 +389,11 @@ fun PreviewMakeDepositComposable() { ) MakeDepositComposable( state = state, - amount = Amount.fromString("TESTKUDOS", "42.23")) { _, _, _ -> - } + supportedWireTypes = listOf(WireType.TalerBank, WireType.IBAN), + amount = Amount.fromString("TESTKUDOS", "42.23"), + validateIban = { true }, + onMakeIbanDeposit = { _, _, _ -> }, + onMakeTalerBankDeposit = { _, _, _, _ -> }, + ) } } diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -208,32 +208,35 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card <!-- P2P send --> - <string name="pay_peer_title">Pay invoice</string> <string name="pay_peer_intro">Do you want to pay this invoice?</string> - <string name="send_intro">Choose where to send money to:</string> + <string name="pay_peer_title">Pay invoice</string> <string name="send_deposit">To a bank account</string> + <string name="send_deposit_account">Account</string> <string name="send_deposit_bitcoin">To a Bitcoin wallet</string> - <string name="send_deposit_title">Deposit to a bank account</string> - <string name="send_deposit_iban">IBAN</string> - <string name="send_deposit_iban_error">IBAN is invalid</string> - <string name="send_deposit_name">Account holder</string> <string name="send_deposit_bitcoin_address">Bitcoin address</string> + <string name="send_deposit_bitcoin_create_button">Transfer Bitcoin</string> <string name="send_deposit_check_fees_button">Check fees</string> <string name="send_deposit_create_button">Make deposit</string> - <string name="send_deposit_bitcoin_create_button">Transfer Bitcoin</string> + <string name="send_deposit_host">Host</string> + <string name="send_deposit_iban">IBAN</string> + <string name="send_deposit_iban_error">IBAN is invalid</string> + <string name="send_deposit_name">Account holder</string> + <string name="send_deposit_taler">x-taler-bank</string> + <string name="send_deposit_title">Deposit to a bank account</string> + <string name="send_intro">Choose where to send money to:</string> <string name="send_peer">To another wallet</string> <string name="send_peer_bitcoin">To another Taler wallet</string> - <string name="send_peer_title">Send money to another wallet</string> <string name="send_peer_create_button">Send funds now</string> - <string name="send_peer_payment_instruction">Let the payee scan this QR code to receive:</string> - <string name="send_peer_expiration_period">Expires in</string> <string name="send_peer_expiration_1d">1 day</string> - <string name="send_peer_expiration_7d">1 week</string> <string name="send_peer_expiration_30d">30 days</string> + <string name="send_peer_expiration_7d">1 week</string> <string name="send_peer_expiration_custom">Custom</string> <string name="send_peer_expiration_days">Days</string> <string name="send_peer_expiration_hours">Hours</string> + <string name="send_peer_expiration_period">Expires in</string> + <string name="send_peer_payment_instruction">Let the payee scan this QR code to receive:</string> <string name="send_peer_purpose">Purpose</string> + <string name="send_peer_title">Send money to another wallet</string> <!-- Withdrawals -->