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