diff --git a/gradle.properties b/gradle.properties
index c322e34..baed629 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.2.2-SNAPSHOT
+version = 4.0.0-SNAPSHOT
# Java Configuration
javaSourceLevel = 1.8
diff --git a/integration-test-app/build.gradle.kts b/integration-test-app/build.gradle.kts
new file mode 100644
index 0000000..afebc37
--- /dev/null
+++ b/integration-test-app/build.gradle.kts
@@ -0,0 +1,49 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+}
+
+android {
+ namespace = "org.eclipse.keyple.plugin.android.nfc.it"
+ compileSdk = (project.findProperty("androidCompileSdk") as String).toInt()
+ defaultConfig {
+ applicationId = "org.eclipse.keyple.plugin.android.nfc.it"
+ minSdk = (project.findProperty("androidMinSdk") as String).toInt()
+ targetSdk = (project.findProperty("androidCompileSdk") as String).toInt()
+ versionCode = 1
+ versionName = "1.0"
+ }
+ buildFeatures { viewBinding = true }
+ buildTypes {
+ getByName("release") { isMinifyEnabled = false }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.toVersion(project.findProperty("javaSourceLevel") as String)
+ targetCompatibility = JavaVersion.toVersion(project.findProperty("javaTargetLevel") as String)
+ }
+ kotlinOptions { jvmTarget = project.findProperty("javaTargetLevel") as String }
+ sourceSets { getByName("main").java.srcDirs("src/main/kotlin") }
+ lint { abortOnError = false }
+ packagingOptions {
+ resources.excludes += "META-INF/NOTICE.md"
+ resources.excludes += "META-INF/LICENSE.md"
+ resources.excludes += "META-INF/LICENSE.txt"
+ resources.excludes += "META-INF/NOTICE.txt"
+ }
+}
+
+dependencies {
+ implementation(platform("org.eclipse.keyple:keyple-java-bom:2026.03.19"))
+ implementation(project(":plugin"))
+ implementation("org.eclipse.keyple:keyple-service-java-lib:4.0.0-SNAPSHOT") { isChanging = true }
+ implementation("org.eclipse.keypop:keypop-reader-java-api")
+ implementation("org.eclipse.keyple:keyple-common-java-api")
+ implementation("org.eclipse.keyple:keyple-util-java-lib")
+ implementation(
+ "org.eclipse.keyple:keyple-plugin-java-api:3.0.0-SNAPSHOT") { isChanging = true }
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:1.7.20")
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("com.google.android.material:material:1.9.0")
+ implementation("org.slf4j:slf4j-api:1.7.36")
+ runtimeOnly("org.slf4j:slf4j-android:1.7.36")
+}
diff --git a/integration-test-app/src/main/AndroidManifest.xml b/integration-test-app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a047965
--- /dev/null
+++ b/integration-test-app/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/IntegrationTestActivity.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/IntegrationTestActivity.kt
new file mode 100644
index 0000000..6117098
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/IntegrationTestActivity.kt
@@ -0,0 +1,255 @@
+/* **************************************************************************************
+ * 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.it
+
+import android.graphics.Color
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import android.os.Bundle
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import org.eclipse.keyple.core.service.SmartCardServiceProvider
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcConfig
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcConstants
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcPluginFactoryProvider
+import org.eclipse.keyple.plugin.android.nfc.it.databinding.ActivityIntegrationTestBinding
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.UiLog
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+import org.eclipse.keyple.plugin.android.nfc.it.module.M01_PluginDiscovery
+import org.eclipse.keyple.plugin.android.nfc.it.module.M02_CardPresence
+import org.eclipse.keyple.plugin.android.nfc.it.module.M03_PowerOnData
+import org.eclipse.keyple.plugin.android.nfc.it.module.M04_ProtocolDetection
+import org.eclipse.keyple.plugin.android.nfc.it.module.M05_ApduExchange
+import org.eclipse.keyple.plugin.android.nfc.it.module.M06_CardObservation
+import org.eclipse.keyple.plugin.android.nfc.it.module.M07_CardDeselect
+import org.eclipse.keyple.plugin.android.nfc.it.module.M08_ObservationLifecycle
+import org.eclipse.keyple.plugin.android.nfc.it.module.M09_MifareUltralight
+import org.eclipse.keyple.plugin.android.nfc.it.module.M10_ErrorRecovery
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcSupportedProtocols
+import org.eclipse.keypop.reader.CardReaderEvent
+import org.eclipse.keypop.reader.ObservableCardReader
+import org.eclipse.keypop.reader.spi.CardReaderObservationExceptionHandlerSpi
+import org.eclipse.keypop.reader.spi.CardReaderObserverSpi
+import org.slf4j.LoggerFactory
+
+class IntegrationTestActivity : AppCompatActivity(), CardReaderObserverSpi {
+
+ private val logger = LoggerFactory.getLogger(IntegrationTestActivity::class.java)
+
+ private lateinit var binding: ActivityIntegrationTestBinding
+ private lateinit var ctx: ValidationContext
+ private var scenarioThread: Thread? = null
+
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityIntegrationTestBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ val log = UiLog(this, binding.tvLog, binding.scrollView)
+ ctx = ValidationContext(log, this)
+
+ binding.btnBack.setOnClickListener {
+ scenarioThread?.interrupt()
+ binding.viewFlipper.displayedChild = 0
+ }
+
+ initKeyple(log)
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.adapter = buildAdapter()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ scenarioThread?.interrupt()
+ ctx.stopDetection()
+ try {
+ ctx.observableReader?.removeObserver(this)
+ ctx.service?.unregisterPlugin(AndroidNfcConstants.PLUGIN_NAME)
+ } catch (e: Exception) {
+ // Ignored: unregistering is best-effort during teardown; any exception here is
+ // unrecoverable and should not prevent the activity from being destroyed.
+ logger.debug("Cleanup on destroy failed — ignored: {}", e.message)
+ }
+ }
+
+ // ── Keyple initialization ──────────────────────────────────────────────────
+
+ private fun initKeyple(log: UiLog) {
+ try {
+ val service = SmartCardServiceProvider.getService()
+ ctx.service = service // assign early so onDestroy can clean up even if a later step fails
+ val config = AndroidNfcConfig(activity = this, isPlatformSoundEnabled = false)
+ log.info("Config: sound=${config.isPlatformSoundEnabled}, skipNdef=${config.skipNdefCheck}")
+ val factory = AndroidNfcPluginFactoryProvider.provideFactory(config)
+ val plugin = service.registerPlugin(factory)
+ ctx.plugin = plugin
+ val rawReader = plugin.getReader(AndroidNfcConstants.READER_NAME)
+ logger.info("Reader type: {}", rawReader?.javaClass?.name ?: "null")
+ val reader =
+ rawReader as? ObservableCardReader
+ ?: error("Reader '${AndroidNfcConstants.READER_NAME}' is not an ObservableCardReader (actual type: ${rawReader?.javaClass?.name})")
+ reader.setReaderObservationExceptionHandler(
+ CardReaderObservationExceptionHandlerSpi { pluginName, readerName, e ->
+ logger.error("Reader observation error [{}/{}]: {}", pluginName, readerName, e.message)
+ log.error("Reader error [$pluginName/$readerName]: ${e.message}")
+ })
+ reader.addObserver(this)
+ ctx.observableReader = reader
+ val spi = ctx.getSpi()
+ AndroidNfcSupportedProtocols.values().forEach { protocol -> spi.activateProtocol(protocol.name) }
+ logger.info("Init complete — observableReader={}", reader.javaClass.simpleName)
+ log.info("Plugin '${AndroidNfcConstants.PLUGIN_NAME}' registered")
+ log.info("Reader '${AndroidNfcConstants.READER_NAME}' ready")
+ } catch (e: Exception) {
+ logger.error("initKeyple failed: {}", e.message)
+ log.error("Initialization failed: ${e.message}")
+ }
+ }
+
+ // ── CardReaderObserverSpi ──────────────────────────────────────────────────
+
+ override fun onReaderEvent(event: CardReaderEvent) {
+ when (event.type) {
+ CardReaderEvent.Type.CARD_INSERTED -> {
+ ctx.log.info("← CARD_INSERTED")
+ ctx.notifyCardInserted()
+ }
+ CardReaderEvent.Type.CARD_REMOVED -> {
+ ctx.log.info("← CARD_REMOVED")
+ ctx.notifyCardRemoved()
+ }
+ else -> ctx.log.warn("← event: ${event.type}")
+ }
+ }
+
+ // ── Scenario execution ─────────────────────────────────────────────────────
+
+ private fun runScenario(scenario: Scenario) {
+ binding.viewFlipper.displayedChild = 1
+ binding.tvScenarioTitle.text = "${scenario.id}: ${scenario.title}"
+ if (!ctx.isInitialized) {
+ binding.tvStatus.text = "✗ FAIL"
+ binding.tvStatus.setTextColor(Color.parseColor("#F44336"))
+ // Do NOT clear the log — keep the initialization error visible
+ ctx.log.error("Cannot run scenario: plugin not initialized (see error above)")
+ return
+ }
+ binding.tvStatus.text = "RUNNING"
+ binding.tvStatus.setTextColor(Color.parseColor("#FF9800"))
+ val log = ctx.log
+ log.clear()
+ log.section("${scenario.id} — ${scenario.title}")
+ log.info("Equipment: ${scenario.requiredEquipment}")
+
+ scenarioThread =
+ Thread {
+ val result =
+ try {
+ ctx.resetProtocols()
+ scenario.run(ctx)
+ } catch (_: InterruptedException) {
+ ScenarioResult.skip(scenario.id, "Cancelled")
+ } catch (e: Exception) {
+ ScenarioResult.fail(scenario.id, e.message ?: "Unexpected error")
+ } finally {
+ ctx.stopDetection()
+ }
+ showResult(result)
+ }
+ scenarioThread!!.start()
+ }
+
+ private fun showResult(result: ScenarioResult) {
+ val (label, color) =
+ when (result.status) {
+ ScenarioResult.Status.PASS -> "✓ PASS" to Color.parseColor("#4CAF50")
+ ScenarioResult.Status.FAIL -> "✗ FAIL" to Color.parseColor("#F44336")
+ ScenarioResult.Status.SKIP -> "⊘ SKIP" to Color.parseColor("#FF9800")
+ }
+ ctx.log.info("$label — ${result.message}")
+ runOnUiThread {
+ binding.tvStatus.text = label
+ binding.tvStatus.setTextColor(color)
+ }
+ }
+
+ // ── RecyclerView adapter ───────────────────────────────────────────────────
+
+ private sealed class ListItem {
+ data class Header(val moduleId: String, val moduleTitle: String) : ListItem()
+
+ data class ScenarioItem(val scenario: Scenario) : ListItem()
+ }
+
+ private fun buildAdapter(): RecyclerView.Adapter {
+ val modules: List =
+ listOf(
+ M01_PluginDiscovery(),
+ M02_CardPresence(),
+ M03_PowerOnData(),
+ M04_ProtocolDetection(),
+ M05_ApduExchange(),
+ M06_CardObservation(),
+ M07_CardDeselect(),
+ M08_ObservationLifecycle(),
+ M09_MifareUltralight(),
+ M10_ErrorRecovery(),
+ )
+ val items = mutableListOf()
+ for (m in modules) {
+ items.add(ListItem.Header(m.id, m.title))
+ for (s in m.scenarios) items.add(ListItem.ScenarioItem(s))
+ }
+
+ return object : RecyclerView.Adapter() {
+
+ override fun getItemViewType(position: Int) = if (items[position] is ListItem.Header) 0 else 1
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return if (viewType == 0) {
+ val v = inflater.inflate(R.layout.item_module_header, parent, false)
+ object : RecyclerView.ViewHolder(v) {}
+ } else {
+ val v = inflater.inflate(R.layout.item_scenario, parent, false)
+ object : RecyclerView.ViewHolder(v) {}
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ when (val item = items[position]) {
+ is ListItem.Header -> {
+ holder.itemView.findViewById(R.id.tvModuleTitle).text =
+ "${item.moduleId} — ${item.moduleTitle}"
+ }
+ is ListItem.ScenarioItem -> {
+ val s = item.scenario
+ holder.itemView.findViewById(R.id.tvScenarioId).text = s.id
+ holder.itemView.findViewById(R.id.tvScenarioTitle).text = s.title
+ holder.itemView.findViewById(R.id.tvScenarioEquipment).text =
+ "Equipment: ${s.requiredEquipment}"
+ holder.itemView.setOnClickListener { runScenario(s) }
+ }
+ }
+ }
+
+ override fun getItemCount() = items.size
+ }
+ }
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/AbstractModule.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/AbstractModule.kt
new file mode 100644
index 0000000..82e905f
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/AbstractModule.kt
@@ -0,0 +1,16 @@
+/* **************************************************************************************
+ * 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.it.framework
+
+abstract class AbstractModule(val id: String, val title: String) {
+ abstract val scenarios: List
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/Scenario.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/Scenario.kt
new file mode 100644
index 0000000..9dd3b88
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/Scenario.kt
@@ -0,0 +1,33 @@
+/* **************************************************************************************
+ * 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.it.framework
+
+interface Scenario {
+
+ companion object {
+ const val ERR_NOT_INITIALIZED = "Plugin not initialized"
+ const val ERR_TIMEOUT_NO_CARD = "Timeout: no card detected"
+ }
+
+ val id: String
+ val title: String
+ val requiredEquipment: String
+
+ fun run(ctx: ValidationContext): ScenarioResult
+
+ /**
+ * Returns a FAIL [ScenarioResult] if the plugin is not initialized, null otherwise.
+ * Call at the top of every [run] implementation to guard against missing initialization.
+ */
+ fun requireInitialized(ctx: ValidationContext): ScenarioResult? =
+ if (!ctx.isInitialized) ScenarioResult.fail(id, ERR_NOT_INITIALIZED) else null
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ScenarioResult.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ScenarioResult.kt
new file mode 100644
index 0000000..486a107
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ScenarioResult.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.it.framework
+
+data class ScenarioResult(val id: String, val status: Status, val message: String) {
+
+ enum class Status {
+ PASS,
+ FAIL,
+ SKIP
+ }
+
+ companion object {
+ fun pass(id: String, message: String) = ScenarioResult(id, Status.PASS, message)
+
+ fun fail(id: String, message: String) = ScenarioResult(id, Status.FAIL, message)
+
+ fun skip(id: String, message: String) = ScenarioResult(id, Status.SKIP, message)
+ }
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/UiLog.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/UiLog.kt
new file mode 100644
index 0000000..73421ec
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/UiLog.kt
@@ -0,0 +1,46 @@
+/* **************************************************************************************
+ * 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.it.framework
+
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class UiLog(
+ private val activity: AppCompatActivity,
+ private val tvLog: TextView,
+ private val scrollView: ScrollView
+) {
+
+ private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
+
+ fun info(msg: String) = append(msg)
+
+ fun warn(msg: String) = append("! $msg")
+
+ fun error(msg: String) = append("✗ $msg")
+
+ fun section(title: String) = append("─── $title ───")
+
+ fun clear() = activity.runOnUiThread { tvLog.text = "" }
+
+ private fun append(msg: String) {
+ val line = "[${fmt.format(Date())}] $msg\n"
+ activity.runOnUiThread {
+ tvLog.append(line)
+ scrollView.post { scrollView.fullScroll(ScrollView.FOCUS_DOWN) }
+ }
+ }
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ValidationContext.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ValidationContext.kt
new file mode 100644
index 0000000..ad576e3
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ValidationContext.kt
@@ -0,0 +1,137 @@
+/* **************************************************************************************
+ * 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.it.framework
+
+import androidx.appcompat.app.AppCompatActivity
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import org.eclipse.keyple.core.plugin.spi.reader.ConfigurableReaderSpi
+import org.eclipse.keyple.core.plugin.spi.reader.observable.ObservableReaderSpi
+import org.eclipse.keyple.core.service.Plugin
+import org.eclipse.keyple.core.service.SmartCardService
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcConstants
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcReader
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcSupportedProtocols
+import org.eclipse.keypop.reader.ObservableCardReader
+import org.slf4j.LoggerFactory
+
+/**
+ * Shared state passed to every scenario. Coordinates card events between the UI thread (NFC
+ * callbacks) and scenario threads (blocking waits).
+ */
+class ValidationContext(val log: UiLog, private val activity: AppCompatActivity) {
+
+ private val logger = LoggerFactory.getLogger(ValidationContext::class.java)
+
+ var service: SmartCardService? = null
+ var plugin: Plugin? = null
+ var observableReader: ObservableCardReader? = null
+
+ val isInitialized: Boolean
+ get() = service != null && plugin != null
+
+ private var cardInsertedLatch = CountDownLatch(1)
+ private var cardRemovedLatch = CountDownLatch(1)
+
+ /**
+ * Returns the reader SPI via the plugin's reader extension, cast to [ConfigurableReaderSpi].
+ * Provides access to isCardPresent(), transmitApdu(), getPowerOnData(), activateProtocol(), etc.
+ */
+ fun getSpi(): ConfigurableReaderSpi {
+ val p = plugin ?: error("Plugin not initialized")
+ return p.getReaderExtension(AndroidNfcReader::class.java, AndroidNfcConstants.READER_NAME)
+ as ConfigurableReaderSpi
+ }
+
+ /** Returns the reader SPI cast to [ObservableReaderSpi] for access to deselectCard(). */
+ fun getObsSpi(): ObservableReaderSpi = getSpi() as ObservableReaderSpi
+
+ /**
+ * Reactivates all supported protocols on the reader SPI, restoring a known-good state before
+ * each scenario. Call this at the start of every test to guarantee protocol independence: a test
+ * that called [ConfigurableReaderSpi.deactivateProtocol] (even in a finally block that was
+ * skipped on cancellation) cannot silently corrupt the next test's detection flags.
+ */
+ fun resetProtocols() {
+ if (!isInitialized) return
+ val spi = getSpi()
+ AndroidNfcSupportedProtocols.values().forEach { spi.activateProtocol(it.name) }
+ logger.debug("Protocols reset — all protocols active")
+ }
+
+ /**
+ * Logs [prompt], starts card detection in SINGLESHOT mode, and blocks until a card is detected
+ * or the timeout expires. Returns true if a card was detected, false on timeout.
+ */
+ fun awaitTap(prompt: String = "Tap a card on the reader...", timeoutSec: Long = 30): Boolean {
+ cardInsertedLatch = CountDownLatch(1)
+ log.info(prompt)
+ activity.runOnUiThread {
+ if (observableReader == null) {
+ logger.error("awaitTap: observableReader is null — startCardDetection skipped")
+ log.error("observableReader is null — detection cannot start")
+ } else {
+ try {
+ logger.info("awaitTap: calling startCardDetection(REPEATING) on {}",
+ observableReader!!.javaClass.name)
+ observableReader!!.startCardDetection(ObservableCardReader.DetectionMode.REPEATING)
+ log.info("Detection active (REPEATING, timeout ${timeoutSec}s)")
+ } catch (e: Exception) {
+ logger.error("startCardDetection failed: {} — {}", e::class.java.simpleName, e.message)
+ log.error("startCardDetection failed: ${e.message}")
+ }
+ }
+ }
+ val detected = cardInsertedLatch.await(timeoutSec, TimeUnit.SECONDS)
+ if (!detected) {
+ log.warn("Tap timeout after ${timeoutSec}s — no card detected")
+ } else {
+ // Called from the scenario thread (outside Keyple's event dispatch) so the state machine
+ // can safely transition to WAIT_FOR_CARD_REMOVAL and start polling for tag removal.
+ observableReader?.finalizeCardProcessing()
+ }
+ return detected
+ }
+
+ /**
+ * Blocks until a card removal event is received or the timeout expires. Returns true if removal
+ * was detected.
+ */
+ fun awaitRemoval(
+ prompt: String = "Remove the card from the reader...",
+ timeoutSec: Long = 30
+ ): Boolean {
+ cardRemovedLatch = CountDownLatch(1)
+ log.info(prompt)
+ val removed = cardRemovedLatch.await(timeoutSec, TimeUnit.SECONDS)
+ if (!removed) log.warn("Removal timeout after ${timeoutSec}s — card still present?")
+ return removed
+ }
+
+ /** Stops card detection (safe to call even if detection is not active). */
+ fun stopDetection() {
+ activity.runOnUiThread {
+ try {
+ observableReader?.stopCardDetection()
+ log.info("Detection stopped")
+ } catch (e: Exception) {
+ // Ignored: stopCardDetection() may throw if NFC was already disabled by the OS;
+ // cleanup is best-effort and no recovery is possible here.
+ logger.debug("stopCardDetection() failed during cleanup — ignored: {}", e.message)
+ }
+ }
+ }
+
+ fun notifyCardInserted() = cardInsertedLatch.countDown()
+
+ fun notifyCardRemoved() = cardRemovedLatch.countDown()
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M01_PluginDiscovery.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M01_PluginDiscovery.kt
new file mode 100644
index 0000000..cdb4ce2
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M01_PluginDiscovery.kt
@@ -0,0 +1,71 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcConstants
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario.Companion.ERR_NOT_INITIALIZED
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+
+/** M01 - Plugin and reader discovery (no card required). */
+class M01_PluginDiscovery : AbstractModule("M01", "Plugin Discovery") {
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M01.1"
+ override val title = "Plugin name"
+ override val requiredEquipment = "None"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val name = AndroidNfcConstants.PLUGIN_NAME
+ ctx.log.info("Plugin name: $name")
+ return if (name == "AndroidNfcPlugin") ScenarioResult.pass(id, "Plugin name: $name")
+ else ScenarioResult.fail(id, "Unexpected plugin name: $name")
+ }
+ },
+ object : Scenario {
+ override val id = "M01.2"
+ override val title = "Reader name"
+ override val requiredEquipment = "None"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ val plugin = ctx.plugin ?: return ScenarioResult.fail(id, ERR_NOT_INITIALIZED)
+ val readers = plugin.getReaders()
+ ctx.log.info("Reader count: ${readers.size}")
+ if (readers.isEmpty()) return ScenarioResult.fail(id, "No readers found")
+ val name = readers.first().name
+ ctx.log.info("Reader name: $name")
+ val expected = AndroidNfcConstants.READER_NAME
+ return if (name == expected) ScenarioResult.pass(id, "Reader name: $name")
+ else ScenarioResult.fail(id, "Expected '$expected', got '$name'")
+ }
+ },
+ object : Scenario {
+ override val id = "M01.3"
+ override val title = "Reader is contactless"
+ override val requiredEquipment = "None"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ val plugin = ctx.plugin ?: return ScenarioResult.fail(id, ERR_NOT_INITIALIZED)
+ val reader = plugin.getReaders().firstOrNull() ?: return ScenarioResult.fail(id, "No reader")
+ val contactless = reader.isContactless
+ ctx.log.info("isContactless: $contactless")
+ return if (contactless) ScenarioResult.pass(id, "Reader is contactless")
+ else ScenarioResult.fail(id, "Reader is not contactless")
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M02_CardPresence.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M02_CardPresence.kt
new file mode 100644
index 0000000..cac1d8c
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M02_CardPresence.kt
@@ -0,0 +1,78 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+
+/** M02 - Card presence detection via isCardPresent(). */
+class M02_CardPresence : AbstractModule("M02", "Card Presence") {
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M02.1"
+ override val title = "isCardPresent() requires active monitoring"
+ override val requiredEquipment = "None"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ // isMonitoringActive guard: isCardPresent() must only be called while the NFC
+ // adapter is in reader mode (between onStartDetection and onStopDetection).
+ // Calling it before startCardDetection() must throw IllegalStateException.
+ val spi = ctx.getSpi()
+ return try {
+ spi.isCardPresent()
+ ScenarioResult.fail(id, "Expected IllegalStateException — call succeeded without monitoring")
+ } catch (e: IllegalStateException) {
+ ctx.log.info("isCardPresent() outside monitoring → \"${e.message}\"")
+ ScenarioResult.pass(id, "isCardPresent() correctly rejected outside monitoring")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M02.2"
+ override val title = "isCardPresent() = true after CARD_INSERTED"
+ override val requiredEquipment = "Any NFC card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ if (!ctx.awaitTap()) return ScenarioResult.skip(id, "Timeout: no card detected")
+ val spi = ctx.getSpi()
+ val present = spi.isCardPresent()
+ ctx.log.info("isCardPresent() after tap: $present")
+ ctx.awaitRemoval()
+ return if (present) ScenarioResult.pass(id, "isCardPresent() = true after tap")
+ else ScenarioResult.fail(id, "isCardPresent() = false after tap — unexpected")
+ }
+ },
+ object : Scenario {
+ override val id = "M02.3"
+ override val title = "isCardPresent() = false after card removal"
+ override val requiredEquipment = "Any NFC card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ if (!ctx.awaitTap()) return ScenarioResult.skip(id, "Timeout: no card detected")
+ val spi = ctx.getSpi()
+ ctx.log.info("Card tapped, isCardPresent(): ${spi.isCardPresent()}")
+ if (!ctx.awaitRemoval()) return ScenarioResult.skip(id, "Timeout: card not removed")
+ val present = spi.isCardPresent()
+ ctx.log.info("isCardPresent() after removal: $present")
+ return if (!present) ScenarioResult.pass(id, "isCardPresent() = false after removal")
+ else ScenarioResult.fail(id, "isCardPresent() = true after removal — unexpected")
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M03_PowerOnData.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M03_PowerOnData.kt
new file mode 100644
index 0000000..d278854
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M03_PowerOnData.kt
@@ -0,0 +1,99 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+import org.json.JSONObject
+
+/** M03 - Power-on data: JSON structure returned after card detection. */
+class M03_PowerOnData : AbstractModule("M03", "Power-On Data") {
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M03.1"
+ override val title = "NFC-A card: type, uid, atqa, sak"
+ override val requiredEquipment = "ISO 14443-A card (e.g. MIFARE, ISO 14443-4 A)"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ if (!ctx.awaitTap("Tap an NFC-A card (ISO 14443-A)..."))
+ return ScenarioResult.skip(id, "Timeout: no card detected")
+ val spi = ctx.getSpi()
+ val raw = spi.getPowerOnData()
+ ctx.log.info("powerOnData: $raw")
+ ctx.awaitRemoval()
+ return try {
+ val json = JSONObject(raw)
+ val type = json.getString("type")
+ ctx.log.info(" type: $type")
+ ctx.log.info(" uid: ${json.optString("uid")}")
+ ctx.log.info(" atqa: ${json.optString("atqa")}")
+ ctx.log.info(" sak: ${json.optString("sak")}")
+ if (type == "A" && json.has("uid") && json.has("atqa") && json.has("sak"))
+ ScenarioResult.pass(id, "NFC-A power-on data OK (type=$type)")
+ else ScenarioResult.fail(id, "Missing fields or wrong type: $raw")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, "Invalid JSON: ${e.message}")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M03.2"
+ override val title = "NFC-B card: type, uid, applicationData, protocolInfo"
+ override val requiredEquipment = "ISO 14443-B card (e.g. ID card, ISO 14443-4 B)"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ if (!ctx.awaitTap("Tap an NFC-B card (ISO 14443-B)..."))
+ return ScenarioResult.skip(id, "Timeout: no card detected")
+ val spi = ctx.getSpi()
+ val raw = spi.getPowerOnData()
+ ctx.log.info("powerOnData: $raw")
+ ctx.awaitRemoval()
+ return try {
+ val json = JSONObject(raw)
+ val type = json.getString("type")
+ ctx.log.info(" type: $type")
+ ctx.log.info(" uid: ${json.optString("uid")}")
+ ctx.log.info(" applicationData: ${json.optString("applicationData")}")
+ ctx.log.info(" protocolInfo: ${json.optString("protocolInfo")}")
+ if (type == "B" &&
+ json.has("uid") &&
+ json.has("applicationData") &&
+ json.has("protocolInfo"))
+ ScenarioResult.pass(id, "NFC-B power-on data OK (type=$type)")
+ else ScenarioResult.fail(id, "Missing fields or wrong type: $raw")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, "Invalid JSON: ${e.message}")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M03.3"
+ override val title = "Power-on data is empty before any tap"
+ override val requiredEquipment = "None"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ val raw = spi.getPowerOnData()
+ ctx.log.info("getPowerOnData() without tap: '$raw'")
+ return if (raw.isEmpty()) ScenarioResult.pass(id, "getPowerOnData() = \"\" (no card)")
+ else ScenarioResult.fail(id, "Expected empty string, got: $raw")
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M04_ProtocolDetection.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M04_ProtocolDetection.kt
new file mode 100644
index 0000000..0f1a2dc
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M04_ProtocolDetection.kt
@@ -0,0 +1,103 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcSupportedProtocols
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario.Companion.ERR_TIMEOUT_NO_CARD
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+
+/** M04 - Protocol detection: isCurrentProtocol() for each supported protocol. */
+class M04_ProtocolDetection : AbstractModule("M04", "Protocol Detection") {
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M04.1"
+ override val title = "ISO 14443-4 detected"
+ override val requiredEquipment = "ISO 14443-4 card (A or B)"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ if (!ctx.awaitTap("Tap an ISO 14443-4 card..."))
+ return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ val current = spi.isCurrentProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ ctx.log.info("isCurrentProtocol(ISO_14443_4): $current")
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ return if (current) ScenarioResult.pass(id, "ISO_14443_4 detected correctly")
+ else ScenarioResult.fail(id, "ISO_14443_4 not detected")
+ }
+ },
+ object : Scenario {
+ override val id = "M04.2"
+ override val title = "MIFARE Ultralight detected"
+ override val requiredEquipment = "MIFARE Ultralight tag"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ if (!ctx.awaitTap("Tap a MIFARE Ultralight tag..."))
+ return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ val current = spi.isCurrentProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ ctx.log.info("isCurrentProtocol(MIFARE_ULTRALIGHT): $current")
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ return if (current) ScenarioResult.pass(id, "MIFARE_ULTRALIGHT detected correctly")
+ else ScenarioResult.fail(id, "MIFARE_ULTRALIGHT not detected")
+ }
+ },
+ object : Scenario {
+ override val id = "M04.3"
+ override val title = "MIFARE Classic 1K detected"
+ override val requiredEquipment = "MIFARE Classic 1K card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K.name)
+ if (!ctx.awaitTap("Tap a MIFARE Classic 1K card..."))
+ return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ val current = spi.isCurrentProtocol(AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K.name)
+ ctx.log.info("isCurrentProtocol(MIFARE_CLASSIC_1K): $current")
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K.name)
+ return if (current) ScenarioResult.pass(id, "MIFARE_CLASSIC_1K detected correctly")
+ else ScenarioResult.fail(id, "MIFARE_CLASSIC_1K not detected")
+ }
+ },
+ object : Scenario {
+ override val id = "M04.4"
+ override val title = "MIFARE Classic 4K detected"
+ override val requiredEquipment = "MIFARE Classic 4K card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K.name)
+ if (!ctx.awaitTap("Tap a MIFARE Classic 4K card..."))
+ return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ val current = spi.isCurrentProtocol(AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K.name)
+ ctx.log.info("isCurrentProtocol(MIFARE_CLASSIC_4K): $current")
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K.name)
+ return if (current) ScenarioResult.pass(id, "MIFARE_CLASSIC_4K detected correctly")
+ else ScenarioResult.fail(id, "MIFARE_CLASSIC_4K not detected")
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M05_ApduExchange.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M05_ApduExchange.kt
new file mode 100644
index 0000000..2a6ed24
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M05_ApduExchange.kt
@@ -0,0 +1,119 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.core.util.HexUtil
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcSupportedProtocols
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario.Companion.ERR_TIMEOUT_NO_CARD
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+
+/** M05 - APDU exchange on ISO 14443-4 cards (transmitApdu). */
+class M05_ApduExchange : AbstractModule("M05", "APDU Exchange") {
+
+ private companion object {
+ const val REQUIRED_EQUIPMENT = "ISO 14443-4 card"
+ const val TAP_PROMPT = "Tap an ISO 14443-4 card..."
+ const val ERR_APDU = "APDU failed"
+ }
+
+ private val GET_CHALLENGE = HexUtil.toByteArray("0084000008")
+ private val SELECT_MF = HexUtil.toByteArray("00A4000000")
+
+ private fun sw(resp: ByteArray): String =
+ if (resp.size >= 2) HexUtil.toHex(resp.copyOfRange(resp.size - 2, resp.size)) else "??"
+
+ private fun isOk(resp: ByteArray): Boolean =
+ resp.size >= 2 && resp[resp.size - 2] == 0x90.toByte() && resp[resp.size - 1] == 0x00.toByte()
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M05.1"
+ override val title = "GET CHALLENGE (SW=9000)"
+ override val requiredEquipment = REQUIRED_EQUIPMENT
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ if (!ctx.awaitTap(TAP_PROMPT)) return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ return try {
+ val cmd = GET_CHALLENGE
+ ctx.log.info("CMD: ${HexUtil.toHex(cmd)}")
+ val resp = spi.transmitApdu(cmd)
+ ctx.log.info("RSP: ${HexUtil.toHex(resp)} SW=${sw(resp)}")
+ ctx.getObsSpi().deselectCard()
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ if (isOk(resp)) ScenarioResult.pass(id, "GET CHALLENGE SW=${sw(resp)}")
+ else ScenarioResult.fail(id, "Unexpected SW: ${sw(resp)}")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, e.message ?: ERR_APDU)
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M05.2"
+ override val title = "SELECT Master File (00 A4 00 00)"
+ override val requiredEquipment = REQUIRED_EQUIPMENT
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ if (!ctx.awaitTap(TAP_PROMPT)) return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ return try {
+ val cmd = SELECT_MF
+ ctx.log.info("CMD: ${HexUtil.toHex(cmd)}")
+ val resp = spi.transmitApdu(cmd)
+ ctx.log.info("RSP: ${HexUtil.toHex(resp)} SW=${sw(resp)}")
+ ctx.getObsSpi().deselectCard()
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ ScenarioResult.pass(id, "SELECT MF SW=${sw(resp)}")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, e.message ?: ERR_APDU)
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M05.3"
+ override val title = "Sequential APDUs: 3x GET CHALLENGE"
+ override val requiredEquipment = REQUIRED_EQUIPMENT
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ if (!ctx.awaitTap(TAP_PROMPT)) return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ return try {
+ var ok = 0
+ for (i in 1..3) {
+ val resp = spi.transmitApdu(GET_CHALLENGE)
+ ctx.log.info("APDU $i: SW=${sw(resp)}")
+ if (isOk(resp)) ok++
+ }
+ ctx.getObsSpi().deselectCard()
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ if (ok == 3) ScenarioResult.pass(id, "3/3 sequential APDUs succeeded")
+ else ScenarioResult.fail(id, "$ok/3 APDUs succeeded")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, e.message ?: ERR_APDU)
+ }
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M06_CardObservation.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M06_CardObservation.kt
new file mode 100644
index 0000000..c19c6c6
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M06_CardObservation.kt
@@ -0,0 +1,79 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario.Companion.ERR_TIMEOUT_NO_CARD
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+
+/** M06 - Card observation: CARD_INSERTED and CARD_REMOVED events via CardReaderObserverSpi. */
+class M06_CardObservation : AbstractModule("M06", "Card Observation") {
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M06.1"
+ override val title = "CARD_INSERTED event received on tap"
+ override val requiredEquipment = "Any NFC card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val detected = ctx.awaitTap("Tap a card to trigger CARD_INSERTED event...")
+ return if (detected) {
+ ctx.log.info("CARD_INSERTED event received")
+ ctx.awaitRemoval()
+ ScenarioResult.pass(id, "CARD_INSERTED event received correctly")
+ } else {
+ ScenarioResult.skip(id, "Timeout: CARD_INSERTED event not received")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M06.2"
+ override val title = "CARD_REMOVED event received after card removal"
+ override val requiredEquipment = "Any NFC card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ if (!ctx.awaitTap()) return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ ctx.log.info("CARD_INSERTED received — waiting for card removal...")
+ val removed = ctx.awaitRemoval("Remove the card to trigger CARD_REMOVED event...")
+ return if (removed) ScenarioResult.pass(id, "CARD_REMOVED event received correctly")
+ else ScenarioResult.skip(id, "Timeout: CARD_REMOVED event not received")
+ }
+ },
+ object : Scenario {
+ override val id = "M06.3"
+ override val title = "Two successive insertions in REPEATING mode"
+ override val requiredEquipment = "Any NFC card (tap twice)"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ // First tap
+ if (!ctx.awaitTap("Tap #1: tap a card..."))
+ return ScenarioResult.skip(id, "Timeout on first tap")
+ ctx.log.info("First CARD_INSERTED received")
+ if (!ctx.awaitRemoval("Remove the card after first tap..."))
+ return ScenarioResult.skip(id, "Timeout waiting for first removal")
+ ctx.log.info("First CARD_REMOVED received")
+ // Second tap
+ if (!ctx.awaitTap("Tap #2: tap the card again..."))
+ return ScenarioResult.skip(id, "Timeout on second tap")
+ ctx.log.info("Second CARD_INSERTED received")
+ ctx.awaitRemoval("Remove the card...")
+ return ScenarioResult.pass(id, "Two successive insertions detected correctly")
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M07_CardDeselect.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M07_CardDeselect.kt
new file mode 100644
index 0000000..351337e
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M07_CardDeselect.kt
@@ -0,0 +1,96 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.core.util.HexUtil
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcSupportedProtocols
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario.Companion.ERR_TIMEOUT_NO_CARD
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+
+/** M07 - deselectCard(): no-op contract and channel behavior after deselection. */
+class M07_CardDeselect : AbstractModule("M07", "Card Deselect") {
+
+ private val GET_CHALLENGE = HexUtil.toByteArray("0084000008")
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M07.1"
+ override val title = "deselectCard() does not throw"
+ override val requiredEquipment = "Any NFC card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ if (!ctx.awaitTap()) return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ return try {
+ ctx.getObsSpi().deselectCard()
+ ctx.log.info("deselectCard() completed without exception")
+ ctx.awaitRemoval()
+ ScenarioResult.pass(id, "deselectCard() is a no-op (no exception thrown)")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, "deselectCard() threw: ${e.message}")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M07.2"
+ override val title = "isCardPresent() = true after deselectCard()"
+ override val requiredEquipment = "Any NFC card (keep card on reader)"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ if (!ctx.awaitTap("Tap a card and KEEP it on the reader..."))
+ return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ return try {
+ ctx.getObsSpi().deselectCard()
+ val present = ctx.getSpi().isCardPresent()
+ ctx.log.info("isCardPresent() after deselectCard(): $present")
+ ctx.awaitRemoval()
+ if (present)
+ ScenarioResult.pass(
+ id, "Card still present after deselectCard() — channel preserved")
+ else ScenarioResult.fail(id, "isCardPresent() = false after deselectCard()")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, "Exception: ${e.message}")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M07.3"
+ override val title = "APDU succeeds after deselectCard()"
+ override val requiredEquipment = "ISO 14443-4 card (keep card on reader)"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ if (!ctx.awaitTap("Tap an ISO 14443-4 card and KEEP it on the reader..."))
+ return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ return try {
+ ctx.getObsSpi().deselectCard()
+ ctx.log.info("deselectCard() called")
+ val resp = spi.transmitApdu(GET_CHALLENGE)
+ ctx.log.info("GET CHALLENGE after deselect: ${HexUtil.toHex(resp)}")
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ val sw = HexUtil.toHex(resp.copyOfRange(resp.size - 2, resp.size))
+ ScenarioResult.pass(id, "APDU after deselectCard() OK, SW=$sw")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, "APDU after deselectCard() failed: ${e.message}")
+ }
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M08_ObservationLifecycle.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M08_ObservationLifecycle.kt
new file mode 100644
index 0000000..1a935fb
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M08_ObservationLifecycle.kt
@@ -0,0 +1,85 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario.Companion.ERR_TIMEOUT_NO_CARD
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+
+/** M08 - Observation lifecycle: start/stop detection, verify events are gated accordingly. */
+class M08_ObservationLifecycle : AbstractModule("M08", "Observation Lifecycle") {
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M08.1"
+ override val title = "Card event received when detection is active"
+ override val requiredEquipment = "Any NFC card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ ctx.log.info("Starting detection...")
+ val detected = ctx.awaitTap("Tap a card within 20 seconds...", timeoutSec = 20)
+ return if (detected) {
+ ctx.log.info("CARD_INSERTED event received while detection active — correct")
+ ctx.awaitRemoval()
+ ScenarioResult.pass(id, "Card event received with detection active")
+ } else {
+ ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M08.2"
+ override val title = "Stop detection then restart — event received after restart"
+ override val requiredEquipment = "Any NFC card"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ // Stop detection first (stopDetection() already logs "Detection stopped")
+ ctx.stopDetection()
+ Thread.sleep(500)
+ // Restart and wait for tap
+ ctx.log.info("Restarting detection...")
+ val detected = ctx.awaitTap("Tap a card after detection restart...", timeoutSec = 20)
+ return if (detected) {
+ ctx.awaitRemoval()
+ ScenarioResult.pass(id, "Card detected after detection restart")
+ } else {
+ ScenarioResult.skip(id, "Timeout: no card detected after restart")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M08.3"
+ override val title = "onStartDetection / onStopDetection do not throw"
+ override val requiredEquipment = "None"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ return try {
+ val obsSpi = ctx.getObsSpi()
+ obsSpi.onStartDetection()
+ ctx.log.info("onStartDetection() OK")
+ Thread.sleep(200)
+ obsSpi.onStopDetection()
+ ctx.log.info("onStopDetection() OK")
+ ScenarioResult.pass(id, "onStartDetection/onStopDetection completed without error")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, "Exception: ${e.message}")
+ }
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M09_MifareUltralight.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M09_MifareUltralight.kt
new file mode 100644
index 0000000..74452ed
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M09_MifareUltralight.kt
@@ -0,0 +1,92 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcSupportedProtocols
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+import org.json.JSONObject
+
+/**
+ * M09 - MIFARE Ultralight: protocol detection and UID/power-on data verification.
+ *
+ * Note: direct block read/write requires an ApduInterpreterFactory configured in AndroidNfcConfig.
+ * These scenarios validate the tag is correctly identified and produces valid power-on data.
+ */
+class M09_MifareUltralight : AbstractModule("M09", "MIFARE Ultralight") {
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M09.1"
+ override val title = "MIFARE Ultralight detected and isCurrentProtocol() = true"
+ override val requiredEquipment = "MIFARE Ultralight tag"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ if (!ctx.awaitTap("Tap a MIFARE Ultralight tag..."))
+ return ScenarioResult.skip(id, "Timeout: no tag detected")
+ val current =
+ spi.isCurrentProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ ctx.log.info("isCurrentProtocol(MIFARE_ULTRALIGHT): $current")
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ return if (current) ScenarioResult.pass(id, "MIFARE Ultralight protocol identified")
+ else ScenarioResult.fail(id, "MIFARE_ULTRALIGHT not identified")
+ }
+ },
+ object : Scenario {
+ override val id = "M09.2"
+ override val title = "MIFARE Ultralight UID in power-on data"
+ override val requiredEquipment = "MIFARE Ultralight tag"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ if (!ctx.awaitTap("Tap a MIFARE Ultralight tag..."))
+ return ScenarioResult.skip(id, "Timeout: no tag detected")
+ val raw = spi.getPowerOnData()
+ ctx.log.info("powerOnData: $raw")
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ return try {
+ val json = JSONObject(raw)
+ val uid = json.getString("uid")
+ ctx.log.info("UID: $uid (${uid.length / 2} bytes)")
+ ScenarioResult.pass(id, "UID present in power-on data: $uid")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, "Invalid power-on data: ${e.message}")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M09.3"
+ override val title = "isProtocolSupported(MIFARE_ULTRALIGHT)"
+ override val requiredEquipment = "None"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ val supported =
+ spi.isProtocolSupported(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name)
+ ctx.log.info("isProtocolSupported(MIFARE_ULTRALIGHT): $supported")
+ return if (supported) ScenarioResult.pass(id, "MIFARE_ULTRALIGHT is supported")
+ else ScenarioResult.fail(id, "MIFARE_ULTRALIGHT is not reported as supported")
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M10_ErrorRecovery.kt b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M10_ErrorRecovery.kt
new file mode 100644
index 0000000..499e0ba
--- /dev/null
+++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M10_ErrorRecovery.kt
@@ -0,0 +1,100 @@
+/* **************************************************************************************
+ * 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.it.module
+
+import org.eclipse.keyple.core.plugin.CardIOException
+import org.eclipse.keyple.core.util.HexUtil
+import org.eclipse.keyple.plugin.android.nfc.AndroidNfcSupportedProtocols
+import org.eclipse.keyple.plugin.android.nfc.it.framework.AbstractModule
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario
+import org.eclipse.keyple.plugin.android.nfc.it.framework.Scenario.Companion.ERR_TIMEOUT_NO_CARD
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ScenarioResult
+import org.eclipse.keyple.plugin.android.nfc.it.framework.ValidationContext
+
+/** M10 - Error recovery: CardIOException on removed card, channel cleanup after error. */
+class M10_ErrorRecovery : AbstractModule("M10", "Error Recovery") {
+
+ private val GET_CHALLENGE = HexUtil.toByteArray("0084000008")
+
+ override val scenarios =
+ listOf(
+ object : Scenario {
+ override val id = "M10.1"
+ override val title = "CardIOException when card removed before APDU"
+ override val requiredEquipment = "ISO 14443-4 card (will be removed)"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ if (!ctx.awaitTap("Tap an ISO 14443-4 card..."))
+ return ScenarioResult.skip(id, ERR_TIMEOUT_NO_CARD)
+ ctx.log.info("Card tapped. Remove the card NOW, then wait 2 seconds...")
+ // Wait for the card to be physically removed before sending the APDU
+ if (!ctx.awaitRemoval("Remove the card NOW...", timeoutSec = 15))
+ return ScenarioResult.skip(id, "Timeout: card not removed")
+ Thread.sleep(500)
+ return try {
+ val resp = spi.transmitApdu(GET_CHALLENGE)
+ // If we get here, no exception was thrown — unexpected
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ ScenarioResult.fail(
+ id, "Expected CardIOException but got response: ${HexUtil.toHex(resp)}")
+ } catch (e: CardIOException) {
+ ctx.log.info("CardIOException received: ${e.message}")
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ ScenarioResult.pass(id, "CardIOException thrown on removed card — correct")
+ } catch (e: Exception) {
+ ScenarioResult.fail(
+ id,
+ "Wrong exception type: ${e.javaClass.simpleName} — ${e.message}")
+ }
+ }
+ },
+ object : Scenario {
+ override val id = "M10.2"
+ override val title = "New insertion works after CardIOException"
+ override val requiredEquipment = "ISO 14443-4 card (two taps)"
+
+ override fun run(ctx: ValidationContext): ScenarioResult {
+ requireInitialized(ctx)?.let { return it }
+ val spi = ctx.getSpi()
+ spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ // First tap — simulate error by removing the card
+ if (!ctx.awaitTap("Tap #1: tap a card, then remove it quickly..."))
+ return ScenarioResult.skip(id, "Timeout on first tap")
+ if (!ctx.awaitRemoval("Remove the card quickly...", timeoutSec = 10))
+ return ScenarioResult.skip(id, "Timeout waiting for first removal")
+ // Try APDU to generate CardIOException
+ try {
+ spi.transmitApdu(GET_CHALLENGE)
+ } catch (_: CardIOException) {
+ ctx.log.info("CardIOException as expected on removed card")
+ } catch (e: Exception) {
+ ctx.log.warn("Unexpected exception during APDU probe: ${e.message}")
+ }
+ // Second tap — must work normally
+ if (!ctx.awaitTap("Tap #2: tap the card again..."))
+ return ScenarioResult.skip(id, "Timeout on second tap")
+ return try {
+ val resp = spi.transmitApdu(GET_CHALLENGE)
+ ctx.log.info("APDU after recovery: SW=${HexUtil.toHex(resp.copyOfRange(resp.size - 2, resp.size))}")
+ ctx.awaitRemoval()
+ spi.deactivateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name)
+ ScenarioResult.pass(id, "New insertion after CardIOException works correctly")
+ } catch (e: Exception) {
+ ScenarioResult.fail(id, "APDU failed after recovery: ${e.message}")
+ }
+ }
+ },
+ )
+}
diff --git a/integration-test-app/src/main/res/layout/activity_integration_test.xml b/integration-test-app/src/main/res/layout/activity_integration_test.xml
new file mode 100644
index 0000000..2b39cd8
--- /dev/null
+++ b/integration-test-app/src/main/res/layout/activity_integration_test.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/integration-test-app/src/main/res/layout/item_module_header.xml b/integration-test-app/src/main/res/layout/item_module_header.xml
new file mode 100644
index 0000000..8c89de0
--- /dev/null
+++ b/integration-test-app/src/main/res/layout/item_module_header.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/integration-test-app/src/main/res/layout/item_scenario.xml b/integration-test-app/src/main/res/layout/item_scenario.xml
new file mode 100644
index 0000000..889988f
--- /dev/null
+++ b/integration-test-app/src/main/res/layout/item_scenario.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/integration-test-app/src/main/res/values/strings.xml b/integration-test-app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..47a336d
--- /dev/null
+++ b/integration-test-app/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ NFC Plugin Integration Tests
+ ← Back
+
diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts
index 8b412c5..2ba7ef5 100644
--- a/plugin/build.gradle.kts
+++ b/plugin/build.gradle.kts
@@ -19,7 +19,7 @@ plugins {
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")
+ implementation("org.eclipse.keyple:keyple-plugin-java-api:3.0.0-SNAPSHOT") { isChanging = true }
api("org.eclipse.keyple:keyple-plugin-storagecard-java-api:1.1.0")
implementation("org.eclipse.keyple:keyple-util-java-lib:2.4.1")
compileOnly("org.slf4j:slf4j-api:1.7.36")
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 c2e97fb..88fec78 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
@@ -22,6 +22,8 @@ import android.nfc.tech.TagTechnology
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
import org.eclipse.keyple.core.plugin.CardIOException
import org.eclipse.keyple.core.plugin.CardInsertionWaiterAsynchronousApi
import org.eclipse.keyple.core.plugin.ReaderIOException
@@ -34,8 +36,6 @@ 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.core.util.json.JsonUtil
-import org.eclipse.keyple.plugin.android.nfc.spi.KeyProvider
import org.json.JSONObject
import org.slf4j.LoggerFactory
@@ -54,37 +54,63 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) :
private const val MIFARE_KEY_B = 0x61
}
- private val nfcAdapter: NfcAdapter = NfcAdapter.getDefaultAdapter(config.activity)
- private val options: Bundle
- 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
+ // ── NFC infrastructure ────────────────────────────────────────────────────────────────────────
+ private val nfcAdapter: NfcAdapter =
+ NfcAdapter.getDefaultAdapter(config.activity)
+ ?: throw IllegalStateException(
+ "NFC is not available on this device or is disabled in system settings"
+ )
+ private val readerModeOptions: Bundle =
+ Bundle().apply {
+ if (config.cardInsertionPollingInterval > 0) {
+ putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, config.cardInsertionPollingInterval)
+ }
+ }
+ private val mainThreadHandler = Handler(Looper.getMainLooper())
+
+ // ── Protocol configuration ────────────────────────────────────────────────────────────────────
+ // NFC_A is shared by ISO_14443_4 and all MIFARE variants. Tracking active protocols as a set
+ // and computing flags on demand (rather than incrementally OR/AND-NOT-ing) ensures that
+ // deactivating one protocol never removes a technology bit still needed by another.
+ private val baseFlags: Int = // derived from config (SKIP_NDEF, NO_PLATFORM_SOUNDS); never changes
+ (if (config.skipNdefCheck) NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK else 0) or
+ (if (!config.isPlatformSoundEnabled) NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS else 0)
+ private val activeProtocols = mutableSetOf()
+ private val readerModeFlags: Int
+ get() {
+ var techFlags = 0
+ for (protocol in activeProtocols) {
+ techFlags =
+ techFlags or
+ when (protocol) {
+ AndroidNfcSupportedProtocols.ISO_14443_4 ->
+ NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_A
+ AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT,
+ AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K,
+ AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K -> NfcAdapter.FLAG_READER_NFC_A
+ }
+ }
+ return baseFlags or techFlags
+ }
- private var flags: Int
- private var tagTechnology: TagTechnology? = null
- private var isCardChannelOpen: Boolean = false
+ // ── Monitoring lifecycle ──────────────────────────────────────────────────────────────────────
+ private var isMonitoringActive = false
private var isWaitingForCardRemoval = false
+ private val removalLock = ReentrantLock()
+ private val removalCondition = removalLock.newCondition()
+ private lateinit var insertionCallback: CardInsertionWaiterAsynchronousApi
+
+ // ── Current tag state (reset at each detection cycle start) ──────────────────────────────────
+ @Volatile private var tagTechnology: TagTechnology? = null
+ private var currentTagTechId: String = "" // qualified class name of the active Android NFC tech
+ private var tagUid: ByteArray = ByteArray(0)
+ private var powerOnData: String = ""
- private lateinit var cardInsertionWaiterAsynchronousApi: CardInsertionWaiterAsynchronousApi
- private lateinit var currentCardProtocol: String
- private lateinit var uid: ByteArray
- private lateinit var powerOnData: String
+ // ── Storage card support (optional, MIFARE) ───────────────────────────────────────────────────
+ private val apduInterpreter: ApduInterpreterSpi?
+ private var loadedKey: ByteArray? = null
init {
- flags =
- (if (config.skipNdefCheck) NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK else 0) or
- (if (!config.isPlatformSoundEnabled) NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS else 0)
- options =
- Bundle().apply {
- if (config.cardInsertionPollingInterval > 0) {
- putInt(
- NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY,
- config.cardInsertionPollingInterval,
- )
- }
- }
apduInterpreter =
config.apduInterpreterFactory?.let {
require(it is ApduInterpreterFactorySpi) {
@@ -100,32 +126,20 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) :
override fun getName(): String = AndroidNfcConstants.READER_NAME
- override fun openPhysicalChannel() {
- if (tagTechnology!!.isConnected) {
- if (logger.isDebugEnabled) {
- logger.debug("Card already connected")
- }
- return
- }
- try {
- tagTechnology!!.connect()
- isCardChannelOpen = true
- loadedKey = null
- } catch (e: Exception) {
- throw CardIOException("Failed to open physical channel", e)
+ override fun isCardPresent(): Boolean {
+ check(isMonitoringActive) { "Call to isCardPresent not allowed outside monitoring" }
+ if (tagTechnology == null) return false
+ val present = isTagPresent()
+ if (!present) {
+ tagTechnology = null
}
+ return present
}
- override fun closePhysicalChannel() {
- isCardChannelOpen = false
- }
-
- override fun isPhysicalChannelOpen(): Boolean {
- return isCardChannelOpen
- }
-
- override fun checkCardPresence(): Boolean {
- throw UnsupportedOperationException("checkCardPresence() method is not supported")
+ override fun deselectCard() {
+ // No-op: Android NFC deselection is managed by the NFC subsystem.
+ // Closing tagTechnology here would break removal detection in waitForCardRemoval(),
+ // which relies on isConnected to detect physical tag removal.
}
override fun getPowerOnData() = powerOnData
@@ -152,34 +166,18 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) :
AndroidNfcSupportedProtocols.values().any { it.name == readerProtocol }
override fun activateProtocol(readerProtocol: String) {
- flags =
- flags or
- when (AndroidNfcSupportedProtocols.valueOf(readerProtocol)) {
- AndroidNfcSupportedProtocols.ISO_14443_4 ->
- NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_A
- AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT,
- AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K,
- AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K -> NfcAdapter.FLAG_READER_NFC_A
- }
+ activeProtocols.add(AndroidNfcSupportedProtocols.valueOf(readerProtocol))
}
override fun deactivateProtocol(readerProtocol: String) {
- flags =
- flags and
- when (AndroidNfcSupportedProtocols.valueOf(readerProtocol)) {
- AndroidNfcSupportedProtocols.ISO_14443_4 ->
- (NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_NFC_A).inv()
- AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT,
- AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K,
- AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K -> NfcAdapter.FLAG_READER_NFC_A.inv()
- }
+ activeProtocols.remove(AndroidNfcSupportedProtocols.valueOf(readerProtocol))
}
override fun isCurrentProtocol(readerProtocol: String): Boolean {
val protocol = AndroidNfcSupportedProtocols.valueOf(readerProtocol)
// Check if the technology identifier matches
- if (protocol.androidNfcTechIdentifier != currentCardProtocol) {
+ if (protocol.androidNfcTechIdentifier != currentTagTechId) {
return false
}
@@ -192,9 +190,7 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) :
return when (protocol) {
AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K ->
mifareClassic.size == MifareClassic.SIZE_1K
- AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K ->
- mifareClassic.size == MifareClassic.SIZE_4K
- else -> false
+ else -> mifareClassic.size == MifareClassic.SIZE_4K
}
}
@@ -203,13 +199,15 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) :
override fun onStartDetection() {
if (logger.isDebugEnabled) {
- logger.debug("Starting card detection")
+ logger.debug("Starting card detection [flags=0x{}]", Integer.toHexString(readerModeFlags))
}
+ // Reset per-card state: these fields belong to the connected tag and must be empty
+ // before a new card is tapped so that getPowerOnData() / getUID() are never stale.
+ powerOnData = ""
+ tagUid = ByteArray(0)
try {
- nfcAdapter.enableReaderMode(config.activity, this, flags, options)
- if (logger.isDebugEnabled) {
- logger.debug("Card detection started")
- }
+ nfcAdapter.enableReaderMode(config.activity, this, readerModeFlags, readerModeOptions)
+ isMonitoringActive = true
} catch (e: Exception) {
throw ReaderIOException("Failed to start card detection", e)
}
@@ -219,67 +217,75 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) :
if (logger.isDebugEnabled) {
logger.debug("Stopping card detection")
}
+ // Null out the tag reference *before* disableReaderMode() so that any concurrently-running
+ // tagPresenceChecker on the main thread sees null and short-circuits without calling
+ // isConnected() on the now-invalidated tag (which would throw SecurityException).
+ tagTechnology = null
+ isMonitoringActive = false
try {
nfcAdapter.disableReaderMode(config.activity)
- if (logger.isDebugEnabled) {
- logger.debug("Card detection stopped")
- }
} catch (e: Exception) {
throw ReaderIOException("Failed to stop card detection", e)
}
}
override fun setCallback(callback: CardInsertionWaiterAsynchronousApi) {
- this.cardInsertionWaiterAsynchronousApi = callback
+ insertionCallback = callback
}
override fun waitForCardRemoval() {
if (!isWaitingForCardRemoval) {
if (logger.isDebugEnabled) {
- logger.debug("Waiting for card removal...")
+ logger.debug("Polling for card removal (interval={}ms)", config.cardRemovalPollingInterval)
}
isWaitingForCardRemoval = true
- handler.post(tagPresenceChecker)
- synchronized(syncWaitRemoval) { syncWaitRemoval.wait() }
+ mainThreadHandler.post(tagPresenceChecker)
+ removalLock.withLock { removalCondition.await() }
isWaitingForCardRemoval = false
}
}
override fun stopWaitForCardRemoval() {
+ if (logger.isDebugEnabled) {
+ logger.debug("Removal wait stopped")
+ }
isWaitingForCardRemoval = false
- handler.removeCallbacks(tagPresenceChecker)
- synchronized(syncWaitRemoval) { syncWaitRemoval.notify() }
+ mainThreadHandler.removeCallbacks(tagPresenceChecker)
+ removalLock.withLock { removalCondition.signal() }
}
private val tagPresenceChecker: Runnable by lazy {
Runnable {
if (!isTagPresent()) {
- synchronized(syncWaitRemoval) { syncWaitRemoval.notify() }
+ logger.info("Card removed")
+ removalLock.withLock { removalCondition.signal() }
return@Runnable
}
if (isWaitingForCardRemoval) {
- handler.postDelayed(tagPresenceChecker, config.cardRemovalPollingInterval.toLong())
+ mainThreadHandler.postDelayed(
+ tagPresenceChecker,
+ config.cardRemovalPollingInterval.toLong(),
+ )
}
}
}
- private fun isTagPresent(): Boolean {
- return try {
- tagTechnology?.isConnected == true
- } catch (_: Exception) {
- if (logger.isDebugEnabled) {
- logger.debug("Card removed")
+ private fun isTagPresent(): Boolean =
+ try {
+ tagTechnology?.isConnected == true
+ } catch (_: SecurityException) {
+ // Defensive catch: should not occur because onStopDetection() nulls tagTechnology before
+ // calling disableReaderMode(), but retained as a safety net for any residual race.
+ logger.warn("Unexpected SecurityException in isTagPresent — treating tag as removed")
+ false
}
- false
- }
- }
override fun transmitIsoApdu(apdu: ByteArray): ByteArray {
return (tagTechnology as IsoDep).transceive(apdu)
}
override fun getUID(): ByteArray {
- return uid
+ return tagUid
}
override fun readBlock(blockAddress: Int, length: Int): ByteArray {
@@ -326,9 +332,8 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) :
val usedKey =
key
- ?: checkNotNull(keyProvider) { "No key loaded and no key provider available" }
+ ?: checkNotNull(config.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)
@@ -340,52 +345,63 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) :
}
override fun onTagDiscovered(tag: Tag) {
- if (logger.isDebugEnabled) {
- logger.debug("Card detected [tag={}]", JsonUtil.toJson(tag))
- }
- isCardChannelOpen = false
+ loadedKey = null
try {
for (technology in tag.techList) when (technology) {
IsoDep::class.qualifiedName -> {
- currentCardProtocol = IsoDep::class.qualifiedName!!
+ currentTagTechId = IsoDep::class.qualifiedName!!
tagTechnology = IsoDep.get(tag)
}
MifareUltralight::class.qualifiedName -> {
- currentCardProtocol = MifareUltralight::class.qualifiedName!!
+ currentTagTechId = MifareUltralight::class.qualifiedName!!
tagTechnology = MifareUltralight.get(tag)
}
MifareClassic::class.qualifiedName -> {
- currentCardProtocol = MifareClassic::class.qualifiedName!!
+ currentTagTechId = MifareClassic::class.qualifiedName!!
tagTechnology = MifareClassic.get(tag)
}
NfcA::class.qualifiedName -> {
val tagA = NfcA.get(tag)
- uid = tagA.tag.id
+ tagUid = tagA.tag.id
+ @Suppress("SpellCheckingInspection")
powerOnData =
JSONObject()
.put("type", "A")
- .put("uid", HexUtil.toHex(uid))
+ .put("uid", HexUtil.toHex(tagUid))
.put("atqa", HexUtil.toHex(tagA.atqa))
.put("sak", HexUtil.toHex(tagA.sak))
.toString()
}
NfcB::class.qualifiedName -> {
val tagB = NfcB.get(tag)
- uid = tagB.tag.id
+ tagUid = tagB.tag.id
powerOnData =
JSONObject()
.put("type", "B")
- .put("uid", HexUtil.toHex(uid))
+ .put("uid", HexUtil.toHex(tagUid))
.put("applicationData", HexUtil.toHex(tagB.applicationData))
.put("protocolInfo", HexUtil.toHex(tagB.protocolInfo))
.toString()
}
- else -> logger.warn("unreachable code")
+ else -> {
+ // Ignored: other technologies in the tag's techList (e.g. Ndef, NfcV) are not supported
+ }
+ }
+ val selectedTech = tagTechnology?.let { it::class.java.simpleName } ?: "none"
+ val uidHex = if (tagUid.isNotEmpty()) HexUtil.toHex(tagUid) else "n/a"
+ logger.info("Card detected [tech={}, uid={}]", selectedTech, uidHex)
+ if (logger.isDebugEnabled) {
+ logger.debug("Tag techs: {}", tag.techList.joinToString { it.substringAfterLast('.') })
}
- cardInsertionWaiterAsynchronousApi.onCardInserted()
- } catch (_: NoSuchElementException) {
+ tagTechnology!!.connect()
+ insertionCallback.onCardInserted()
+ } catch (e: Exception) {
tagTechnology = null
- logger.warn("Card technology not supported")
+ logger.warn(
+ "Failed to connect to tag [reason={}, type={}]",
+ e.message,
+ e::class.java.simpleName,
+ )
}
}
}
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
index 24e41f2..05ab104 100644
--- 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
@@ -16,7 +16,7 @@ package org.eclipse.keyple.plugin.android.nfc.spi
*
* @since 3.2.0
*/
-interface KeyProvider {
+fun interface KeyProvider {
/**
* Retrieves the key associated with the given key number.
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e8d383a..c9d0ca5 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,5 +1,6 @@
rootProject.name = "keyple-plugin-android-nfc-java-lib"
include(":plugin")
+include(":integration-test-app")
pluginManagement {
repositories {