taler-android

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

commit 9685a754582ce89b1f29ab062bc67d0696422f72
parent 6c0bef9d2f4633661f2eb2bbb253807df61dac25
Author: Iván Ávalos <avalos@disroot.org>
Date:   Fri, 12 Jul 2024 21:05:11 -0600

[wallet] Implement handling of taler:// via NFC

Diffstat:
Mwallet/src/main/AndroidManifest.xml | 1+
Mwallet/src/main/java/net/taler/wallet/HostCardEmulatorService.kt | 369+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mwallet/src/main/java/net/taler/wallet/MainActivity.kt | 84++++++++++++++++++++++++++++++-------------------------------------------------
Mwallet/src/main/res/xml/apduservice.xml | 1+
4 files changed, 273 insertions(+), 182 deletions(-)

diff --git a/wallet/src/main/AndroidManifest.xml b/wallet/src/main/AndroidManifest.xml @@ -58,6 +58,7 @@ </intent-filter> <intent-filter> <action android:name="android.intent.action.VIEW" /> + <action android:name="android.nfc.action.NDEF_DISCOVERED"/> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> diff --git a/wallet/src/main/java/net/taler/wallet/HostCardEmulatorService.kt b/wallet/src/main/java/net/taler/wallet/HostCardEmulatorService.kt @@ -1,6 +1,6 @@ /* * This file is part of GNU Taler - * (C) 2020 Taler Systems S.A. + * (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 @@ -16,78 +16,45 @@ package net.taler.wallet -import android.content.BroadcastReceiver -import android.content.Context +import android.app.Activity +import android.app.Service import android.content.Intent -import android.content.IntentFilter -import android.net.Uri +import android.nfc.NdefMessage +import android.nfc.NdefRecord import android.nfc.cardemulation.HostApduService import android.os.Bundle import android.util.Log -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.util.concurrent.ConcurrentLinkedDeque - -fun makeApduSuccessResponse(payload: ByteArray): ByteArray { - val stream = ByteArrayOutputStream() - stream.write(payload) - stream.write(0x90) - stream.write(0x00) - return stream.toByteArray() -} - - -fun makeApduFailureResponse(): ByteArray { - val stream = ByteArrayOutputStream() - stream.write(0x6F) - stream.write(0x00) - return stream.toByteArray() -} - - -fun readApduBodySize(stream: ByteArrayInputStream): Int { - val b0 = stream.read() - if (b0 == -1) { - return 0 - } - if (b0 != 0) { - return b0 - } - val b1 = stream.read() - val b2 = stream.read() - - return (b1 shl 8) and b2 -} - +import java.math.BigInteger class HostCardEmulatorService : HostApduService() { - val queuedRequests: ConcurrentLinkedDeque<String> = ConcurrentLinkedDeque() - private lateinit var receiver: BroadcastReceiver - - override fun onCreate() { - super.onCreate() - receiver = object : BroadcastReceiver() { - override fun onReceive(p0: Context?, p1: Intent?) { - queuedRequests.addLast(p1!!.getStringExtra("tunnelMessage")) - } + private var uri: String? = null + private val ndefMessage: NdefMessage? + get() = uri?.let { + val record = createUriRecord(it) + NdefMessage(record) } - IntentFilter(HTTP_TUNNEL_REQUEST).also { filter -> - registerReceiver(receiver, filter) - } - } - override fun onDestroy() { - super.onDestroy() - unregisterReceiver(receiver) - } + private val ndefUriBytes: ByteArray? + get() = ndefMessage?.toByteArray() - override fun onDeactivated(reason: Int) { - Log.d(TAG, "Deactivated: $reason") - Intent().also { intent -> - intent.action = MERCHANT_NFC_DISCONNECTED - sendBroadcast(intent) + private val ndefUriLen: ByteArray? + get() = ndefUriBytes?.size?.toLong()?.let { size -> + fillByteArrayToFixedDimension( + BigInteger.valueOf(size).toByteArray(), + 2 + ) } + + private var readCapabilityContainerCheck = false + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.getStringExtra("uri")?.let { uri = it } + + Log.i(TAG, "onStartCommand() | URI: $uri") + Log.i(TAG, "onStartCommand() | NDEF$ndefMessage") + + return Service.START_STICKY } override fun processCommandApdu( @@ -98,92 +65,234 @@ class HostCardEmulatorService : HostApduService() { Log.d(TAG, "Processing command APDU") if (commandApdu == null) { - Log.d(TAG, "APDU is null") - return makeApduFailureResponse() + Log.d(TAG, "processCommandApi() no data received") + return A_ERROR + } + + val message = ndefMessage + if (message == null) { + Log.d(TAG, "processCommandApi() no data to write") + return A_ERROR } - val stream = ByteArrayInputStream(commandApdu) + // + // The following flow is based on Appendix E "Example of Mapping Version 2.0 Command Flow" + // in the NFC Forum specification + // + Log.i(TAG, "processCommandApdu() | incoming commandApdu: " + commandApdu.toHex()) + + // + // First command: NDEF Tag Application select (Section 5.5.2 in NFC Forum spec) + // + if (APDU_SELECT.contentEquals(commandApdu)) { + Log.i(TAG, "APDU_SELECT triggered. Our Response: " + A_OKAY.toHex()) + return A_OKAY + } + + // + // Second command: Capability Container select (Section 5.5.3 in NFC Forum spec) + // + if (CAPABILITY_CONTAINER_OK.contentEquals(commandApdu)) { + Log.i(TAG, "CAPABILITY_CONTAINER_OK triggered. Our Response: " + A_OKAY.toHex()) + return A_OKAY + } - val command = stream.read() + // + // Third command: ReadBinary data from CC file (Section 5.5.4 in NFC Forum spec) + // + if (READ_CAPABILITY_CONTAINER.contentEquals(commandApdu) && !readCapabilityContainerCheck) { + Log.i(TAG, "READ_CAPABILITY_CONTAINER triggered. Our Response: " + READ_CAPABILITY_CONTAINER_RESPONSE.toHex()) + + readCapabilityContainerCheck = true + return READ_CAPABILITY_CONTAINER_RESPONSE + } - if (command != 0) { - Log.d(TAG, "APDU has invalid command") - return makeApduFailureResponse() + // + // Fourth command: NDEF Select command (Section 5.5.5 in NFC Forum spec) + // + if (NDEF_SELECT_OK.contentEquals(commandApdu)) { + Log.i(TAG, "NDEF_SELECT_OK triggered. Our Response: " + A_OKAY.toHex()) + return A_OKAY } - val instruction = stream.read() + if (NDEF_READ_BINARY_NLEN.contentEquals(commandApdu)) { + // Build our response + val response = ByteArray(ndefUriLen!!.size + A_OKAY.size) + System.arraycopy(ndefUriLen!!, 0, response, 0, ndefUriLen!!.size) + System.arraycopy(A_OKAY, 0, response, ndefUriLen!!.size, A_OKAY.size) - // Read instruction parameters, currently ignored. - stream.read() - stream.read() + Log.i(TAG, "NDEF_READ_BINARY_NLEN triggered. Our Response: " + response.toHex()) - if (instruction == SELECT_INS) { - // FIXME: validate body! - return makeApduSuccessResponse(ByteArray(0)) + readCapabilityContainerCheck = false + return response } - if (instruction == GET_INS) { - val req = queuedRequests.poll() - return if (req != null) { - Log.v(TAG, "sending tunnel request") - makeApduSuccessResponse(req.toByteArray(Charsets.UTF_8)) - } else { - makeApduSuccessResponse(ByteArray(0)) - } + if (commandApdu.sliceArray(0..1).contentEquals(NDEF_READ_BINARY)) { + val offset = commandApdu.sliceArray(2..3).toHex().toInt(16) + val length = commandApdu.sliceArray(4..4).toHex().toInt(16) + + val fullResponse = ByteArray(ndefUriLen!!.size + ndefUriBytes!!.size) + System.arraycopy(ndefUriLen!!, 0, fullResponse, 0, ndefUriLen!!.size) + System.arraycopy( + ndefUriBytes!!, + 0, + fullResponse, + ndefUriLen!!.size, + ndefUriBytes!!.size, + ) + + Log.i(TAG, "NDEF_READ_BINARY triggered. Full data: " + fullResponse.toHex()) + Log.i(TAG, "READ_BINARY - OFFSET: $offset - LEN: $length") + + val slicedResponse = fullResponse.sliceArray(offset until fullResponse.size) + + // Build our response + val realLength = if (slicedResponse.size <= length) slicedResponse.size else length + val response = ByteArray(realLength + A_OKAY.size) + + System.arraycopy(slicedResponse, 0, response, 0, realLength) + System.arraycopy(A_OKAY, 0, response, realLength, A_OKAY.size) + + Log.i(TAG, "NDEF_READ_BINARY triggered. Our Response: " + response.toHex()) + + readCapabilityContainerCheck = false + return response } - if (instruction == PUT_INS) { - val bodySize = readApduBodySize(stream) - val talerInstr = stream.read() - val bodyBytes = stream.readBytes() - if (1 + bodyBytes.size != bodySize) { - Log.w(TAG, "mismatched body size ($bodySize vs ${bodyBytes.size}") - } - - when (talerInstr) { - 1 -> { - val url = String(bodyBytes, Charsets.UTF_8) - Log.v(TAG, "got URL: '$url'") - - Intent(this, MainActivity::class.java).also { intent -> - intent.data = Uri.parse(url) - intent.action = Intent.ACTION_VIEW - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(intent) - } - } - 2 -> { - Log.v(TAG, "got http response: ${bodyBytes.toString(Charsets.UTF_8)}") - - Intent().also { intent -> - intent.action = HTTP_TUNNEL_RESPONSE - intent.putExtra("response", bodyBytes.toString(Charsets.UTF_8)) - sendBroadcast(intent) - } - } - else -> { - Log.v(TAG, "taler instruction $talerInstr unknown") - } - } - - return makeApduSuccessResponse(ByteArray(0)) + // + // We're doing something outside our scope + // + Log.wtf(TAG, "processCommandApdu() | I don't know what's going on!!!") + return A_ERROR + } + + override fun onDeactivated(reason: Int) { + Log.i(TAG, "onDeactivated() Fired! Reason: $reason") + } + + private fun ByteArray.toHex(): String { + val result = StringBuffer() + + forEach { + val octet = it.toInt() + val firstIndex = (octet and 0xF0).ushr(4) + val secondIndex = octet and 0x0F + result.append(HEX_CHARS[firstIndex]) + result.append(HEX_CHARS[secondIndex]) } - return makeApduFailureResponse() + return result.toString() } - companion object { - const val TAG = "taler-wallet-hce" - const val SELECT_INS = 0xA4 - const val PUT_INS = 0xDA - const val GET_INS = 0xCA + private fun createUriRecord(uri: String) = NdefRecord.createUri(uri) + + private fun fillByteArrayToFixedDimension(array: ByteArray, fixedSize: Int): ByteArray { + if (array.size == fixedSize) { + return array + } - const val TRIGGER_PAYMENT_ACTION = "net.taler.TRIGGER_PAYMENT_ACTION" + val start = byteArrayOf(0x00.toByte()) + val filledArray = ByteArray(start.size + array.size) + System.arraycopy(start, 0, filledArray, 0, start.size) + System.arraycopy(array, 0, filledArray, start.size, array.size) + return fillByteArrayToFixedDimension(filledArray, fixedSize) + } - const val MERCHANT_NFC_CONNECTED = "net.taler.MERCHANT_NFC_CONNECTED" - const val MERCHANT_NFC_DISCONNECTED = "net.taler.MERCHANT_NFC_DISCONNECTED" + companion object { + private const val TAG = "taler-wallet-hce" + + private val APDU_SELECT = byteArrayOf( + 0x00.toByte(), // CLA - Class - Class of instruction + 0xA4.toByte(), // INS - Instruction - Instruction code + 0x04.toByte(), // P1 - Parameter 1 - Instruction parameter 1 + 0x00.toByte(), // P2 - Parameter 2 - Instruction parameter 2 + 0x07.toByte(), // Lc field - Number of bytes present in the data field of the command + 0xD2.toByte(), + 0x76.toByte(), + 0x00.toByte(), + 0x00.toByte(), + 0x85.toByte(), + 0x01.toByte(), + 0x01.toByte(), // NDEF Tag Application name + 0x00.toByte(), // Le field - Maximum number of bytes expected in the data field of the response to the command + ) + + private val CAPABILITY_CONTAINER_OK = byteArrayOf( + 0x00.toByte(), // CLA - Class - Class of instruction + 0xa4.toByte(), // INS - Instruction - Instruction code + 0x00.toByte(), // P1 - Parameter 1 - Instruction parameter 1 + 0x0c.toByte(), // P2 - Parameter 2 - Instruction parameter 2 + 0x02.toByte(), // Lc field - Number of bytes present in the data field of the command + 0xe1.toByte(), + 0x03.toByte(), // file identifier of the CC file + ) + + private val READ_CAPABILITY_CONTAINER = byteArrayOf( + 0x00.toByte(), // CLA - Class - Class of instruction + 0xb0.toByte(), // INS - Instruction - Instruction code + 0x00.toByte(), // P1 - Parameter 1 - Instruction parameter 1 + 0x00.toByte(), // P2 - Parameter 2 - Instruction parameter 2 + 0x0f.toByte(), // Lc field - Number of bytes present in the data field of the command + ) + + private val READ_CAPABILITY_CONTAINER_RESPONSE = byteArrayOf( + 0x00.toByte(), 0x11.toByte(), // CCLEN length of the CC file + 0x20.toByte(), // Mapping Version 2.0 + 0xFF.toByte(), 0xFF.toByte(), // MLe maximum + 0xFF.toByte(), 0xFF.toByte(), // MLc maximum + 0x04.toByte(), // T field of the NDEF File Control TLV + 0x06.toByte(), // L field of the NDEF File Control TLV + 0xE1.toByte(), 0x04.toByte(), // File Identifier of NDEF file + 0xFF.toByte(), 0xFE.toByte(), // Maximum NDEF file size of 65534 bytes + 0x00.toByte(), // Read access without any security + 0xFF.toByte(), // Write access without any security + 0x90.toByte(), 0x00.toByte(), // A_OKAY + ) + + private val NDEF_SELECT_OK = byteArrayOf( + 0x00.toByte(), // CLA - Class - Class of instruction + 0xa4.toByte(), // Instruction byte (INS) for Select command + 0x00.toByte(), // Parameter byte (P1), select by identifier + 0x0c.toByte(), // Parameter byte (P1), select by identifier + 0x02.toByte(), // Lc field - Number of bytes present in the data field of the command + 0xE1.toByte(), + 0x04.toByte(), // file identifier of the NDEF file retrieved from the CC file + ) + + private val NDEF_READ_BINARY = byteArrayOf( + 0x00.toByte(), // Class byte (CLA) + 0xb0.toByte(), // Instruction byte (INS) for ReadBinary command + ) + + private val NDEF_READ_BINARY_NLEN = byteArrayOf( + 0x00.toByte(), // Class byte (CLA) + 0xb0.toByte(), // Instruction byte (INS) for ReadBinary command + 0x00.toByte(), + 0x00.toByte(), // Parameter byte (P1, P2), offset inside the CC file + 0x02.toByte(), // Le field + ) + + private val A_OKAY = byteArrayOf( + 0x90.toByte(), // SW1 Status byte 1 - Command processing status + 0x00.toByte(), // SW2 Status byte 2 - Command processing qualifier + ) + + private val A_ERROR = byteArrayOf( + 0x6A.toByte(), // SW1 Status byte 1 - Command processing status + 0x82.toByte(), // SW2 Status byte 2 - Command processing qualifier + ) + + private val HEX_CHARS = "0123456789ABCDEF".toCharArray() + + fun setUri(activity: MainActivity, uri: String) { + val intent = Intent(activity, HostCardEmulatorService::class.java) + intent.putExtra("uri", uri) + activity.startService(intent) + } - const val HTTP_TUNNEL_RESPONSE = "net.taler.HTTP_TUNNEL_RESPONSE" - const val HTTP_TUNNEL_REQUEST = "net.taler.HTTP_TUNNEL_REQUEST" + fun clearUri(activity: Activity) { + val intent = Intent(activity, HostCardEmulatorService::class.java) + activity.stopService(intent) + } } } \ No newline at end of file diff --git a/wallet/src/main/java/net/taler/wallet/MainActivity.kt b/wallet/src/main/java/net/taler/wallet/MainActivity.kt @@ -17,11 +17,10 @@ package net.taler.wallet import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent import android.content.Intent.ACTION_VIEW -import android.content.IntentFilter +import android.nfc.NdefMessage +import android.nfc.NfcAdapter import android.os.Bundle import android.util.Log import android.view.Menu @@ -51,12 +50,10 @@ import com.journeyapps.barcodescanner.ScanOptions.QR_CODE import net.taler.common.EventObserver import net.taler.wallet.BuildConfig.VERSION_CODE import net.taler.wallet.BuildConfig.VERSION_NAME -import net.taler.wallet.HostCardEmulatorService.Companion.HTTP_TUNNEL_RESPONSE -import net.taler.wallet.HostCardEmulatorService.Companion.MERCHANT_NFC_CONNECTED -import net.taler.wallet.HostCardEmulatorService.Companion.MERCHANT_NFC_DISCONNECTED -import net.taler.wallet.HostCardEmulatorService.Companion.TRIGGER_PAYMENT_ACTION import net.taler.wallet.databinding.ActivityMainBinding import net.taler.wallet.events.ObservabilityDialog +import net.taler.wallet.transactions.TransactionPeerPullCredit +import net.taler.wallet.transactions.TransactionPeerPushDebit class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, OnPreferenceStartFragmentCallback { @@ -115,10 +112,18 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, //model.startTunnel() - registerReceiver(triggerPaymentReceiver, IntentFilter(TRIGGER_PAYMENT_ACTION)) - registerReceiver(nfcConnectedReceiver, IntentFilter(MERCHANT_NFC_CONNECTED)) - registerReceiver(nfcDisconnectedReceiver, IntentFilter(MERCHANT_NFC_DISCONNECTED)) - registerReceiver(tunnelResponseReceiver, IntentFilter(HTTP_TUNNEL_RESPONSE)) + model.transactionManager.selectedTransaction.observe(this) { tx -> + HostCardEmulatorService.clearUri(this) + + when (tx) { + is TransactionPeerPushDebit -> tx.talerUri + is TransactionPeerPullCredit -> tx.talerUri + else -> return@observe + }?.let { uri -> + Log.d(TAG, "Transaction ${tx.transactionId} selected with URI $uri") + HostCardEmulatorService.setUri(this, uri) + } + } model.scanCodeEvent.observe(this, EventObserver { val scanOptions = ScanOptions().apply { @@ -152,6 +157,22 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, if (intent?.action == ACTION_VIEW) intent.dataString?.let { uri -> handleTalerUri(uri, "intent") } + + if (NfcAdapter.ACTION_NDEF_DISCOVERED == intent?.action) { + intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)?.also { rawMessages -> + val messages: List<NdefMessage> = rawMessages.map { it as NdefMessage } + + messages.forEach { message -> + message.records?.forEach { record -> + record.toUri()?.let { uri -> + Log.d(TAG, "URI read from NFC tag: $uri") + handleTalerUri(uri.toString(), "nfc") + } + } + } + } + + } } override fun onCreateOptionsMenu(menu: Menu?): Boolean { @@ -204,47 +225,6 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, nav.navigate(R.id.action_global_handle_uri, args) } - override fun onDestroy() { - unregisterReceiver(triggerPaymentReceiver) - unregisterReceiver(nfcConnectedReceiver) - unregisterReceiver(nfcDisconnectedReceiver) - unregisterReceiver(tunnelResponseReceiver) - super.onDestroy() - } - - private val triggerPaymentReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (nav.currentDestination?.id == R.id.promptPayment) return - intent.extras?.getString("contractUrl")?.let { url -> - nav.navigate(R.id.action_global_promptPayment) - model.paymentManager.preparePay(url) - } - } - } - - private val nfcConnectedReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Log.v(TAG, "got MERCHANT_NFC_CONNECTED") - //model.startTunnel() - } - } - - private val nfcDisconnectedReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Log.v(TAG, "got MERCHANT_NFC_DISCONNECTED") - //model.stopTunnel() - } - } - - private val tunnelResponseReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Log.v("taler-tunnel", "got HTTP_TUNNEL_RESPONSE") - intent.getStringExtra("response")?.let { - model.tunnelResponse(it) - } - } - } - override fun onPreferenceStartFragment( caller: PreferenceFragmentCompat, pref: Preference, diff --git a/wallet/src/main/res/xml/apduservice.xml b/wallet/src/main/res/xml/apduservice.xml @@ -21,5 +21,6 @@ android:category="other" android:description="@string/host_apdu_service_desc"> <aid-filter android:name="F00054414C4552" /> + <aid-filter android:name="D2760000850101"/> </aid-group> </host-apdu-service> \ No newline at end of file