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:
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"><b>Step %1$s:</b> %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>