taler-android

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

commit 5c2fdb6efaa0690ea4ff1e54ace4ca4c540e00c6
parent d012270b571e069fa0203c45f45bbd780da088fe
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 23 Jul 2026 12:07:02 +0200

[wallet] UI improvements for xTOTP/NFC

Diffstat:
Dwallet/src/main/java/net/taler/wallet/payment/NfcTotpWriter.kt | 154-------------------------------------------------------------------------------
Mwallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt | 3---
Awallet/src/main/java/net/taler/wallet/payment/PayTotpComposable.kt | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt | 7++++---
Mwallet/src/main/java/net/taler/wallet/transactions/Transactions.kt | 1+
Mwallet/src/main/res/values/strings.xml | 8+++-----
6 files changed, 164 insertions(+), 165 deletions(-)

diff --git a/wallet/src/main/java/net/taler/wallet/payment/NfcTotpWriter.kt b/wallet/src/main/java/net/taler/wallet/payment/NfcTotpWriter.kt @@ -1,154 +0,0 @@ -/* - * 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.payment - -import android.app.Activity -import android.nfc.NdefMessage -import android.nfc.NdefRecord -import android.nfc.NfcAdapter -import android.nfc.tech.Ndef -import android.widget.Toast -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -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.Companion.CenterHorizontally -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import net.taler.wallet.R -import java.io.ByteArrayOutputStream - -private const val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_V - -private fun formatTotpPayload(totpString: String): ByteArray { - val codes = totpString.split("\n").mapNotNull { it.toUIntOrNull() } - val out = ByteArrayOutputStream() - out.write(0x42) - for (code in codes) { - val v = code.toInt() - out.write(v shr 0 and 0xFF) - out.write(v shr 8 and 0xFF) - out.write(v shr 16 and 0xFF) - out.write(v shr 24 and 0xFF) - } - return out.toByteArray() -} - -@Composable -fun TotpNfcWriter( - totpString: String, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val activity = context as? Activity - var isWriting by remember { mutableStateOf(false) } - - DisposableEffect(activity) { - onDispose { - if (isWriting && activity != null) { - NfcAdapter.getDefaultAdapter(context)?.disableReaderMode(activity) - } - } - } - - val ndefMessage = remember(totpString) { - val payload = formatTotpPayload(totpString) - val record = NdefRecord( - NdefRecord.TNF_WELL_KNOWN, - "T".encodeToByteArray(), - ByteArray(0), - payload, - ) - NdefMessage(arrayOf(record)) - } - - Column(modifier = modifier, horizontalAlignment = CenterHorizontally) { - Text( - modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), - text = stringResource(R.string.payment_confirmation_code), - style = MaterialTheme.typography.bodyMedium, - ) - - Text( - modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp), - text = totpString, - fontFamily = FontFamily.Monospace, - fontSize = MaterialTheme.typography.titleLarge.fontSize, - ) - - Button( - onClick = { - val nfcAdapter = NfcAdapter.getDefaultAdapter(context) - val act = activity - if (nfcAdapter == null || act == null) { - Toast.makeText(context, R.string.nfc_not_available, Toast.LENGTH_SHORT).show() - return@Button - } - isWriting = true - nfcAdapter.enableReaderMode(act, { tag -> - val ndef = Ndef.get(tag) - if (ndef != null) { - try { - ndef.connect() - ndef.writeNdefMessage(ndefMessage) - act.runOnUiThread { - Toast.makeText(context, R.string.nfc_write_success, Toast.LENGTH_SHORT).show() - } - } catch (e: Exception) { - act.runOnUiThread { - Toast.makeText(context, "${e.message}", Toast.LENGTH_SHORT).show() - } - } finally { - act.runOnUiThread { - isWriting = false - nfcAdapter.disableReaderMode(act) - } - } - } else { - act.runOnUiThread { - isWriting = false - nfcAdapter.disableReaderMode(act) - Toast.makeText(context, R.string.nfc_tag_not_ndef, Toast.LENGTH_SHORT).show() - } - } - }, NFC_READER_FLAGS, null) - }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp).padding(top = 4.dp), - enabled = !isWriting, - ) { - Text(if (isWriting) stringResource(R.string.nfc_writing) else stringResource(R.string.nfc_write_button)) - } - - if (!isWriting) { - Spacer(Modifier.height(ButtonDefaults.IconSpacing)) - } - } -} diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTemplateComposable.kt @@ -30,11 +30,8 @@ import androidx.compose.ui.res.stringResource 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.main.AmountResult import net.taler.wallet.R -import net.taler.wallet.balances.ScopeInfo import net.taler.wallet.compose.LoadingScreen import net.taler.wallet.compose.TalerSurface import net.taler.wallet.systemBarsPaddingBottom diff --git a/wallet/src/main/java/net/taler/wallet/payment/PayTotpComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/PayTotpComposable.kt @@ -0,0 +1,156 @@ +/* + * 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.payment + +import android.app.Activity +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.tech.Ndef +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import net.taler.wallet.R +import net.taler.wallet.ui.theme.TalerTheme +import java.io.ByteArrayOutputStream + +private const val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_V + +private fun formatTotpPayload(totpString: String): ByteArray { + val codes = totpString.split("\n").mapNotNull { it.toUIntOrNull() } + val out = ByteArrayOutputStream() + out.write(0x42) + for (code in codes) { + val v = code.toInt() + out.write(v shr 0 and 0xFF) + out.write(v shr 8 and 0xFF) + out.write(v shr 16 and 0xFF) + out.write(v shr 24 and 0xFF) + } + return out.toByteArray() +} + +@Composable +fun PayTotpComposable( + totpString: String, + enableNfc: Boolean = false, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val activity = context as? Activity + val nfcAdapter = remember(enableNfc) { if (enableNfc) NfcAdapter.getDefaultAdapter(context) else null } + var nfcDone by remember { mutableStateOf(false) } + + if (enableNfc) { + val ndefMessage = remember(totpString) { + val payload = formatTotpPayload(totpString) + val record = NdefRecord( + NdefRecord.TNF_WELL_KNOWN, + "T".encodeToByteArray(), + ByteArray(0), + payload, + ) + NdefMessage(arrayOf(record)) + } + + LaunchedEffect(activity, ndefMessage) { + val act = activity ?: return@LaunchedEffect + if (nfcAdapter == null) return@LaunchedEffect + nfcAdapter.enableReaderMode(act, { tag -> + val ndef = Ndef.get(tag) + if (ndef != null) { + try { + ndef.connect() + ndef.writeNdefMessage(ndefMessage) + act.runOnUiThread { + nfcDone = true + Toast.makeText(context, R.string.nfc_write_success, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + act.runOnUiThread { + Toast.makeText(context, "${e.message}", Toast.LENGTH_SHORT).show() + } + } finally { + act.runOnUiThread { nfcAdapter.disableReaderMode(act) } + } + } else { + nfcAdapter.disableReaderMode(act) + } + }, NFC_READER_FLAGS, null) + } + + DisposableEffect(activity) { + onDispose { + if (activity != null) { + nfcAdapter?.disableReaderMode(activity) + } + } + } + } + + Column(modifier = modifier, horizontalAlignment = CenterHorizontally) { + Text( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + text = stringResource(R.string.payment_confirmation_code), + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp), + text = totpString, + fontFamily = FontFamily.Monospace, + fontSize = MaterialTheme.typography.titleLarge.fontSize, + ) + + if (enableNfc) { + if (nfcAdapter == null) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.nfc_not_available), + color = MaterialTheme.colorScheme.error, + ) + } else if (nfcDone) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.nfc_write_success), + color = TalerTheme.extraColors.success, + ) + } else { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.nfc_scanning), + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -141,9 +141,10 @@ fun TransactionPaymentComposable( ) } - if (t.posConfirmation != null) { - TotpNfcWriter(totpString = t.posConfirmation) - } + if (t.posConfirmation != null) PayTotpComposable( + totpString = t.posConfirmation, + enableNfc = t.posConfirmationViaNfc == true, + ) if (t.info != null) PurchaseDetails(info = t.info) { onFulfill(t.info.fulfillmentUrl ?: "") diff --git a/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt b/wallet/src/main/java/net/taler/wallet/transactions/Transactions.kt @@ -448,6 +448,7 @@ class TransactionPayment( override val amountEffective: Amount, override val scopes: List<ScopeInfo>, val posConfirmation: String? = null, + val posConfirmationViaNfc: Boolean? = null, ) : Transaction() { override val icon = R.drawable.transaction_payment override val detailPageNav = WalletDestination.TransactionPayment diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -71,11 +71,9 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish <string name="millisecond">%1$d ms</string> <string name="offline">Operation requires internet access. Please ensure your internet connection works and try again.</string> <string name="offline_banner">No internet access</string> - <string name="nfc_not_available">NFC is not available on this device</string> - <string name="nfc_tag_not_ndef">NFC tag does not support NDEF</string> - <string name="nfc_write_button">Write NFC</string> - <string name="nfc_write_success">Confirmation code written to terminal</string> - <string name="nfc_writing">Tap phone to terminal…</string> + <string name="nfc_not_available">NFC not available</string> + <string name="nfc_scanning">Tap NFC tag to write confirmation codes</string> + <string name="nfc_write_success">Confirmation codes written</string> <string name="ok">OK</string> <string name="open">Open</string> <string name="paste">Paste</string>