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