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