Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6d5d248
feat: add MIFARE Classic support
jeanpierrefortune Jan 13, 2026
4e805a0
feat: update Mifare Classic authentication and key management APIs
jeanpierrefortune Jan 15, 2026
6e0f792
refactor: relocate `KeyProvider` to `spi` package and update dependen…
jeanpierrefortune Jan 15, 2026
e5da033
refactor: rename `keyIndex` to `keyNumber` in `KeyProvider` interface
jeanpierrefortune Jan 15, 2026
e06498f
refactor: simplify key management in `AndroidNfcReaderAdapter`
jeanpierrefortune Jan 15, 2026
6207b80
feat: refactor readBlock and add support for MIFARE Ultralight in And…
jeanpierrefortune Jan 15, 2026
fe98f0b
refactor: remove redundant MIFARE Ultralight parameter validation in …
jeanpierrefortune Jan 15, 2026
d87265c
refactor: rename variables in AndroidNfcReaderAdapter for consistency
jeanpierrefortune Jan 15, 2026
85783e7
refactor: simplify MIFARE read logic in AndroidNfcReaderAdapter
jeanpierrefortune Jan 15, 2026
f07066b
refactor: simplify MIFARE read logic in AndroidNfcReaderAdapter
jeanpierrefortune Jan 15, 2026
8edc0ed
refactor: improve writeBlock logic in AndroidNfcReaderAdapter
jeanpierrefortune Jan 15, 2026
455f6b0
refactor: improve MIFARE Classic authentication and tag discovery
jeanpierrefortune Jan 15, 2026
e37029d
style: format AndroidNfcReaderAdapter.kt
jeanpierrefortune Jan 15, 2026
06e6cbf
feat: add MIFARE Classic support, KeyProvider, and upgrade storage ca…
jeanpierrefortune Jan 15, 2026
20e8310
refactor: distinguish between MIFARE Classic 1K and 4K protocols
jeanpierrefortune Jan 16, 2026
1fd5e6f
refactor: move MIFARE Classic size check from isProtocolSupported to …
jeanpierrefortune Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
17 changes: 16 additions & 1 deletion plugin/src/main/kdoc/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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).
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand All @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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?
}
Loading