taler-android

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

commit a90ee6f13100901c25e10de0d577cbeed19e3b49
parent b626d88fa3adb0bf21049b6ba4e9cf9993df7198
Author: Iván Ávalos <avalos@disroot.org>
Date:   Fri, 13 Feb 2026 00:25:57 +0100

[wallet] add cyclos support + refactor payto:// parsing logic

Diffstat:
Mwallet/src/main/java/net/taler/wallet/accounts/Accounts.kt | 44++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/accounts/AccountsFragment.kt | 21+++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/transactions/Transactions.kt | 72+++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mwallet/src/main/java/net/taler/wallet/transfer/ScreenTransfer.kt | 19+++++++++++++++++++
Awallet/src/main/java/net/taler/wallet/transfer/TransferCyclos.kt | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt | 65++++++++++++++++-------------------------------------------------
Mwallet/src/main/res/values/strings.xml | 2++
7 files changed, 253 insertions(+), 72 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/accounts/Accounts.kt b/wallet/src/main/java/net/taler/wallet/accounts/Accounts.kt @@ -87,6 +87,7 @@ sealed class PaytoUri( return when (uri.authority?.lowercase()) { "iban" -> PaytoUriIban.fromString(uri) "x-taler-bank" -> PaytoUriTalerBank.fromString(uri) + "cyclos" -> PaytoUriCyclos.fromString(uri) "bitcoin" -> PaytoUriBitcoin.fromString(uri) else -> null } @@ -184,6 +185,49 @@ data class PaytoUriTalerBank( } @Serializable +@SerialName("cyclos") +data class PaytoUriCyclos( + val host: String, + val account: String, + override val targetPath: String, + override val params: Map<String, String>, + override val receiverName: String, +) : PaytoUri( + isKnown = true, + targetType = "cyclos", +) { + val paytoUri: String + get() = Uri.Builder() + .scheme("payto") + .authority(targetType) + .appendPath(host) + .appendPath(account) + .apply { + appendQueryParameter("receiver-name", receiverName) + params.forEach { (key, value) -> + if (value.isNotEmpty()) { + appendQueryParameter(key, value) + } + } + } + .build().toString() + + companion object { + fun fromString(uri: Uri): PaytoUriCyclos? { + return PaytoUriCyclos( + account = uri.lastPathSegment ?: return null, + receiverName = uri.getQueryParameter("receiver-name") ?: return null, + host = uri.pathSegments + .subList(0, uri.pathSegments.lastIndex) + .joinToString("/"), + params = uri.queryParametersMap, + targetPath = "", + ) + } + } +} + +@Serializable @SerialName("bitcoin") data class PaytoUriBitcoin( @SerialName("segwitAddrs") diff --git a/wallet/src/main/java/net/taler/wallet/accounts/AccountsFragment.kt b/wallet/src/main/java/net/taler/wallet/accounts/AccountsFragment.kt @@ -253,11 +253,16 @@ fun BankAccountRow( color = MaterialTheme.colorScheme.onSecondaryContainer, ) ) + is PaytoUriBitcoin -> Icon( Icons.Default.CurrencyBitcoin, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer, ) + + is PaytoUriCyclos -> Text("Cy", + style = MaterialTheme.typography.labelLarge) + else -> Icon( Icons.Default.AccountBalance, contentDescription = null, @@ -270,6 +275,7 @@ fun BankAccountRow( when(paytoUri) { is PaytoUriIban -> Text(stringResource(R.string.send_deposit_iban)) is PaytoUriTalerBank -> Text(stringResource(R.string.send_deposit_taler)) + is PaytoUriCyclos -> Text(stringResource(R.string.send_deposit_cyclos)) is PaytoUriBitcoin -> Text(stringResource(R.string.send_deposit_bitcoin)) else -> {} } @@ -282,6 +288,7 @@ fun BankAccountRow( when(paytoUri) { is PaytoUriIban -> Text(paytoUri.iban) is PaytoUriTalerBank -> Text(paytoUri.account) + is PaytoUriCyclos -> Text(paytoUri.receiverName) is PaytoUriBitcoin -> { Text(remember(paytoUri.segwitAddresses) { paytoUri.segwitAddresses.joinToString(" ") @@ -334,6 +341,20 @@ val previewKnownAccounts = listOf( KnownBankAccountInfo( bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", + paytoUri = PaytoUriCyclos( + host = "demo.cyclos.org", + account = "john123", + targetPath = "", + params = emptyMap(), + receiverName = "John Doe", + ).paytoUri, + kycCompleted = true, + currencies = listOf("UI"), + label = "Cyclos demo", + ), + + KnownBankAccountInfo( + bankAccountId = "acct:EHHRQMZNDNAW3KZMBW0ATTNHCT3WH3TNX3HNMS4MKGK10E1W0YNG", paytoUri = PaytoUriBitcoin( segwitAddresses = listOf("bc1qkrnmwd8t4yxzpha8gk3w8h8lyecfp2ra9yvgf9"), targetPath = "", diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt @@ -47,6 +47,11 @@ import net.taler.wallet.backend.TalerErrorInfo import net.taler.common.CurrencySpecification import net.taler.common.Merchant import net.taler.common.RelativeTime +import net.taler.wallet.accounts.PaytoUri +import net.taler.wallet.accounts.PaytoUriBitcoin +import net.taler.wallet.accounts.PaytoUriCyclos +import net.taler.wallet.accounts.PaytoUriIban +import net.taler.wallet.accounts.PaytoUriTalerBank import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.refund.RefundPaymentInfo import net.taler.wallet.transactions.TransactionMajorState.Done @@ -282,13 +287,16 @@ data class WithdrawalExchangeAccountDetails ( ?.let { Amount.fromJSONString(it) } ?: amountEffective).withSpec(currencySpecification) return if ("bitcoin".equals(uri.authority, true)) { + // FIXME: use parsing logic from PaytoUriBitcoin.fromString() val msg = uri.getQueryParameter("message").orEmpty() val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg) - val reserve = reg?.value ?: uri.getQueryParameter("subject")!! + val reserve = reg?.value + ?: uri.getQueryParameter("subject") + ?: return null val segwitAddresses = Bech32.generateFakeSegwitAddress(reserve, uri.pathSegments.first()) TransferData.Bitcoin( - account = uri.lastPathSegment!!, + account = uri.lastPathSegment ?: return null, segwitAddresses = segwitAddresses, subject = reserve, amountRaw = amountRaw, @@ -297,28 +305,46 @@ data class WithdrawalExchangeAccountDetails ( withdrawalAccount = copy(paytoUri = uri.toString()), ) } else if (uri.authority.equals("x-taler-bank", true)) { - TransferData.Taler( - account = uri.lastPathSegment!!, - receiverName = uri.getQueryParameter("receiver-name"), - subject = uri.getQueryParameter("message") ?: "Error: No message in URI", - amountRaw = amountRaw, - amountEffective = amountEffective, - exchangeBaseUrl = uri.pathSegments[0] ?: return null, - transferAmount = transferAmount, - withdrawalAccount = copy(paytoUri = uri.toString()), - ) + PaytoUriTalerBank.fromString(uri)?.let { data -> + TransferData.Taler( + account = data.account, + receiverName = data.receiverName, + subject = uri.getQueryParameter("message") ?: return@let null, + amountRaw = amountRaw, + amountEffective = amountEffective, + exchangeBaseUrl = data.host, + transferAmount = transferAmount, + withdrawalAccount = copy(paytoUri = uri.toString()) + ) + } } else if (uri.authority.equals("iban", true)) { - TransferData.IBAN( - iban = uri.lastPathSegment!!, - receiverName = uri.getQueryParameter("receiver-name"), - receiverTown = uri.getQueryParameter("receiver-town"), - receiverPostalCode = uri.getQueryParameter("receiver-postal-code"), - subject = uri.getQueryParameter("message") ?: "Error: No message in URI", - amountRaw = amountRaw, - amountEffective = amountEffective, - transferAmount = transferAmount, - withdrawalAccount = copy(paytoUri = uri.toString()), - ) + PaytoUriIban.fromString(uri)?.let { data -> + TransferData.IBAN( + iban = data.iban, + receiverName = data.receiverName, + receiverTown = data.receiverTown, + receiverPostalCode = data.receiverPostalCode, + subject = uri.getQueryParameter("message") ?: return@let null, + amountRaw = amountRaw, + amountEffective = amountEffective, + transferAmount = transferAmount, + withdrawalAccount = copy(paytoUri = uri.toString()), + ) + } + } else if (uri.authority.equals("cyclos", true)) { + PaytoUriCyclos.fromString(uri)?.let { data -> + TransferData.Cyclos( + account = data.account, + receiverName = data.receiverName, + host = data.host, + subject = uri.getQueryParameter("message") ?: return@let null, + amountRaw = amountRaw, + amountEffective = amountEffective, + transferAmount = transferAmount + .withSpec(currencySpecification), + withdrawalAccount = copy(paytoUri = uri.toString()), + ) + } } else null } } diff --git a/wallet/src/main/java/net/taler/wallet/transfer/ScreenTransfer.kt b/wallet/src/main/java/net/taler/wallet/transfer/ScreenTransfer.kt @@ -175,6 +175,11 @@ fun ScreenTransfer( transferContext = transferContext, ) + is TransferData.Cyclos -> TransferCyclos( + transfer = transfer, + transactionAmountEffective = transfer.amountEffective.withSpec(spec), + ) + is TransferData.Bitcoin -> TransferBitcoin( transfer = transfer, ) @@ -376,6 +381,20 @@ fun ScreenTransferPreview( ), ), ), + TransferData.Cyclos( + host = "demo.cyclos.org/abc", + account = "1234567890", + receiverName = "Taler Wire", + subject = "Taler Withdrawal P2T19EXRBY4B145JRNZ8CQTD7TCS03JE9VZRCEVKVWCP930P56WG", + amountRaw = Amount("IU", 10, 0), + amountEffective = Amount("IU", 9, 5), + transferAmount = Amount("IU", 10, 0), + withdrawalAccount = WithdrawalExchangeAccountDetails( + paytoUri = "https://taler.net/cyclos", + transferAmount = Amount("IU", 10, 0), + status = Ok, + ), + ), TransferData.Bitcoin( account = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", segwitAddresses = listOf( diff --git a/wallet/src/main/java/net/taler/wallet/transfer/TransferCyclos.kt b/wallet/src/main/java/net/taler/wallet/transfer/TransferCyclos.kt @@ -0,0 +1,101 @@ +/* + * This file is part of GNU Taler + * (C) 2026 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.transfer + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import net.taler.common.Amount +import net.taler.wallet.R +import net.taler.wallet.compose.WarningLabel +import net.taler.wallet.withdraw.TransferData + +@Composable +fun TransferCyclos( + transfer: TransferData.Cyclos, + transactionAmountEffective: Amount, + // TODO: transferContext? +) { + Column( + modifier = Modifier.padding(all = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource( + R.string.withdraw_manual_ready_intro, + transfer.transferAmount, + transactionAmountEffective, + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .padding(vertical = 8.dp) + ) + + WarningLabel( + modifier = Modifier.padding( + horizontal = 8.dp, + vertical = 16.dp, + ), + label = stringResource(R.string.send_deposit_kyc_auth_warning_account), + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 6.dp) + ) + + TransferStep(1, stringResource(R.string.withdraw_manual_step_subject)) + + DetailRow( + stringResource(R.string.withdraw_manual_ready_subject), + transfer.subject, + characterBreak = true, + ) + + WarningLabel( + modifier = Modifier.padding( + horizontal = 8.dp, + vertical = 16.dp, + ), + label = stringResource(R.string.withdraw_manual_ready_warning), + ) + + TransferStep(2, stringResource(R.string.withdraw_manual_step_cyclos)) + + DetailRow( + stringResource(R.string.withdraw_manual_ready_receiver), + transfer.receiverName, + ) + + WithdrawalAmountTransfer( + conversionAmountRaw = transfer.transferAmount, + ) + + TransferStep(3, + stringResource( + R.string.withdraw_manual_step_finish, + transfer.transferAmount, + ), + ) + } +} +\ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import net.taler.common.Amount -import net.taler.common.Bech32 import net.taler.wallet.main.TAG import net.taler.wallet.backend.TalerErrorInfo import net.taler.wallet.backend.WalletBackendApi @@ -39,7 +38,6 @@ import net.taler.wallet.exchanges.ExchangeItem import net.taler.wallet.exchanges.ExchangeManager import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails import net.taler.wallet.withdraw.WithdrawStatus.Status.* -import androidx.core.net.toUri import kotlinx.coroutines.runBlocking import net.taler.common.CurrencySpecification import net.taler.wallet.R @@ -125,6 +123,17 @@ sealed class TransferData { val iban: String, ): TransferData() + data class Cyclos( + override val subject: String, + override val amountRaw: Amount, + override val amountEffective: Amount, + override val transferAmount: Amount, + override val withdrawalAccount: WithdrawalExchangeAccountDetails, + val host: String, // host + fpath + val account: String, // FIXME: account ID not used at all? + val receiverName: String, // FIXME: actual ID used for payments? + ): TransferData() + data class Bitcoin( override val subject: String, override val amountRaw: Amount, @@ -568,53 +577,11 @@ class WithdrawManager( manualTransferResponse = response, transactionId = response.transactionId, withdrawalTransfers = response.withdrawalAccountsList.mapNotNull { - val details = status.amountInfo ?: error("no amountInfo") - val uri = it.paytoUri.toUri() - if ("bitcoin".equals(uri.authority, true)) { - val msg = uri.getQueryParameter("message").orEmpty() - val reg = "\\b([A-Z0-9]{52})\\b".toRegex().find(msg) - val reserve = reg?.value ?: uri.getQueryParameter("subject")!! - val segwitAddresses = - Bech32.generateFakeSegwitAddress(reserve, uri.pathSegments.first()) - TransferData.Bitcoin( - account = uri.lastPathSegment!!, - segwitAddresses = segwitAddresses, - subject = reserve, - amountRaw = details.amountRaw, - amountEffective = details.amountEffective, - transferAmount = it.transferAmount - ?.withSpec(it.currencySpecification) - ?: details.amountEffective, - withdrawalAccount = it.copy(paytoUri = uri.toString()), - ) - } else if (uri.authority.equals("x-taler-bank", true)) { - TransferData.Taler( - account = uri.lastPathSegment!!, - receiverName = uri.getQueryParameter("receiver-name"), - subject = uri.getQueryParameter("message") ?: "Error: No message in URI", - amountRaw = details.amountRaw, - amountEffective = details.amountEffective, - exchangeBaseUrl = uri.host!!, - transferAmount = it.transferAmount - ?.withSpec(it.currencySpecification) - ?: details.amountEffective, - withdrawalAccount = it.copy(paytoUri = uri.toString()), - ) - } else if (uri.authority.equals("iban", true)) { - TransferData.IBAN( - iban = uri.lastPathSegment!!, - receiverName = uri.getQueryParameter("receiver-name"), - receiverTown = uri.getQueryParameter("receiver-town"), - receiverPostalCode = uri.getQueryParameter("receiver-postal-code"), - subject = uri.getQueryParameter("message") ?: "Error: No message in URI", - amountRaw = details.amountRaw, - amountEffective = details.amountEffective, - transferAmount = it.transferAmount - ?.withSpec(it.currencySpecification) - ?: details.amountEffective, - withdrawalAccount = it.copy(paytoUri = uri.toString()), - ) - } else null + val details = status.amountInfo ?: return@mapNotNull null + it.getTransferDetails( + amountRaw = details.amountRaw, + amountEffective = details.amountEffective, + ) }, ) } \ No newline at end of file diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -307,6 +307,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="send_deposit_button_label">Deposit</string> <string name="send_deposit_check_fees_button">Check fees</string> <string name="send_deposit_create_button">Make deposit</string> + <string name="send_deposit_cyclos">Cyclos</string> <string name="send_deposit_host">Local currency bank</string> <string name="send_deposit_iban">IBAN</string> <string name="send_deposit_iban_error">IBAN is invalid</string> @@ -374,6 +375,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="withdraw_manual_ready_town">Town</string> <string name="withdraw_manual_ready_warning">This is mandatory, otherwise your money will not arrive in this wallet.</string> <string name="withdraw_manual_step">&lt;b&gt;Step %1$s:&lt;/b&gt; %2$s</string> + <string name="withdraw_manual_step_cyclos">Copy and paste recipient and amount into the corresponding fields in your banking app or website.</string> <string name="withdraw_manual_step_finish">Finish the wire transfer of %1$s in your banking app or website, then this withdrawal will proceed automatically. Depending on your bank the transfer can take from minutes to two working days, please be patient.</string> <string name="withdraw_manual_step_iban">If you don\'t already have it in your banking favorites list, then copy and paste recipient and IBAN into the recipient/IBAN fields in your banking app or website (and save it as favorite for the next time):</string> <string name="withdraw_manual_step_subject">Copy this code and paste it into the subject/purpose field in your banking app or bank website:</string>