commit f61ff2dd9f3b42ccb9037aed97071f755a8cc41e
parent 47f91f31f7d2e658979121f5e5590c02791577fa
Author: Iván Ávalos <avalos@disroot.org>
Date: Thu, 27 Jun 2024 14:04:55 -0600
[wallet] Display payment QR codes in manual withdrawal
bug 0008957
Diffstat:
6 files changed, 275 insertions(+), 37 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/compose/ExpandableCard.kt b/wallet/src/main/java/net/taler/wallet/compose/ExpandableCard.kt
@@ -0,0 +1,114 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 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.compose
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedCard
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ExpandableCard(
+ modifier: Modifier = Modifier,
+ expanded: Boolean = false,
+ setExpanded: (expanded: Boolean) -> Unit,
+ header: @Composable RowScope.() -> Unit,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val rotationState by animateFloatAsState(
+ targetValue = if (expanded) 180f else 0f,
+ label = "Rotation state of expand icon button",
+ )
+
+ OutlinedCard(
+ modifier = modifier.padding(8.dp),
+ onClick = { setExpanded(!expanded) }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize() // edit animation here
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, top = 4.dp, bottom = 4.dp, end = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ header()
+
+ IconButton(
+ modifier = Modifier.rotate(rotationState),
+ onClick = { setExpanded(!expanded) }
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = "Drop Down Arrow"
+ )
+ }
+ }
+
+ if (expanded) {
+ content()
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun ExpandableCardPreview() {
+ TalerSurface {
+ var expanded by remember { mutableStateOf(true) }
+ ExpandableCard(
+ expanded = expanded,
+ setExpanded = { expanded = it },
+ header = { Text("Swiss QR") },
+ content = {
+ QrCodeUriComposable(
+ talerUri = "taler://withdraw-exchange",
+ clipBoardLabel = "",
+ showContents = false,
+ )
+ }
+ )
+ }
+}
+\ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt
@@ -60,6 +60,7 @@ fun ColumnScope.QrCodeUriComposable(
talerUri: String,
clipBoardLabel: String,
buttonText: String = stringResource(R.string.copy),
+ showContents: Boolean = true,
inBetween: (@Composable ColumnScope.() -> Unit)? = null,
) {
val qrCodeSize = getQrCodeSize()
@@ -74,43 +75,45 @@ fun ColumnScope.QrCodeUriComposable(
modifier = Modifier
.size(qrCodeSize)
.align(CenterHorizontally)
- .padding(vertical = 8.dp),
+ .padding(vertical = if (showContents) 8.dp else 0.dp),
bitmap = qrCode,
contentDescription = stringResource(id = R.string.button_scan_qr_code),
)
}
if (inBetween != null) inBetween()
val scrollState = rememberScrollState()
- Box(modifier = Modifier.padding(16.dp)) {
- Text(
- modifier = Modifier.horizontalScroll(scrollState),
- fontFamily = FontFamily.Monospace,
- style = MaterialTheme.typography.bodyLarge,
- text = talerUri,
- )
- }
- Row(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly,
- ) {
- CopyToClipboardButton(
- label = clipBoardLabel,
- content = talerUri,
- buttonText = buttonText,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ if (showContents) {
+ Box(modifier = Modifier.padding(16.dp)) {
+ Text(
+ modifier = Modifier.horizontalScroll(scrollState),
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.bodyLarge,
+ text = talerUri,
)
- )
- ShareButton(
- content = talerUri,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ }
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ CopyToClipboardButton(
+ label = clipBoardLabel,
+ content = talerUri,
+ buttonText = buttonText,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
)
- )
+ ShareButton(
+ content = talerUri,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ )
+ }
}
}
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt b/wallet/src/main/java/net/taler/wallet/withdraw/WithdrawManager.kt
@@ -23,6 +23,7 @@ import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.taler.common.Amount
import net.taler.common.Bech32
@@ -161,6 +162,28 @@ data class AcceptManualWithdrawalResponse(
val transactionId: String,
)
+@Serializable
+data class GetQrCodesForPaytoResponse(
+ val codes: List<QrCodeSpec>,
+)
+
+@Serializable
+data class QrCodeSpec(
+ val type: Type = Type.Unknown,
+ val qrContent: String,
+) {
+ @Serializable
+ enum class Type {
+ Unknown,
+
+ @SerialName("epc-qr")
+ EpcQr,
+
+ @SerialName("spc")
+ SPC,
+ }
+}
+
class WithdrawManager(
private val api: WalletBackendApi,
private val scope: CoroutineScope,
@@ -169,6 +192,8 @@ class WithdrawManager(
val withdrawStatus = MutableLiveData<WithdrawStatus>()
val testWithdrawalStatus = MutableLiveData<WithdrawTestStatus>()
+ val qrCodes = MutableLiveData<List<QrCodeSpec>>()
+
var exchangeFees: ExchangeFees? = null
private set
@@ -331,11 +356,26 @@ class WithdrawManager(
}.onError {
handleError("acceptManualWithdrawal", it)
}.onSuccess { response ->
- withdrawStatus.value = createManualTransferRequired(
- status = status,
- response = response,
- )
+ scope.launch {
+ withdrawStatus.value = createManualTransferRequired(
+ status = status,
+ response = response,
+ )
+ }
+ }
+ }
+
+ fun getQrCodesForPayto(uri: String) = scope.launch {
+ var codes = emptyList<QrCodeSpec>()
+ api.request("getQrCodesForPayto", GetQrCodesForPaytoResponse.serializer()) {
+ put("paytoUri", uri)
+ }.onError { error ->
+ handleError("getQrCodesForPayto", error)
+ }.onSuccess { response ->
+ codes = response.codes
}
+
+ qrCodes.value = codes
}
private fun handleError(operation: String, error: TalerErrorInfo) {
@@ -378,7 +418,7 @@ fun createManualTransferRequired(
subject = reserve,
amountRaw = amountRaw,
amountEffective = amountEffective,
- withdrawalAccount = it.copy(paytoUri = uri.toString())
+ withdrawalAccount = it.copy(paytoUri = uri.toString()),
)
} else if (uri.authority.equals("x-taler-bank", true)) {
TransferData.Taler(
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ManualWithdrawSuccessFragment.kt
@@ -21,6 +21,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -65,8 +67,12 @@ class ManualWithdrawSuccessFragment : Fragment() {
setContent {
TalerSurface {
+ val qrCodes by withdrawManager.qrCodes.observeAsState()
+
ScreenTransfer(
status = status,
+ qrCodes = qrCodes ?: emptyList(),
+ getQrCodes = { withdrawManager.getQrCodesForPayto(it.paytoUri) },
spec = balanceManager.getSpecForCurrency(status.transactionAmountRaw.currency),
bankAppClick = { onBankAppClick(it) },
shareClick = { onShareClick(it) },
diff --git a/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt b/wallet/src/main/java/net/taler/wallet/withdraw/manual/ScreenTransfer.kt
@@ -32,7 +32,9 @@ 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.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -45,23 +47,30 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.taler.common.Amount
+import net.taler.common.CurrencySpecification
import net.taler.wallet.CURRENCY_BTC
import net.taler.wallet.R
-import net.taler.common.CurrencySpecification
+import net.taler.wallet.compose.ExpandableCard
+import net.taler.wallet.compose.QrCodeUriComposable
import net.taler.common.canAppHandleUri
import net.taler.wallet.compose.ShareButton
import net.taler.wallet.compose.copyToClipBoard
import net.taler.wallet.transactions.AmountType
import net.taler.wallet.transactions.TransactionAmountComposable
import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails
-import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails.Status.*
+import net.taler.wallet.transactions.WithdrawalExchangeAccountDetails.Status.Ok
+import net.taler.wallet.withdraw.QrCodeSpec
+import net.taler.wallet.withdraw.QrCodeSpec.Type.EpcQr
+import net.taler.wallet.withdraw.QrCodeSpec.Type.SPC
import net.taler.wallet.withdraw.TransferData
import net.taler.wallet.withdraw.WithdrawStatus
@Composable
fun ScreenTransfer(
status: WithdrawStatus.ManualTransferRequired,
+ qrCodes: List<QrCodeSpec>,
spec: CurrencySpecification?,
+ getQrCodes: (account: WithdrawalExchangeAccountDetails) -> Unit,
bankAppClick: ((transfer: TransferData) -> Unit)?,
shareClick: ((transfer: TransferData) -> Unit)?,
) {
@@ -77,6 +86,17 @@ fun ScreenTransfer(
val defaultTransfer = transfers[0]
var selectedTransfer by remember { mutableStateOf(defaultTransfer) }
+ val qrExpandedStates = remember(qrCodes) {
+ val map = mutableStateMapOf<QrCodeSpec, Boolean>()
+ qrCodes.forEach {
+ map[it] = false
+ }
+ map
+ }
+
+ LaunchedEffect(Unit) {
+ getQrCodes(defaultTransfer.withdrawalAccount)
+ }
Column {
if (status.withdrawalTransfers.size > 1) {
@@ -86,7 +106,10 @@ fun ScreenTransfer(
onSelectAccount = { account ->
status.withdrawalTransfers.find {
it.withdrawalAccount.paytoUri == account.paytoUri
- }?.let { selectedTransfer = it }
+ }?.let {
+ selectedTransfer = it
+ getQrCodes(it.withdrawalAccount)
+ }
}
)
}
@@ -137,6 +160,22 @@ fun ScreenTransfer(
.padding(bottom = 16.dp),
)
}
+
+ qrCodes.forEach { spec ->
+ QrCard(
+ expanded = qrExpandedStates[spec]!!,
+ setExpanded = { expanded ->
+ if (expanded) { // un-expand all others
+ qrExpandedStates.forEach { (k, _) ->
+ qrExpandedStates[k] = false
+ }
+ }
+ // expand only toggled one
+ qrExpandedStates[spec] = expanded
+ },
+ qrCode = spec,
+ )
+ }
}
}
}
@@ -267,6 +306,34 @@ fun TransferAccountChooser(
}
}
+@Composable
+fun QrCard(
+ expanded: Boolean,
+ setExpanded: (expanded: Boolean) -> Unit,
+ qrCode: QrCodeSpec,
+) {
+ val label = when(qrCode.type) {
+ EpcQr -> stringResource(R.string.withdraw_manual_qr_epc)
+ SPC -> stringResource(R.string.withdraw_manual_qr_spc)
+ else -> return
+ }
+
+ ExpandableCard(
+ expanded = expanded,
+ setExpanded = setExpanded,
+ header = {
+ Text(label, style = MaterialTheme.typography.titleMedium)
+ },
+ content = {
+ QrCodeUriComposable(
+ talerUri = qrCode.qrContent,
+ clipBoardLabel = label,
+ showContents = false,
+ )
+ },
+ )
+}
+
@Preview
@Composable
fun ScreenTransferPreview() {
@@ -323,6 +390,11 @@ fun ScreenTransferPreview() {
spec = null,
bankAppClick = {},
shareClick = {},
+ qrCodes = listOf(
+ QrCodeSpec(EpcQr, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0"),
+ QrCodeSpec(SPC, "BCD\\n002\\n1\\nSCT\\n\\n\\nGENODEM1GLS/DE54430609674049078800\\n\\n\\nTaler MJ15S835A5ENQZGJX161TS7FND6Q5DSABS8FCHB8ECF9NT1J8GH0")
+ ),
+ getQrCodes = {},
)
}
}
\ No newline at end of file
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
@@ -238,6 +238,8 @@ GNU Taler is immune against many types of fraud, such as phishing of credit card
<string name="withdraw_manual_bitcoin_intro">Now make a split transaction with the following three outputs.</string>
<string name="withdraw_manual_check_fees">Check fees</string>
<string name="withdraw_manual_payment_options">Payment options supported by %1$s:\n\n%2$s</string>
+ <string name="withdraw_manual_qr_epc">EPC QR</string>
+ <string name="withdraw_manual_qr_spc">Swiss QR</string>
<string name="withdraw_manual_ready_account">Account</string>
<string name="withdraw_manual_ready_bank_button">Open in banking app</string>
<string name="withdraw_manual_ready_details_intro">Bank transfer details</string>