commit 3c1c86ba64acdd185b7e5612f8806c127cf2ca51 parent 33fbf69d0d31e3116f996fe1a13b8a353e62d9e5 Author: Iván Ávalos <avalos@disroot.org> Date: Sun, 14 Jul 2024 21:28:35 -0600 Share NFC logic across all apps Diffstat:
16 files changed, 369 insertions(+), 574 deletions(-)
diff --git a/cashier/src/main/AndroidManifest.xml b/cashier/src/main/AndroidManifest.xml @@ -6,6 +6,10 @@ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.NFC" /> + <uses-feature + android:name="android.hardware.nfc.hce" + android:required="false" /> + <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" @@ -29,6 +33,19 @@ </intent-filter> </activity> + <service + android:name="net.taler.lib.android.TalerNfcService" + android:exported="true" + android:permission="android.permission.BIND_NFC_SERVICE"> + <intent-filter> + <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" /> + </intent-filter> + + <meta-data + android:name="android.nfc.cardemulation.host_apdu_service" + android:resource="@xml/apduservice" /> + </service> + </application> </manifest> diff --git a/cashier/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt b/cashier/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt @@ -34,16 +34,15 @@ import net.taler.cashier.withdraw.TransactionFragmentDirections.Companion.action import net.taler.cashier.withdraw.WithdrawResult.Error import net.taler.cashier.withdraw.WithdrawResult.InsufficientBalance import net.taler.cashier.withdraw.WithdrawResult.Success -import net.taler.common.NfcManager import net.taler.common.exhaustive import net.taler.common.fadeIn import net.taler.common.fadeOut +import net.taler.lib.android.TalerNfcService class TransactionFragment : Fragment() { private val viewModel: MainViewModel by activityViewModels() private val withdrawManager by lazy { viewModel.withdrawManager } - private val nfcManager = NfcManager() private lateinit var ui: FragmentTransactionBinding @@ -67,7 +66,7 @@ class TransactionFragment : Fragment() { } // change intro text depending on whether NFC is available or not - val hasNfc = NfcManager.hasNfc(requireContext()) + val hasNfc = TalerNfcService.hasNfc(requireContext()) val intro = if (hasNfc) R.string.transaction_intro_nfc else R.string.transaction_intro ui.introView.setText(intro) @@ -76,26 +75,17 @@ class TransactionFragment : Fragment() { } } - override fun onStart() { - super.onStart() - if (withdrawManager.withdrawResult.value is Success) { - NfcManager.start(requireActivity(), nfcManager) - } - } - - override fun onStop() { - super.onStop() - NfcManager.stop(requireActivity()) - } - override fun onDestroy() { super.onDestroy() + TalerNfcService.clearUri(requireActivity()) if (!requireActivity().isChangingConfigurations) { withdrawManager.abort() } } private fun onWithdrawResultReceived(result: WithdrawResult?) { + TalerNfcService.clearUri(requireActivity()) + if (result != null) { ui.progressBar.animate() .alpha(0f) @@ -109,11 +99,7 @@ class TransactionFragment : Fragment() { is Error -> setErrorMsg(result.msg) is Success -> { // start NFC - nfcManager.setTagString(result.talerUri) - NfcManager.start( - requireActivity(), - nfcManager - ) + TalerNfcService.setUri(requireActivity(), result.talerUri) // show QR code ui.qrCodeView.alpha = 0f ui.qrCodeView.animate() diff --git a/cashier/src/main/res/values/strings.xml b/cashier/src/main/res/values/strings.xml @@ -53,4 +53,5 @@ <string name="about_copyright_holder" translatable="false">Taler Systems S.A.</string> <string name="about_supported_bank_api">Bank API Version: %s</string> + <string name="host_apdu_service_desc">Taler Cashier NFC payments</string> </resources> diff --git a/wallet/src/main/res/xml/apduservice.xml b/cashier/src/main/res/xml/apduservice.xml diff --git a/merchant-terminal/src/main/AndroidManifest.xml b/merchant-terminal/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ <uses-permission android:name="android.permission.NFC" /> <uses-feature - android:name="android.hardware.nfc" + android:name="android.hardware.nfc.hce" android:required="false" /> <uses-feature @@ -49,6 +49,20 @@ <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> + + <service + android:name="net.taler.lib.android.TalerNfcService" + android:exported="true" + android:permission="android.permission.BIND_NFC_SERVICE"> + <intent-filter> + <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" /> + </intent-filter> + + <meta-data + android:name="android.nfc.cardemulation.host_apdu_service" + android:resource="@xml/apduservice" /> + </service> + </application> </manifest> diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/MainActivity.kt @@ -33,13 +33,12 @@ import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupWithNavController import com.google.android.material.navigation.NavigationView.OnNavigationItemSelectedListener -import net.taler.common.NfcManager +import net.taler.lib.android.TalerNfcService import net.taler.merchantpos.databinding.ActivityMainBinding class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { private val model: MainViewModel by viewModels() - private val nfcManager = NfcManager() private lateinit var ui: ActivityMainBinding private lateinit var nav: NavController @@ -57,7 +56,9 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { model.paymentManager.payment.observe(this) { payment -> payment?.talerPayUri?.let { - nfcManager.setTagString(it) + TalerNfcService.setUri(this, it) + } ?: run { + TalerNfcService.clearUri(this) } } @@ -82,17 +83,6 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener { } } - public override fun onResume() { - super.onResume() - // TODO should we only read tags when a payment is to be made? - NfcManager.start(this, nfcManager) - } - - public override fun onPause() { - super.onPause() - NfcManager.stop(this) - } - override fun onNavigationItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.nav_order -> nav.navigate(R.id.action_global_order) diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/payment/ProcessPaymentFragment.kt @@ -25,12 +25,12 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar -import net.taler.common.NfcManager.Companion.hasNfc import net.taler.common.QrCodeManager.makeQrCode import net.taler.common.fadeIn import net.taler.common.fadeOut import net.taler.common.navigate import net.taler.common.showError +import net.taler.lib.android.TalerNfcService.Companion.hasNfc import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.databinding.FragmentProcessPaymentBinding diff --git a/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt b/merchant-terminal/src/main/java/net/taler/merchantpos/refund/RefundUriFragment.kt @@ -23,8 +23,8 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController -import net.taler.common.NfcManager.Companion.hasNfc import net.taler.common.QrCodeManager.makeQrCode +import net.taler.lib.android.TalerNfcService.Companion.hasNfc import net.taler.merchantpos.MainViewModel import net.taler.merchantpos.R import net.taler.merchantpos.databinding.FragmentRefundUriBinding diff --git a/merchant-terminal/src/main/res/values/strings.xml b/merchant-terminal/src/main/res/values/strings.xml @@ -81,4 +81,5 @@ <string name="toast_back_to_exit">Click «back» again to exit</string> + <string name="host_apdu_service_desc">Taler Merchant NFC payments</string> </resources> diff --git a/wallet/src/main/res/xml/apduservice.xml b/merchant-terminal/src/main/res/xml/apduservice.xml diff --git a/taler-kotlin-android/src/main/java/net/taler/common/NfcManager.kt b/taler-kotlin-android/src/main/java/net/taler/common/NfcManager.kt @@ -1,234 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2020 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.common - -import android.app.Activity -import android.content.Context -import android.nfc.NfcAdapter -import android.nfc.NfcAdapter.FLAG_READER_NFC_A -import android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK -import android.nfc.NfcAdapter.getDefaultAdapter -import android.nfc.Tag -import android.nfc.tech.IsoDep -import android.util.Log -import net.taler.common.ByteArrayUtils.hexStringToByteArray -import org.json.JSONObject -import java.io.ByteArrayOutputStream -import java.net.URL -import javax.net.ssl.HttpsURLConnection - -@Suppress("unused") -private const val TALER_AID = "A0000002471001" - -class NfcManager : NfcAdapter.ReaderCallback { - - companion object { - const val TAG = "taler-merchant" - - /** - * Returns true if NFC is supported and false otherwise. - */ - fun hasNfc(context: Context): Boolean { - return getNfcAdapter(context) != null - } - - /** - * Enables NFC reader mode. Don't forget to call [stop] afterwards. - */ - fun start(activity: Activity, nfcManager: NfcManager) { - getNfcAdapter(activity)?.enableReaderMode(activity, nfcManager, nfcManager.flags, null) - } - - /** - * Disables NFC reader mode. Call after [start]. - */ - fun stop(activity: Activity) { - getNfcAdapter(activity)?.disableReaderMode(activity) - } - - private fun getNfcAdapter(context: Context): NfcAdapter? { - return getDefaultAdapter(context) - } - } - - private val flags = FLAG_READER_NFC_A or FLAG_READER_SKIP_NDEF_CHECK - - private var tagString: String? = null - private var currentTag: IsoDep? = null - - fun setTagString(tagString: String) { - this.tagString = tagString - } - - override fun onTagDiscovered(tag: Tag?) { - - Log.v(TAG, "tag discovered") - - val isoDep = IsoDep.get(tag) - isoDep.connect() - - currentTag = isoDep - - isoDep.transceive(apduSelectFile()) - - val tagString: String? = tagString - if (tagString != null) { - isoDep.transceive(apduPutTalerData(1, tagString.toByteArray())) - } - - // FIXME: use better pattern for sleeps in between requests - // -> start with fast polling, poll more slowly if no requests are coming - - while (true) { - try { - val reqFrame = isoDep.transceive(apduGetData()) - if (reqFrame.size < 2) { - Log.v(TAG, "request frame too small") - break - } - val req = ByteArray(reqFrame.size - 2) - if (req.isEmpty()) { - continue - } - reqFrame.copyInto(req, 0, 0, reqFrame.size - 2) - val jsonReq = JSONObject(req.toString(Charsets.UTF_8)) - val reqId = jsonReq.getInt("id") - Log.v(TAG, "got request $jsonReq") - val jsonInnerReq = jsonReq.getJSONObject("request") - val method = jsonInnerReq.getString("method") - val urlStr = jsonInnerReq.getString("url") - Log.v(TAG, "url '$urlStr'") - Log.v(TAG, "method '$method'") - val url = URL(urlStr) - val conn: HttpsURLConnection = url.openConnection() as HttpsURLConnection - conn.setRequestProperty("Accept", "application/json") - conn.connectTimeout = 5000 - conn.doInput = true - when (method) { - "get" -> { - conn.requestMethod = "GET" - } - "postJson" -> { - conn.requestMethod = "POST" - conn.doOutput = true - conn.setRequestProperty("Content-Type", "application/json; utf-8") - val body = jsonInnerReq.getString("body") - conn.outputStream.write(body.toByteArray(Charsets.UTF_8)) - } - else -> { - throw Exception("method not supported") - } - } - Log.v(TAG, "connecting") - conn.connect() - Log.v(TAG, "connected") - - val statusCode = conn.responseCode - val tunnelResp = JSONObject() - tunnelResp.put("id", reqId) - tunnelResp.put("status", conn.responseCode) - - if (statusCode == 200) { - val stream = conn.inputStream - val httpResp = stream.buffered().readBytes() - tunnelResp.put("responseJson", JSONObject(httpResp.toString(Charsets.UTF_8))) - } - - Log.v(TAG, "sending: $tunnelResp") - - isoDep.transceive(apduPutTalerData(2, tunnelResp.toString().toByteArray())) - } catch (e: Exception) { - Log.v(TAG, "exception during NFC loop: $e") - break - } - } - - isoDep.close() - } - - private fun writeApduLength(stream: ByteArrayOutputStream, size: Int) { - when { - size == 0 -> { - // No size field needed! - } - size <= 255 -> // One byte size field - stream.write(size) - size <= 65535 -> { - stream.write(0) - // FIXME: is this supposed to be little or big endian? - stream.write(size and 0xFF) - stream.write((size ushr 8) and 0xFF) - } - else -> throw Error("payload too big") - } - } - - private fun apduSelectFile(): ByteArray { - return hexStringToByteArray("00A4040007A0000002471001") - } - - private fun apduPutData(payload: ByteArray): ByteArray { - val stream = ByteArrayOutputStream() - - // Class - stream.write(0x00) - - // Instruction 0xDA = put data - stream.write(0xDA) - - // Instruction parameters - // (proprietary encoding) - stream.write(0x01) - stream.write(0x00) - - writeApduLength(stream, payload.size) - - stream.write(payload) - - return stream.toByteArray() - } - - private fun apduPutTalerData(talerInst: Int, payload: ByteArray): ByteArray { - val realPayload = ByteArrayOutputStream() - realPayload.write(talerInst) - realPayload.write(payload) - return apduPutData(realPayload.toByteArray()) - } - - private fun apduGetData(): ByteArray { - val stream = ByteArrayOutputStream() - - // Class - stream.write(0x00) - - // Instruction 0xCA = get data - stream.write(0xCA) - - // Instruction parameters - // (proprietary encoding) - stream.write(0x01) - stream.write(0x00) - - // Max expected response size, two - // zero bytes denotes 65536 - stream.write(0x0) - stream.write(0x0) - - return stream.toByteArray() - } - -} diff --git a/taler-kotlin-android/src/main/java/net/taler/lib/android/TalerNfcService.kt b/taler-kotlin-android/src/main/java/net/taler/lib/android/TalerNfcService.kt @@ -0,0 +1,313 @@ +/* + * This file is part of GNU Taler + * (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 + * 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.lib.android + +import android.app.Activity +import android.app.Service +import android.content.Context +import android.content.Intent +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter.getDefaultAdapter +import android.nfc.cardemulation.HostApduService +import android.os.Bundle +import android.util.Log +import java.math.BigInteger + +class TalerNfcService : HostApduService() { + + private var uri: String? = null + private val ndefMessage: NdefMessage? + get() = uri?.let { + val record = createUriRecord(it) + NdefMessage(record) + } + + private val ndefUriBytes: ByteArray? + get() = ndefMessage?.toByteArray() + + 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( + commandApdu: ByteArray?, + extras: Bundle? + ): ByteArray { + + Log.d(TAG, "Processing command APDU") + + if (commandApdu == null) { + 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 + } + + // + // 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 + } + + // + // 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 + } + + // + // 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 + } + + 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) + + Log.i(TAG, "NDEF_READ_BINARY_NLEN triggered. Our Response: " + response.toHex()) + + readCapabilityContainerCheck = false + return response + } + + 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 + } + + // + // 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 result.toString() + } + + private fun createUriRecord(uri: String) = NdefRecord.createUri(uri) + + private fun fillByteArrayToFixedDimension(array: ByteArray, fixedSize: Int): ByteArray { + if (array.size == fixedSize) { + return array + } + + 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) + } + + override fun onDestroy() { + super.onDestroy() + Log.i(TAG, "onDestroy() NFC service") + uri = null + } + + 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() + + /** + * Returns true if NFC is supported and false otherwise. + */ + fun hasNfc(context: Context): Boolean { + return getDefaultAdapter(context) != null + } + + fun setUri(activity: Activity, uri: String) { + val intent = Intent(activity, TalerNfcService::class.java) + intent.putExtra("uri", uri) + activity.startService(intent) + } + + fun clearUri(activity: Activity) { + val intent = Intent(activity, TalerNfcService::class.java) + activity.stopService(intent) + } + } +} +\ No newline at end of file diff --git a/wallet/src/main/AndroidManifest.xml b/wallet/src/main/AndroidManifest.xml @@ -81,7 +81,7 @@ tools:replace="screenOrientation" /> <service - android:name=".HostCardEmulatorService" + android:name="net.taler.lib.android.TalerNfcService" android:exported="true" android:permission="android.permission.BIND_NFC_SERVICE"> <intent-filter> diff --git a/wallet/src/main/java/net/taler/wallet/HostCardEmulatorService.kt b/wallet/src/main/java/net/taler/wallet/HostCardEmulatorService.kt @@ -1,298 +0,0 @@ -/* - * This file is part of GNU Taler - * (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 - * 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 - -import android.app.Activity -import android.app.Service -import android.content.Intent -import android.nfc.NdefMessage -import android.nfc.NdefRecord -import android.nfc.cardemulation.HostApduService -import android.os.Bundle -import android.util.Log -import java.math.BigInteger - -class HostCardEmulatorService : HostApduService() { - - private var uri: String? = null - private val ndefMessage: NdefMessage? - get() = uri?.let { - val record = createUriRecord(it) - NdefMessage(record) - } - - private val ndefUriBytes: ByteArray? - get() = ndefMessage?.toByteArray() - - 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( - commandApdu: ByteArray?, - extras: Bundle? - ): ByteArray { - - Log.d(TAG, "Processing command APDU") - - if (commandApdu == null) { - 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 - } - - // - // 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 - } - - // - // 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 - } - - // - // 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 - } - - 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) - - Log.i(TAG, "NDEF_READ_BINARY_NLEN triggered. Our Response: " + response.toHex()) - - readCapabilityContainerCheck = false - return response - } - - 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 - } - - // - // 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 result.toString() - } - - private fun createUriRecord(uri: String) = NdefRecord.createUri(uri) - - private fun fillByteArrayToFixedDimension(array: ByteArray, fixedSize: Int): ByteArray { - if (array.size == fixedSize) { - return array - } - - 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) - } - - 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) - } - - 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 @@ -48,6 +48,7 @@ import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions.QR_CODE import net.taler.common.EventObserver +import net.taler.lib.android.TalerNfcService import net.taler.wallet.BuildConfig.VERSION_CODE import net.taler.wallet.BuildConfig.VERSION_NAME import net.taler.wallet.databinding.ActivityMainBinding @@ -109,7 +110,7 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, handleIntents() model.transactionManager.selectedTransaction.observe(this) { tx -> - HostCardEmulatorService.clearUri(this) + TalerNfcService.clearUri(this) when (tx) { is TransactionPeerPushDebit -> tx.talerUri @@ -117,7 +118,7 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, else -> return@observe }?.let { uri -> Log.d(TAG, "Transaction ${tx.transactionId} selected with URI $uri") - HostCardEmulatorService.setUri(this, uri) + TalerNfcService.setUri(this, uri) } } @@ -234,4 +235,8 @@ class MainActivity : AppCompatActivity(), OnNavigationItemSelectedListener, return true } + override fun onDestroy() { + super.onDestroy() + TalerNfcService.clearUri(this) + } } diff --git a/wallet/src/main/res/xml/apduservice.xml b/wallet/src/main/res/xml/apduservice.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?><!-- ~ 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