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 @@ + + + + + + + + + + + + + + + +