taler-android

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

commit 6008d0e82624c04facdfbb8bc80e0518d4b089c9
parent 5a50c1764be2f885469db3816853ae0beea7a8f1
Author: Iván Ávalos <avalos@disroot.org>
Date:   Wed, 22 Jul 2026 12:20:14 +0200

[wallet] implement writing xTOTP codes via NFC

Diffstat:
Awallet/src/main/java/net/taler/wallet/payment/NfcTotpWriter.kt | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt | 5+----
Mwallet/src/main/res/values/strings.xml | 5+++++
3 files changed, 161 insertions(+), 4 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 @@ -0,0 +1,155 @@ +/* + * 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.nfc.tech.NfcA +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/TransactionPaymentComposable.kt b/wallet/src/main/java/net/taler/wallet/payment/TransactionPaymentComposable.kt @@ -143,10 +143,7 @@ fun TransactionPaymentComposable( } if (t.posConfirmation != null) { - TransactionInfoComposable( - label = stringResource(id = R.string.payment_confirmation_code), - info = t.posConfirmation, - ) + TotpNfcWriter(totpString = t.posConfirmation) } if (t.info != null) PurchaseDetails(info = t.info) { diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml @@ -71,6 +71,11 @@ 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="ok">OK</string> <string name="open">Open</string> <string name="paste">Paste</string>