From 0973a02fdc92c197dbeb4b104e0fc1d0c79cc18d Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Wed, 13 May 2026 18:25:16 +0200 Subject: [PATCH 1/3] wip --- gradle.properties | 2 +- plugin/build.gradle.kts | 2 +- .../android/nfc/AndroidNfcReaderAdapter.kt | 66 +++++++------------ 3 files changed, 24 insertions(+), 46 deletions(-) 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/plugin/build.gradle.kts b/plugin/build.gradle.kts index 8b412c5..f14f327 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") 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..2464b70 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 @@ -64,13 +64,12 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : private var flags: Int private var tagTechnology: TagTechnology? = null - private var isCardChannelOpen: Boolean = false private var isWaitingForCardRemoval = false private lateinit var cardInsertionWaiterAsynchronousApi: CardInsertionWaiterAsynchronousApi - private lateinit var currentCardProtocol: String - private lateinit var uid: ByteArray - private lateinit var powerOnData: String + private var currentCardProtocol: String = "" + private var uid: ByteArray = ByteArray(0) + private var powerOnData: String = "" init { flags = @@ -100,32 +99,19 @@ 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 { + 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 @@ -192,9 +178,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 } } @@ -263,16 +247,7 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : } } - private fun isTagPresent(): Boolean { - return try { - tagTechnology?.isConnected == true - } catch (_: Exception) { - if (logger.isDebugEnabled) { - logger.debug("Card removed") - } - false - } - } + private fun isTagPresent(): Boolean = tagTechnology?.isConnected == true override fun transmitIsoApdu(apdu: ByteArray): ByteArray { return (tagTechnology as IsoDep).transceive(apdu) @@ -343,7 +318,7 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : 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 -> { @@ -380,12 +355,15 @@ internal class AndroidNfcReaderAdapter(private val config: AndroidNfcConfig) : .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 + } } + tagTechnology!!.connect() cardInsertionWaiterAsynchronousApi.onCardInserted() - } catch (_: NoSuchElementException) { + } catch (e: Exception) { tagTechnology = null - logger.warn("Card technology not supported") + logger.warn("Failed to connect to card technology [reason={}]", e.message) } } } From e92f1279b54ff54473f7c8750aad6a2fb03f1cb8 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Mon, 18 May 2026 14:46:18 +0200 Subject: [PATCH 2/3] wip --- integration-test-app/build.gradle.kts | 49 ++++ .../src/main/AndroidManifest.xml | 23 ++ .../android/nfc/it/IntegrationTestActivity.kt | 212 ++++++++++++++++++ .../nfc/it/framework/AbstractModule.kt | 16 ++ .../android/nfc/it/framework/Scenario.kt | 20 ++ .../nfc/it/framework/ScenarioResult.kt | 29 +++ .../plugin/android/nfc/it/framework/UiLog.kt | 46 ++++ .../nfc/it/framework/ValidationContext.kt | 92 ++++++++ .../nfc/it/module/M01_PluginDiscovery.kt | 70 ++++++ .../android/nfc/it/module/M02_CardPresence.kt | 72 ++++++ .../android/nfc/it/module/M03_PowerOnData.kt | 99 ++++++++ .../nfc/it/module/M04_ProtocolDetection.kt | 102 +++++++++ .../android/nfc/it/module/M05_ApduExchange.kt | 115 ++++++++++ .../nfc/it/module/M06_CardObservation.kt | 78 +++++++ .../android/nfc/it/module/M07_CardDeselect.kt | 95 ++++++++ .../nfc/it/module/M08_ObservationLifecycle.kt | 85 +++++++ .../nfc/it/module/M09_MifareUltralight.kt | 92 ++++++++ .../nfc/it/module/M10_ErrorRecovery.kt | 97 ++++++++ .../res/layout/activity_integration_test.xml | 91 ++++++++ .../main/res/layout/item_module_header.xml | 10 + .../src/main/res/layout/item_scenario.xml | 40 ++++ .../src/main/res/values/strings.xml | 5 + plugin/build.gradle.kts | 2 +- settings.gradle.kts | 1 + 24 files changed, 1540 insertions(+), 1 deletion(-) create mode 100644 integration-test-app/build.gradle.kts create mode 100644 integration-test-app/src/main/AndroidManifest.xml create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/IntegrationTestActivity.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/AbstractModule.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/Scenario.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ScenarioResult.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/UiLog.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ValidationContext.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M01_PluginDiscovery.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M02_CardPresence.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M03_PowerOnData.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M04_ProtocolDetection.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M05_ApduExchange.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M06_CardObservation.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M07_CardDeselect.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M08_ObservationLifecycle.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M09_MifareUltralight.kt create mode 100644 integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M10_ErrorRecovery.kt create mode 100644 integration-test-app/src/main/res/layout/activity_integration_test.xml create mode 100644 integration-test-app/src/main/res/layout/item_module_header.xml create mode 100644 integration-test-app/src/main/res/layout/item_scenario.xml create mode 100644 integration-test-app/src/main/res/values/strings.xml diff --git a/integration-test-app/build.gradle.kts b/integration-test-app/build.gradle.kts new file mode 100644 index 0000000..0bb002b --- /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-nop: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..9b23cab --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/IntegrationTestActivity.kt @@ -0,0 +1,212 @@ +/* ************************************************************************************** + * 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.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.keypop.reader.CardReaderEvent +import org.eclipse.keypop.reader.ObservableCardReader +import org.eclipse.keypop.reader.spi.CardReaderObserverSpi + +class IntegrationTestActivity : AppCompatActivity(), CardReaderObserverSpi { + + 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 (_: Exception) {} + } + + // ── Keyple initialization ────────────────────────────────────────────────── + + private fun initKeyple(log: UiLog) { + try { + val service = SmartCardServiceProvider.getService() + val factory = AndroidNfcPluginFactoryProvider.provideFactory(AndroidNfcConfig(activity = this)) + val plugin = service.registerPlugin(factory) + val reader = plugin.getReader(AndroidNfcConstants.READER_NAME) as ObservableCardReader + reader.addObserver(this) + ctx.service = service + ctx.plugin = plugin + ctx.observableReader = reader + log.info("Plugin '${AndroidNfcConstants.PLUGIN_NAME}' registered") + log.info("Reader '${AndroidNfcConstants.READER_NAME}' ready") + } catch (e: Exception) { + log.error("Initialization failed: ${e.message}") + } + } + + // ── CardReaderObserverSpi ────────────────────────────────────────────────── + + override fun onReaderEvent(event: CardReaderEvent) { + when (event.type) { + CardReaderEvent.Type.CARD_INSERTED -> ctx.notifyCardInserted() + CardReaderEvent.Type.CARD_REMOVED -> ctx.notifyCardRemoved() + else -> {} + } + } + + // ── Scenario execution ───────────────────────────────────────────────────── + + private fun runScenario(scenario: Scenario) { + binding.viewFlipper.displayedChild = 1 + binding.tvScenarioTitle.text = "${scenario.id}: ${scenario.title}" + binding.tvStatus.text = "RUNNING" + val log = ctx.log + log.clear() + log.section("${scenario.id} — ${scenario.title}") + log.info("Equipment: ${scenario.requiredEquipment}") + + scenarioThread = + Thread { + val result = + try { + 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 = + when (result.status) { + ScenarioResult.Status.PASS -> "✓ PASS" + ScenarioResult.Status.FAIL -> "✗ FAIL" + ScenarioResult.Status.SKIP -> "⊘ SKIP" + } + ctx.log.info("$label — ${result.message}") + runOnUiThread { binding.tvStatus.text = label } + } + + // ── 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..a2ed2bf --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/Scenario.kt @@ -0,0 +1,20 @@ +/* ************************************************************************************** + * 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 { + val id: String + val title: String + val requiredEquipment: String + + fun run(ctx: ValidationContext): ScenarioResult +} 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..a27dd6d --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/framework/ValidationContext.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.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.keypop.reader.ObservableCardReader + +/** + * 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) { + + 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 + + /** + * 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 { + observableReader?.startCardDetection(ObservableCardReader.DetectionMode.SINGLESHOT) + } + return cardInsertedLatch.await(timeoutSec, TimeUnit.SECONDS) + } + + /** + * 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) + return cardRemovedLatch.await(timeoutSec, TimeUnit.SECONDS) + } + + /** Stops card detection (safe to call even if detection is not active). */ + fun stopDetection() { + activity.runOnUiThread { + try { + observableReader?.stopCardDetection() + } catch (_: Exception) {} + } + } + + 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..ea5696e --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M01_PluginDiscovery.kt @@ -0,0 +1,70 @@ +/* ************************************************************************************** + * 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.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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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, "Plugin 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, "Plugin 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..a2d581a --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M02_CardPresence.kt @@ -0,0 +1,72 @@ +/* ************************************************************************************** + * 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() = false before any tap" + override val requiredEquipment = "None (keep any card away from the reader)" + + override fun run(ctx: ValidationContext): ScenarioResult { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + val present = spi.isCardPresent() + ctx.log.info("isCardPresent(): $present") + return if (!present) ScenarioResult.pass(id, "isCardPresent() = false — correct") + else ScenarioResult.fail(id, "isCardPresent() = true without tap — unexpected") + } + }, + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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..7536638 --- /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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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..44c7008 --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M04_ProtocolDetection.kt @@ -0,0 +1,102 @@ +/* ************************************************************************************** + * 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 + +/** 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name) + if (!ctx.awaitTap("Tap an ISO 14443-4 card...")) + return ScenarioResult.skip(id, "Timeout: no card detected") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + spi.activateProtocol(AndroidNfcSupportedProtocols.MIFARE_ULTRALIGHT.name) + if (!ctx.awaitTap("Tap a MIFARE Ultralight tag...")) + return ScenarioResult.skip(id, "Timeout: no card 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 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + spi.activateProtocol(AndroidNfcSupportedProtocols.MIFARE_CLASSIC_1K.name) + if (!ctx.awaitTap("Tap a MIFARE Classic 1K card...")) + return ScenarioResult.skip(id, "Timeout: no card detected") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + spi.activateProtocol(AndroidNfcSupportedProtocols.MIFARE_CLASSIC_4K.name) + if (!ctx.awaitTap("Tap a MIFARE Classic 4K card...")) + return ScenarioResult.skip(id, "Timeout: no card detected") + 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..f691a65 --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M05_ApduExchange.kt @@ -0,0 +1,115 @@ +/* ************************************************************************************** + * 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.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 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 = "ISO 14443-4 card" + + override fun run(ctx: ValidationContext): ScenarioResult { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name) + if (!ctx.awaitTap("Tap an ISO 14443-4 card...")) + return ScenarioResult.skip(id, "Timeout: no card detected") + 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 ?: "APDU failed") + } + } + }, + object : Scenario { + override val id = "M05.2" + override val title = "SELECT Master File (00 A4 00 00)" + override val requiredEquipment = "ISO 14443-4 card" + + override fun run(ctx: ValidationContext): ScenarioResult { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name) + if (!ctx.awaitTap("Tap an ISO 14443-4 card...")) + return ScenarioResult.skip(id, "Timeout: no card detected") + 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 ?: "APDU failed") + } + } + }, + object : Scenario { + override val id = "M05.3" + override val title = "Sequential APDUs: 3x GET CHALLENGE" + override val requiredEquipment = "ISO 14443-4 card" + + override fun run(ctx: ValidationContext): ScenarioResult { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name) + if (!ctx.awaitTap("Tap an ISO 14443-4 card...")) + return ScenarioResult.skip(id, "Timeout: no card detected") + 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 ?: "APDU failed") + } + } + }, + ) +} 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..f6382ff --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M06_CardObservation.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 + +/** 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + if (!ctx.awaitTap()) return ScenarioResult.skip(id, "Timeout: no card detected") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + // 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..f20e618 --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M07_CardDeselect.kt @@ -0,0 +1,95 @@ +/* ************************************************************************************** + * 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.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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + if (!ctx.awaitTap()) return ScenarioResult.skip(id, "Timeout: no card detected") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + if (!ctx.awaitTap("Tap a card and KEEP it on the reader...")) + return ScenarioResult.skip(id, "Timeout: no card detected") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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, "Timeout: no card detected") + 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..6be9496 --- /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.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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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, "Timeout: no card detected") + } + } + }, + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + // Stop detection first + ctx.stopDetection() + ctx.log.info("Detection stopped") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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..4d50ba9 --- /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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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..0a45d9d --- /dev/null +++ b/integration-test-app/src/main/kotlin/org/eclipse/keyple/plugin/android/nfc/it/module/M10_ErrorRecovery.kt @@ -0,0 +1,97 @@ +/* ************************************************************************************** + * 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.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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + val spi = ctx.getSpi() + spi.activateProtocol(AndroidNfcSupportedProtocols.ISO_14443_4.name) + if (!ctx.awaitTap("Tap an ISO 14443-4 card...")) + return ScenarioResult.skip(id, "Timeout: no card detected") + 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 { + if (!ctx.isInitialized) return ScenarioResult.fail(id, "Plugin not initialized") + 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 (_: Exception) {} + // 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..0afdd79 --- /dev/null +++ b/integration-test-app/src/main/res/layout/activity_integration_test.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + +