diff --git a/CHANGELOG.md b/CHANGELOG.md index 506a349..ce1993e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added `MIFARE_CLASSIC_1K` and `MIFARE_CLASSIC_4K` support in `AndroidNfcSupportedProtocols`. +- Added `KeyProvider` interface to allow providing authentication keys dynamically. +- Added `keyProvider` property to `AndroidNfcConfig`. +### Upgraded +- `keyple-plugin-storagecard-java-api` `1.0.0` -> `1.1.0` +### Documentation +- Updated overview documentation to clarify configuration and storage card support. ## [3.1.0] - 2025-07-08 ### Added diff --git a/gradle.properties b/gradle.properties index 90a0e53..b42d8f0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ group = org.eclipse.keyple title = Keyple Plugin Android NFC Java Lib description = Keyple add-on to manage Android NFC readers -version = 3.1.1-SNAPSHOT +version = 3.2.0-SNAPSHOT # Java Configuration javaSourceLevel = 1.8 diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index fb88826..00575e2 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -20,7 +20,7 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib:1.7.20") implementation("org.eclipse.keyple:keyple-common-java-api:2.0.2") implementation("org.eclipse.keyple:keyple-plugin-java-api:2.3.2") - api("org.eclipse.keyple:keyple-plugin-storagecard-java-api:1.0.0") + api("org.eclipse.keyple:keyple-plugin-storagecard-java-api:1.1.0-SNAPSHOT") { isChanging = true } implementation("org.eclipse.keyple:keyple-util-java-lib:2.4.0") implementation("org.slf4j:slf4j-api:1.7.32") } diff --git a/plugin/src/main/kdoc/overview.md b/plugin/src/main/kdoc/overview.md index f2ddff9..26be02d 100644 --- a/plugin/src/main/kdoc/overview.md +++ b/plugin/src/main/kdoc/overview.md @@ -12,4 +12,19 @@ package. Since NFC events are managed asynchronously by the Android system, **working without observation is not possible** in this context. Any integration of the Android NFC plugin must therefore be designed -around this event-driven approach. \ No newline at end of file +around this event-driven approach. + +### Configuration & Instantiation + +To create an instance of the plugin, use the [AndroidNfcPluginFactoryProvider] which requires an +[AndroidNfcConfig] object. + +### Storage Card Support + +To support storage cards (such as **Mifare Classic**, **Mifare Ultralight**, etc.), the plugin relies +on the `keyple-plugin-storagecard-java-api`. An implementation of `ApduInterpreterFactory` must be +provided via the [AndroidNfcConfig.apduInterpreterFactory] property. + +This factory is responsible for creating the interpreter that translates standard APDU commands into +specific Android NFC I/O operations (e.g. using `MifareClassic` or `MifareUltralight` Android tech +classes). \ No newline at end of file diff --git a/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcConfig.kt b/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcConfig.kt index 51bcb45..2c41689 100644 --- a/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcConfig.kt +++ b/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcConfig.kt @@ -13,6 +13,7 @@ package org.eclipse.keyple.plugin.android.nfc import android.app.Activity import org.eclipse.keyple.core.plugin.storagecard.ApduInterpreterFactory +import org.eclipse.keyple.plugin.android.nfc.spi.KeyProvider /** * Configuration class holding all the plugin options. @@ -30,6 +31,8 @@ import org.eclipse.keyple.core.plugin.storagecard.ApduInterpreterFactory * behavior (corresponds to EXTRA_READER_PRESENCE_CHECK_DELAY). * @property cardRemovalPollingInterval (optional, default value: `100`) Delay (in milliseconds) for * performing presence checks while waiting for card removal. + * @property keyProvider (optional, default value: `null`) A provider for retrieving keys during + * authentication. * @since 3.0.0 */ data class AndroidNfcConfig( @@ -38,5 +41,6 @@ data class AndroidNfcConfig( val isPlatformSoundEnabled: Boolean = true, val skipNdefCheck: Boolean = true, val cardInsertionPollingInterval: Int = 0, - val cardRemovalPollingInterval: Int = 100 + val cardRemovalPollingInterval: Int = 100, + val keyProvider: KeyProvider? = null ) diff --git a/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcReaderAdapter.kt b/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcReaderAdapter.kt index 132a876..a8fd679 100644 --- a/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcReaderAdapter.kt +++ b/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcReaderAdapter.kt @@ -14,6 +14,7 @@ package org.eclipse.keyple.plugin.android.nfc import android.nfc.NfcAdapter import android.nfc.Tag import android.nfc.tech.IsoDep +import android.nfc.tech.MifareClassic import android.nfc.tech.MifareUltralight import android.nfc.tech.NfcA import android.nfc.tech.NfcB @@ -29,9 +30,11 @@ import org.eclipse.keyple.core.plugin.spi.reader.observable.ObservableReaderSpi import org.eclipse.keyple.core.plugin.spi.reader.observable.state.insertion.CardInsertionWaiterAsynchronousSpi import org.eclipse.keyple.core.plugin.spi.reader.observable.state.removal.CardRemovalWaiterBlockingSpi import org.eclipse.keyple.core.plugin.storagecard.internal.CommandProcessorApi +import org.eclipse.keyple.core.plugin.storagecard.internal.KeyStorageType import org.eclipse.keyple.core.plugin.storagecard.internal.spi.ApduInterpreterFactorySpi import org.eclipse.keyple.core.plugin.storagecard.internal.spi.ApduInterpreterSpi import org.eclipse.keyple.core.util.HexUtil +import org.eclipse.keyple.plugin.android.nfc.spi.KeyProvider import org.json.JSONObject import org.slf4j.LoggerFactory @@ -51,6 +54,8 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : private val handler = Handler(Looper.getMainLooper()) private val syncWaitRemoval = Object() private val apduInterpreter: ApduInterpreterSpi? + private var loadedKey: ByteArray? = null + private val keyProvider: KeyProvider? = config.keyProvider private var flags: Int private var tagTechnology: TagTechnology? = null @@ -64,6 +69,8 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : private companion object { private const val MIFARE_ULTRALIGHT_READ_SIZE = 16 + private const val MIFARE_KEY_A = 0x60 + private const val MIFARE_KEY_B = 0x61 } init { @@ -98,6 +105,7 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : try { tagTechnology!!.connect() isCardChannelOpen = true + loadedKey = null } catch (e: Exception) { throw CardIOException("Error while opening physical channel", e) } @@ -144,7 +152,9 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : when (AndroidNfcSupportedProtocols.valueOf(readerProtocol)) { AndroidNfcSupportedProtocols.ISO_14443_4 -> NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_A - AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT -> NfcAdapter.FLAG_READER_NFC_A + AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT, + AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K, + AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K -> NfcAdapter.FLAG_READER_NFC_A } } @@ -154,13 +164,35 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : when (AndroidNfcSupportedProtocols.valueOf(readerProtocol)) { AndroidNfcSupportedProtocols.ISO_14443_4 -> (NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_A).inv() - AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT -> NfcAdapter.FLAG_READER_NFC_A.inv() + AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT, + AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K, + AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K -> NfcAdapter.FLAG_READER_NFC_A.inv() } } - override fun isCurrentProtocol(readerProtocol: String): Boolean = - AndroidNfcSupportedProtocols.valueOf(readerProtocol).androidNfcTechIdentifier == - currentCardProtocol + override fun isCurrentProtocol(readerProtocol: String): Boolean { + val protocol = AndroidNfcSupportedProtocols.valueOf(readerProtocol) + + // Check if the technology identifier matches + if (protocol.androidNfcTechIdentifier != currentCardProtocol) { + return false + } + + // For MIFARE Classic, check the actual card size to distinguish between 1K and 4K + if (protocol == AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K || + protocol == AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K) { + val mifareClassic = tagTechnology as? MifareClassic ?: return false + return when (protocol) { + AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K -> + mifareClassic.size == MifareClassic.SIZE_1K + AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K -> + mifareClassic.size == MifareClassic.SIZE_4K + else -> false + } + } + + return true + } override fun onStartDetection() { logger.info("{}: start card detection", name) @@ -233,25 +265,59 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : return uid } - override fun readBlock(blockNumber: Int, length: Int): ByteArray { - require(length % MifareUltralight.PAGE_SIZE == 0) { - "Requested length ($length) must be a multiple of PAGE_SIZE (${MifareUltralight.PAGE_SIZE})." - } - require(blockNumber >= 0) { "Block number must be non-negative." } - require(length <= MIFARE_ULTRALIGHT_READ_SIZE) { - "Requested length ($length) exceeds maximum readable size 16 in a single operation." + override fun readBlock(blockAddress: Int, length: Int): ByteArray { + return when (val tech = tagTechnology) { + is MifareClassic -> adjustBufferLength(tech.readBlock(blockAddress), length) + is MifareUltralight -> adjustBufferLength(tech.readPages(blockAddress), length) + else -> + throw UnsupportedOperationException( + "Unsupported tag technology: ${tech?.let { it::class.java.simpleName } ?: "null"}") } - val ultralight = tagTechnology as MifareUltralight - val readData = ultralight.readPages(blockNumber) - return if (length < MIFARE_ULTRALIGHT_READ_SIZE) { - readData.copyOf(length) + } + + private fun adjustBufferLength(data: ByteArray, expectedLength: Int): ByteArray { + return if (expectedLength < data.size) { + data.copyOf(expectedLength) } else { - readData + data } } - override fun writeBlock(blockNumber: Int, data: ByteArray?) { - (tagTechnology as MifareUltralight).writePage(blockNumber, data) + override fun writeBlock(blockAddress: Int, data: ByteArray) { + when (val tech = tagTechnology) { + is MifareClassic -> tech.writeBlock(blockAddress, data) + is MifareUltralight -> tech.writePage(blockAddress, data) + else -> + throw UnsupportedOperationException( + "Unsupported tag technology: ${tech?.let { it::class.java.simpleName } ?: "null"}") + } + } + + override fun loadKey(keyStorageType: KeyStorageType, keyNumber: Int, key: ByteArray) { + loadedKey = key.copyOf() + } + + override fun generalAuthenticate(blockAddress: Int, keyType: Int, keyNumber: Int): Boolean { + val mifareClassic = + tagTechnology as? MifareClassic + ?: throw CardIOException("General Authenticate is only supported for Mifare Classic.") + + val key = loadedKey + loadedKey = null + + val usedKey = + key + ?: checkNotNull(keyProvider) { "No key loaded and no key provider available." } + .getKey(keyNumber) + ?: throw IllegalStateException("No key found for key number: $keyNumber") + + val sectorIndex = mifareClassic.blockToSector(blockAddress) + + return when (keyType) { + MIFARE_KEY_A -> mifareClassic.authenticateSectorWithKeyA(sectorIndex, usedKey) + MIFARE_KEY_B -> mifareClassic.authenticateSectorWithKeyB(sectorIndex, usedKey) + else -> throw IllegalArgumentException("Unsupported key type: 0x${keyType.toString(16)}") + } } override fun onTagDiscovered(tag: Tag) { @@ -267,6 +333,10 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : currentCardProtocol = MifareUltralight::class.qualifiedName!! tagTechnology = MifareUltralight.get(tag) } + MifareClassic::class.qualifiedName -> { + currentCardProtocol = MifareClassic::class.qualifiedName!! + tagTechnology = MifareClassic.get(tag) + } NfcA::class.qualifiedName -> { val tagA = NfcA.get(tag) uid = tagA.tag.id diff --git a/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcSupportedProtocols.kt b/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcSupportedProtocols.kt index 51aaad7..cb0323b 100644 --- a/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcSupportedProtocols.kt +++ b/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/AndroidNfcSupportedProtocols.kt @@ -12,6 +12,7 @@ package org.eclipse.keyple.plugin.android.nfc import android.nfc.tech.IsoDep +import android.nfc.tech.MifareClassic import android.nfc.tech.MifareUltralight /** @@ -33,7 +34,21 @@ enum class AndroidNfcSupportedProtocols(private val techId: String) { * * @since 3.1.0 */ - MIFARE_ULTRALIGHT(MifareUltralight::class.qualifiedName!!); + MIFARE_ULTRALIGHT(MifareUltralight::class.qualifiedName!!), + + /** + * NXP MIFARE Classic 1K protocol. + * + * @since 3.2.0 + */ + MIFARE_CLASSIC_1K(MifareClassic::class.qualifiedName!!), + + /** + * NXP MIFARE Classic 4K protocol. + * + * @since 3.2.0 + */ + MIFARE_CLASSIC_4K(MifareClassic::class.qualifiedName!!); internal val androidNfcTechIdentifier: String get() = techId diff --git a/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/spi/KeyProvider.kt b/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/spi/KeyProvider.kt new file mode 100644 index 0000000..24e41f2 --- /dev/null +++ b/plugin/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/spi/KeyProvider.kt @@ -0,0 +1,29 @@ +/* ************************************************************************************** + * Copyright (c) 2026 Calypso Networks Association https://calypsonet.org/ + * + * See the NOTICE file(s) distributed with this work for additional information + * regarding copyright ownership. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License 2.0 which is available at http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ************************************************************************************** */ +package org.eclipse.keyple.plugin.android.nfc.spi + +/** + * Interface allowing the application to provide authentication keys dynamically. + * + * @since 3.2.0 + */ +interface KeyProvider { + + /** + * Retrieves the key associated with the given key number. + * + * @param keyNumber The number of the key requested. + * @return The key as a byte array, or null if not found. + * @since 3.2.0 + */ + fun getKey(keyNumber: Int): ByteArray? +}