From 20039f710ac29093fb272cf1ed34830d628ee77d Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Mon, 2 Feb 2026 16:49:00 +0100 Subject: [PATCH 1/2] feat: add response time validation for APDU requests --- build.gradle.kts | 16 ++--- .../core/service/AbstractReaderAdapter.java | 14 +++-- .../core/service/ApduResponseAdapter.java | 26 +++++++- .../service/CardSelectionManagerAdapter.java | 2 +- .../DistributedLocalServiceAdapter.java | 12 +++- .../keyple/core/service/InternalDto.java | 7 +++ .../core/service/LocalReaderAdapter.java | 62 +++++++++++++++---- .../service/ObservableLocalReaderAdapter.java | 3 +- .../service/AbstractReaderAdapterTest.java | 12 ++-- 9 files changed, 122 insertions(+), 32 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2237a427..ec1e9b0d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,21 +15,21 @@ plugins { dependencies { implementation("org.eclipse.keypop:keypop-reader-java-api:2.1.0") - implementation("org.eclipse.keypop:keypop-card-java-api:2.0.1") + implementation("org.eclipse.keypop:keypop-card-java-api:2.1.0-SNAPSHOT") implementation("org.eclipse.keyple:keyple-common-java-api:2.0.2") - implementation("org.eclipse.keyple:keyple-plugin-java-api:2.3.1") - implementation("org.eclipse.keyple:keyple-distributed-remote-java-api:3.1.0") - implementation("org.eclipse.keyple:keyple-distributed-local-java-api:2.2.0") + implementation("org.eclipse.keyple:keyple-plugin-java-api:2.3.2") + implementation("org.eclipse.keyple:keyple-distributed-remote-java-api:3.1.1") + implementation("org.eclipse.keyple:keyple-distributed-local-java-api:2.2.1") implementation("org.eclipse.keyple:keyple-util-java-lib:2.4.0") - implementation("com.google.code.gson:gson:2.10.1") + implementation("com.google.code.gson:gson:2.13.2") implementation("org.slf4j:slf4j-api:1.7.32") testImplementation("org.slf4j:slf4j-simple:1.7.32") testImplementation(platform("org.junit:junit-bom:5.10.2")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.vintage:junit-vintage-engine") - testImplementation("org.assertj:assertj-core:3.25.3") - testImplementation("org.mockito:mockito-core:5.11.0") - testImplementation("org.awaitility:awaitility:4.2.1") + testImplementation("org.assertj:assertj-core:3.27.6") + testImplementation("org.mockito:mockito-core:5.21.0") + testImplementation("org.awaitility:awaitility:4.3.0") } /////////////////////////////////////////////////////////////////////////////// diff --git a/src/main/java/org/eclipse/keyple/core/service/AbstractReaderAdapter.java b/src/main/java/org/eclipse/keyple/core/service/AbstractReaderAdapter.java index eabb56a7..77984958 100644 --- a/src/main/java/org/eclipse/keyple/core/service/AbstractReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/core/service/AbstractReaderAdapter.java @@ -105,7 +105,9 @@ final List transmitCardSelectionRequests( List cardSelectionRequests, MultiSelectionProcessing multiSelectionProcessing, ChannelControl channelControl) - throws ReaderBrokenCommunicationException, CardBrokenCommunicationException { + throws ReaderBrokenCommunicationException, + CardBrokenCommunicationException, + UnexpectedResponseTimeException { checkStatus(); @@ -201,7 +203,8 @@ abstract List processCardSelectionRequests( ChannelControl channelControl) throws ReaderBrokenCommunicationException, CardBrokenCommunicationException, - UnexpectedStatusWordException; + UnexpectedStatusWordException, + UnexpectedResponseTimeException; /** * Abstract method performing the actual transmission of the card request. @@ -213,13 +216,15 @@ abstract List processCardSelectionRequests( * @throws CardBrokenCommunicationException if the communication with the card has failed. * @throws UnexpectedStatusWordException If status word verification is enabled in the card * request and the card returned an unexpected code. + * @throws UnexpectedResponseTimeException If the response time exceeds the maximum expected time. * @since 2.0.0 */ abstract CardResponseApi processCardRequest( CardRequestSpi cardRequest, ChannelControl channelControl) throws ReaderBrokenCommunicationException, CardBrokenCommunicationException, - UnexpectedStatusWordException; + UnexpectedStatusWordException, + UnexpectedResponseTimeException; /** * {@inheritDoc} @@ -241,7 +246,8 @@ public final CardResponseApi transmitCardRequest( CardRequestSpi cardRequest, ChannelControl channelControl) throws ReaderBrokenCommunicationException, CardBrokenCommunicationException, - UnexpectedStatusWordException { + UnexpectedStatusWordException, + UnexpectedResponseTimeException { checkStatus(); Assert.getInstance() diff --git a/src/main/java/org/eclipse/keyple/core/service/ApduResponseAdapter.java b/src/main/java/org/eclipse/keyple/core/service/ApduResponseAdapter.java index 16a8b128..3e5405b4 100644 --- a/src/main/java/org/eclipse/keyple/core/service/ApduResponseAdapter.java +++ b/src/main/java/org/eclipse/keyple/core/service/ApduResponseAdapter.java @@ -24,6 +24,7 @@ final class ApduResponseAdapter implements ApduResponseApi { private final byte[] apdu; private final int statusWord; + private final Integer responseTime; /** * Builds an APDU response from an array of bytes from the card, computes the status word. @@ -32,8 +33,21 @@ final class ApduResponseAdapter implements ApduResponseApi { * @since 2.0.0 */ ApduResponseAdapter(byte[] apdu) { + this(apdu, null); + } + + /** + * Builds an APDU response from an array of bytes from the card, computes the status word. + * + * @param apdu An array of at least 2 bytes. + * @param responseTime The response time in milliseconds (can be null). + * @since 3.4.0 + */ + ApduResponseAdapter(byte[] apdu, Integer responseTime) { this.apdu = apdu; - statusWord = ((apdu[apdu.length - 2] & 0x000000FF) << 8) + (apdu[apdu.length - 1] & 0x000000FF); + this.statusWord = + ((apdu[apdu.length - 2] & 0x000000FF) << 8) + (apdu[apdu.length - 1] & 0x000000FF); + this.responseTime = responseTime; } /** @@ -66,6 +80,16 @@ public int getStatusWord() { return statusWord; } + /** + * {@inheritDoc} + * + * @since 3.4.0 + */ + @Override + public Integer getResponseTime() { + return responseTime; + } + /** * Converts the APDU response into a string where the data is encoded in a json format. * diff --git a/src/main/java/org/eclipse/keyple/core/service/CardSelectionManagerAdapter.java b/src/main/java/org/eclipse/keyple/core/service/CardSelectionManagerAdapter.java index 55a5f4c3..10efa565 100644 --- a/src/main/java/org/eclipse/keyple/core/service/CardSelectionManagerAdapter.java +++ b/src/main/java/org/eclipse/keyple/core/service/CardSelectionManagerAdapter.java @@ -250,7 +250,7 @@ public CardSelectionResult processCardSelectionScenario(CardReader reader) { cardSelectors, cardSelectionRequests, multiSelectionProcessing, channelControl); } catch (ReaderBrokenCommunicationException e) { throw new ReaderCommunicationException(e.getMessage(), e); - } catch (CardBrokenCommunicationException e) { + } catch (CardBrokenCommunicationException | UnexpectedResponseTimeException e) { throw new CardCommunicationException(e.getMessage(), e); } diff --git a/src/main/java/org/eclipse/keyple/core/service/DistributedLocalServiceAdapter.java b/src/main/java/org/eclipse/keyple/core/service/DistributedLocalServiceAdapter.java index 58ca74b1..343a5a90 100644 --- a/src/main/java/org/eclipse/keyple/core/service/DistributedLocalServiceAdapter.java +++ b/src/main/java/org/eclipse/keyple/core/service/DistributedLocalServiceAdapter.java @@ -315,11 +315,15 @@ private String execute() { * * @throws CardBrokenCommunicationException If a card communication error occurs. * @throws ReaderBrokenCommunicationException If a reader communication error occurs. + * @throws UnexpectedStatusWordException If an unexpected status word is received. + * @throws UnexpectedResponseTimeException If the response time exceeds the maximum expected + * time. */ private void transmitCardRequest() throws CardBrokenCommunicationException, ReaderBrokenCommunicationException, - UnexpectedStatusWordException { + UnexpectedStatusWordException, + UnexpectedResponseTimeException { // Extract parameters from the message JsonObject params = input.getAsJsonObject(JsonProperty.PARAMETERS.getKey()); @@ -345,9 +349,13 @@ private void transmitCardRequest() * * @throws CardBrokenCommunicationException If a card communication error occurs. * @throws ReaderBrokenCommunicationException If a reader communication error occurs. + * @throws UnexpectedResponseTimeException If the response time exceeds the maximum expected + * time. */ private void transmitCardSelectionRequests() - throws CardBrokenCommunicationException, ReaderBrokenCommunicationException { + throws CardBrokenCommunicationException, + ReaderBrokenCommunicationException, + UnexpectedResponseTimeException { // Extract parameters from the message JsonObject params = input.getAsJsonObject(JsonProperty.PARAMETERS.getKey()); diff --git a/src/main/java/org/eclipse/keyple/core/service/InternalDto.java b/src/main/java/org/eclipse/keyple/core/service/InternalDto.java index 2683e32b..75eedbe5 100644 --- a/src/main/java/org/eclipse/keyple/core/service/InternalDto.java +++ b/src/main/java/org/eclipse/keyple/core/service/InternalDto.java @@ -175,6 +175,7 @@ static class ApduRequest implements ApduRequestSpi { private byte[] apdu; private Set successfulStatusWords; private String info; + private Integer maxExpectedResponseTime; /** * Default constructor. @@ -193,6 +194,7 @@ static class ApduRequest implements ApduRequestSpi { this.apdu = src.getApdu().clone(); this.successfulStatusWords = new HashSet<>(src.getSuccessfulStatusWords()); this.info = src.getInfo(); + this.maxExpectedResponseTime = src.getMaxExpectedResponseTime(); } @Override @@ -210,6 +212,11 @@ public String getInfo() { return info; } + @Override + public Integer getMaxExpectedResponseTime() { + return maxExpectedResponseTime; + } + @Override public String toString() { return "APDU_REQUEST = " + JsonUtil.toJson(this); diff --git a/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java b/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java index a4903d60..de1e9743 100644 --- a/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java @@ -157,7 +157,8 @@ final List processCardSelectionRequests( ChannelControl channelControl) throws ReaderBrokenCommunicationException, CardBrokenCommunicationException, - UnexpectedStatusWordException { + UnexpectedStatusWordException, + UnexpectedResponseTimeException { checkStatus(); @@ -215,7 +216,8 @@ final CardResponseApi processCardRequest( CardRequestSpi cardRequest, ChannelControl channelControl) throws CardBrokenCommunicationException, ReaderBrokenCommunicationException, - UnexpectedStatusWordException { + UnexpectedStatusWordException, + UnexpectedResponseTimeException { checkStatus(); @@ -351,11 +353,14 @@ public final void releaseChannel() throws ReaderBrokenCommunicationException { * @return A not null reference. * @throws ReaderIOException if the communication with the reader has failed. * @throws CardIOException if the communication with the card has failed. + * @throws UnexpectedResponseTimeException if the response time exceeds the maximum expected time. */ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) - throws CardIOException, ReaderIOException { + throws CardIOException, ReaderIOException, UnexpectedResponseTimeException { ApduResponseAdapter apduResponse; + long startTimeNano = System.nanoTime(); + if (logger.isDebugEnabled()) { long timeStamp = System.nanoTime(); long elapsed10ms = (timeStamp - before) / 100000; @@ -367,7 +372,9 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) elapsed10ms / 10.0); } - apduResponse = new ApduResponseAdapter(readerSpi.transmitApdu(apduRequest.getApdu())); + byte[] responseBytes = readerSpi.transmitApdu(apduRequest.getApdu()); + int responseTime = (int) ((System.nanoTime() - startTimeNano) / 1_000_000); + apduResponse = new ApduResponseAdapter(responseBytes, responseTime); if (logger.isDebugEnabled()) { long timeStamp = System.nanoTime(); @@ -386,6 +393,8 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) // RL-SW-61XX.1 // Handle chained responses by accumulating data from multiple GET RESPONSE commands List dataChunks = new ArrayList<>(); + int accumulatedTime = + apduResponse.getResponseTime() != null ? apduResponse.getResponseTime() : 0; // Add initial data if present if (apduResponse.getDataOut().length > 0) { @@ -415,8 +424,11 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) } // Execute APDU directly to avoid recursive status handling - byte[] responseBytes = readerSpi.transmitApdu(getResponseApdu); - apduResponse = new ApduResponseAdapter(responseBytes); + long chainedStartTimeNano = System.nanoTime(); + byte[] chainedResponseBytes = readerSpi.transmitApdu(getResponseApdu); + int chainedResponseTime = (int) ((System.nanoTime() - chainedStartTimeNano) / 1_000_000); + accumulatedTime += chainedResponseTime; + apduResponse = new ApduResponseAdapter(chainedResponseBytes, chainedResponseTime); if (logger.isDebugEnabled()) { long timeStamp = System.nanoTime(); @@ -453,7 +465,7 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) completeApdu[totalLength] = (byte) ((apduResponse.getStatusWord() >> 8) & 0xFF); completeApdu[totalLength + 1] = (byte) (apduResponse.getStatusWord() & 0xFF); - apduResponse = new ApduResponseAdapter(completeApdu); + apduResponse = new ApduResponseAdapter(completeApdu, accumulatedTime); } } else if (apduResponse.getDataOut().length == 0) { @@ -486,6 +498,19 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) } } + // Validate response time against max expected time + Integer maxExpectedTime = apduRequest.getMaxExpectedResponseTime(); + if (maxExpectedTime != null + && apduResponse.getResponseTime() != null + && apduResponse.getResponseTime() > maxExpectedTime) { + throw new UnexpectedResponseTimeException( + new CardResponseAdapter(Collections.singletonList(apduResponse), true), + true, + String.format( + "Response time %d ms exceeded maximum expected time %d ms", + apduResponse.getResponseTime(), maxExpectedTime)); + } + return apduResponse; } @@ -499,6 +524,7 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) * @throws CardBrokenCommunicationException If the communication with the card has failed. * @throws UnexpectedStatusWordException If status word verification is enabled in the card * request and the card returned an unexpected code. + * @throws UnexpectedResponseTimeException If the response time exceeds the maximum expected time. */ private CardSelectionResponseApi processCardSelectionRequest( CardSelector cardSelector, @@ -506,7 +532,8 @@ private CardSelectionResponseApi processCardSelectionRequest( ChannelControl channelControl) throws ReaderBrokenCommunicationException, CardBrokenCommunicationException, - UnexpectedStatusWordException { + UnexpectedStatusWordException, + UnexpectedResponseTimeException { isLogicalChannelOpen = false; @@ -551,10 +578,13 @@ private CardSelectionResponseApi processCardSelectionRequest( * @return A not null {@link SelectionStatus}. * @throws ReaderBrokenCommunicationException If the communication with the reader has failed. * @throws CardBrokenCommunicationException If the communication with the card has failed. + * @throws UnexpectedResponseTimeException If the response time exceeds the maximum expected time. */ private SelectionStatus processSelection( CardSelector cardSelector, CardSelectionRequestSpi cardSelectionRequest) - throws CardBrokenCommunicationException, ReaderBrokenCommunicationException { + throws CardBrokenCommunicationException, + ReaderBrokenCommunicationException, + UnexpectedResponseTimeException { try { // RL-CLA-CHAAUTO.1 String powerOnData; @@ -644,10 +674,13 @@ private boolean checkPowerOnData(String powerOnData, InternalCardSelector cardSe * * @param cardSelector The card selector. * @return A not null {@link ApduResponseApi} containing the FCI. + * @throws CardIOException if the communication with the card has failed. + * @throws ReaderIOException if the communication with the reader has failed. + * @throws UnexpectedResponseTimeException If the response time exceeds the maximum expected time. * @see #processSelection(CardSelector, CardSelectionRequestSpi) */ private ApduResponseAdapter selectByAid(InternalIsoCardSelector cardSelector) - throws CardIOException, ReaderIOException { + throws CardIOException, ReaderIOException, UnexpectedResponseTimeException { ApduResponseAdapter fciResponse; @@ -679,9 +712,10 @@ private ApduResponseAdapter selectByAid(InternalIsoCardSelector cardSelector) * @return A not null {@link ApduResponseApi}. * @throws ReaderIOException if the communication with the reader has failed. * @throws CardIOException if the communication with the card has failed. + * @throws UnexpectedResponseTimeException If the response time exceeds the maximum expected time. */ private ApduResponseAdapter processExplicitAidSelection(InternalIsoCardSelector cardSelector) - throws CardIOException, ReaderIOException { + throws CardIOException, ReaderIOException, UnexpectedResponseTimeException { final byte[] aid = cardSelector.getAid(); if (logger.isDebugEnabled()) { @@ -881,6 +915,12 @@ public String getInfo() { return info; } + @Override + public Integer getMaxExpectedResponseTime() { + // Internal GET RESPONSE commands have no specific time constraint + return null; + } + @Override public String toString() { return "APDU_REQUEST = " + JsonUtil.toJson(this); diff --git a/src/main/java/org/eclipse/keyple/core/service/ObservableLocalReaderAdapter.java b/src/main/java/org/eclipse/keyple/core/service/ObservableLocalReaderAdapter.java index 7c9ec8df..24fe5915 100644 --- a/src/main/java/org/eclipse/keyple/core/service/ObservableLocalReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/core/service/ObservableLocalReaderAdapter.java @@ -22,6 +22,7 @@ import org.eclipse.keypop.card.CardBrokenCommunicationException; import org.eclipse.keypop.card.CardSelectionResponseApi; import org.eclipse.keypop.card.ReaderBrokenCommunicationException; +import org.eclipse.keypop.card.UnexpectedResponseTimeException; import org.eclipse.keypop.reader.CardReaderEvent; import org.eclipse.keypop.reader.ObservableCardReader; import org.eclipse.keypop.reader.ReaderCommunicationException; @@ -289,7 +290,7 @@ final CardReaderEvent processCardInserted() { getName(), new ReaderCommunicationException(READER_MONITORING_ERROR, e)); - } catch (CardBrokenCommunicationException e) { + } catch (CardBrokenCommunicationException | UnexpectedResponseTimeException e) { // The last transmission failed, close the logical and physical channels. closeLogicalAndPhysicalChannelsSilently(); // The card was removed or not read correctly, no exception raising or event notification, diff --git a/src/test/java/org/eclipse/keyple/core/service/AbstractReaderAdapterTest.java b/src/test/java/org/eclipse/keyple/core/service/AbstractReaderAdapterTest.java index ac1652cc..678ec9dc 100644 --- a/src/test/java/org/eclipse/keyple/core/service/AbstractReaderAdapterTest.java +++ b/src/test/java/org/eclipse/keyple/core/service/AbstractReaderAdapterTest.java @@ -70,7 +70,8 @@ public void getExtension_whenReaderIsNotRegistered_shouldISE() { public void transmitCardRequest_whenReaderIsNotRegistered_shouldISE() throws UnexpectedStatusWordException, ReaderBrokenCommunicationException, - CardBrokenCommunicationException { + CardBrokenCommunicationException, + UnexpectedResponseTimeException { readerAdapter.transmitCardRequest(cardRequestSpi, ChannelControl.KEEP_OPEN); } @@ -78,7 +79,8 @@ public void transmitCardRequest_whenReaderIsNotRegistered_shouldISE() public void transmitCardRequest_shouldInvoke_processCardRequest() throws UnexpectedStatusWordException, ReaderBrokenCommunicationException, - CardBrokenCommunicationException { + CardBrokenCommunicationException, + UnexpectedResponseTimeException { readerAdapter = Mockito.spy(readerAdapter); readerAdapter.register(); readerAdapter.transmitCardRequest(cardRequestSpi, ChannelControl.KEEP_OPEN); @@ -100,7 +102,8 @@ List processCardSelectionRequests( ChannelControl channelControl) throws ReaderBrokenCommunicationException, CardBrokenCommunicationException, - UnexpectedStatusWordException { + UnexpectedStatusWordException, + UnexpectedResponseTimeException { return new ArrayList(); } @@ -108,7 +111,8 @@ List processCardSelectionRequests( CardResponseApi processCardRequest(CardRequestSpi cardRequest, ChannelControl channelControl) throws ReaderBrokenCommunicationException, CardBrokenCommunicationException, - UnexpectedStatusWordException { + UnexpectedStatusWordException, + UnexpectedResponseTimeException { return Mockito.mock(CardResponseApi.class); } From fe815e2e42345d4eef1f55efe288a669acf3ff65 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Fortune Date: Tue, 3 Feb 2026 15:52:08 +0100 Subject: [PATCH 2/2] chore(release): bump version to 3.5.0-SNAPSHOT and update deps feat(core): enhance response time handling and tests --- CHANGELOG.md | 9 ++ gradle.properties | 2 +- .../core/service/LocalReaderAdapter.java | 11 +- .../core/service/ApduResponseAdapterTest.java | 65 +++++++++ .../keyple/core/service/InternalDtoTest.java | 126 ++++++++++++++++++ .../LocalReaderAdapterResponseTimeTest.java | 106 +++++++++++++++ .../core/service/LocalReaderAdapterTest.java | 1 + 7 files changed, 312 insertions(+), 8 deletions(-) create mode 100644 src/test/java/org/eclipse/keyple/core/service/InternalDtoTest.java create mode 100644 src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterResponseTimeTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be8c8ba..21fb27f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Upgraded +- Keypop Card API `2.0.1` -> `2.1.0` +- Keyple Plugin API `2.3.1` -> `2.3.2` +- Keyple Distributed Remote API `3.1.0` -> `3.1.1` +- Keyple Distributed Local API `2.2.0` -> `2.2.1` +- Google Gson Library `2.10.1` -> `2.13.2` +- AssertJ `3.25.3` -> `3.27.6` (test dependency) +- Mockito `5.11.0` -> `5.21.0` (test dependency) +- Awaitility `4.2.1` -> `4.3.0` (test dependency) ## [3.4.0] - 2025-11-21 ### Upgraded diff --git a/gradle.properties b/gradle.properties index 4878ab89..47a6c516 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ group = org.eclipse.keyple title = Keyple Service Java Lib description = Keyple core components -version = 3.4.0-SNAPSHOT +version = 3.5.0-SNAPSHOT # Java Configuration javaSourceLevel = 1.8 diff --git a/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java b/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java index de1e9743..81ca6375 100644 --- a/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java @@ -393,8 +393,6 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) // RL-SW-61XX.1 // Handle chained responses by accumulating data from multiple GET RESPONSE commands List dataChunks = new ArrayList<>(); - int accumulatedTime = - apduResponse.getResponseTime() != null ? apduResponse.getResponseTime() : 0; // Add initial data if present if (apduResponse.getDataOut().length > 0) { @@ -424,11 +422,8 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) } // Execute APDU directly to avoid recursive status handling - long chainedStartTimeNano = System.nanoTime(); byte[] chainedResponseBytes = readerSpi.transmitApdu(getResponseApdu); - int chainedResponseTime = (int) ((System.nanoTime() - chainedStartTimeNano) / 1_000_000); - accumulatedTime += chainedResponseTime; - apduResponse = new ApduResponseAdapter(chainedResponseBytes, chainedResponseTime); + apduResponse = new ApduResponseAdapter(chainedResponseBytes); if (logger.isDebugEnabled()) { long timeStamp = System.nanoTime(); @@ -465,7 +460,9 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest) completeApdu[totalLength] = (byte) ((apduResponse.getStatusWord() >> 8) & 0xFF); completeApdu[totalLength + 1] = (byte) (apduResponse.getStatusWord() & 0xFF); - apduResponse = new ApduResponseAdapter(completeApdu, accumulatedTime); + // Calculate total response time from the beginning of the initial APDU + int totalResponseTime = (int) ((System.nanoTime() - startTimeNano) / 1_000_000); + apduResponse = new ApduResponseAdapter(completeApdu, totalResponseTime); } } else if (apduResponse.getDataOut().length == 0) { diff --git a/src/test/java/org/eclipse/keyple/core/service/ApduResponseAdapterTest.java b/src/test/java/org/eclipse/keyple/core/service/ApduResponseAdapterTest.java index e18f5271..902fe18a 100644 --- a/src/test/java/org/eclipse/keyple/core/service/ApduResponseAdapterTest.java +++ b/src/test/java/org/eclipse/keyple/core/service/ApduResponseAdapterTest.java @@ -29,4 +29,69 @@ public void buildApduResponseAdapter() { assertThat(apduResponseAdapter.getStatusWord()).isEqualTo(0x9000); assertThat(apduResponseAdapter.getDataOut()).isEqualTo(HexUtil.toByteArray(HEX_REQUEST_DATA)); } + + @Test + public void buildApduResponseAdapter_withoutResponseTime_shouldReturnNull() { + // Given + byte[] apdu = HexUtil.toByteArray(HEX_REQUEST); + + // When + apduResponseAdapter = new ApduResponseAdapter(apdu); + + // Then + assertThat(apduResponseAdapter.getResponseTime()).isNull(); + } + + @Test + public void buildApduResponseAdapter_withResponseTime_shouldReturnResponseTime() { + // Given + byte[] apdu = HexUtil.toByteArray(HEX_REQUEST); + Integer expectedResponseTime = 150; + + // When + apduResponseAdapter = new ApduResponseAdapter(apdu, expectedResponseTime); + + // Then + assertThat(apduResponseAdapter.getResponseTime()).isEqualTo(150); + assertThat(apduResponseAdapter.getApdu()).isEqualTo(HexUtil.toByteArray(HEX_REQUEST)); + assertThat(apduResponseAdapter.getStatusWord()).isEqualTo(0x9000); + assertThat(apduResponseAdapter.getDataOut()).isEqualTo(HexUtil.toByteArray(HEX_REQUEST_DATA)); + } + + @Test + public void buildApduResponseAdapter_withNullResponseTime_shouldReturnNull() { + // Given + byte[] apdu = HexUtil.toByteArray(HEX_REQUEST); + + // When + apduResponseAdapter = new ApduResponseAdapter(apdu, null); + + // Then + assertThat(apduResponseAdapter.getResponseTime()).isNull(); + } + + @Test + public void buildApduResponseAdapter_withZeroResponseTime_shouldReturnZero() { + // Given + byte[] apdu = HexUtil.toByteArray(HEX_REQUEST); + + // When + apduResponseAdapter = new ApduResponseAdapter(apdu, 0); + + // Then + assertThat(apduResponseAdapter.getResponseTime()).isEqualTo(0); + } + + @Test + public void buildApduResponseAdapter_withLargeResponseTime_shouldReturnValue() { + // Given + byte[] apdu = HexUtil.toByteArray(HEX_REQUEST); + Integer largeResponseTime = 5000; // 5 seconds + + // When + apduResponseAdapter = new ApduResponseAdapter(apdu, largeResponseTime); + + // Then + assertThat(apduResponseAdapter.getResponseTime()).isEqualTo(5000); + } } diff --git a/src/test/java/org/eclipse/keyple/core/service/InternalDtoTest.java b/src/test/java/org/eclipse/keyple/core/service/InternalDtoTest.java new file mode 100644 index 00000000..1a16a57a --- /dev/null +++ b/src/test/java/org/eclipse/keyple/core/service/InternalDtoTest.java @@ -0,0 +1,126 @@ +/* ************************************************************************************** + * 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.core.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import org.eclipse.keyple.core.util.json.JsonUtil; +import org.eclipse.keypop.card.spi.ApduRequestSpi; +import org.junit.Test; + +public class InternalDtoTest { + + @Test + public void apduRequest_withMaxExpectedResponseTime_shouldCopyValue() { + // Given + ApduRequestSpi originalRequest = mock(ApduRequestSpi.class); + when(originalRequest.getApdu()).thenReturn(new byte[] {0x00, (byte) 0xA4, 0x04, 0x00}); + when(originalRequest.getSuccessfulStatusWords()) + .thenReturn(new HashSet<>(Collections.singletonList(0x9000))); + when(originalRequest.getInfo()).thenReturn("Test APDU"); + when(originalRequest.getMaxExpectedResponseTime()).thenReturn(200); + + // When + InternalDto.ApduRequest dto = new InternalDto.ApduRequest(originalRequest); + + // Then + assertThat(dto.getMaxExpectedResponseTime()).isEqualTo(200); + assertThat(dto.getApdu()).isEqualTo(new byte[] {0x00, (byte) 0xA4, 0x04, 0x00}); + assertThat(dto.getInfo()).isEqualTo("Test APDU"); + } + + @Test + public void apduRequest_withNullMaxExpectedResponseTime_shouldReturnNull() { + // Given + ApduRequestSpi originalRequest = mock(ApduRequestSpi.class); + when(originalRequest.getApdu()).thenReturn(new byte[] {0x00, (byte) 0xA4}); + when(originalRequest.getSuccessfulStatusWords()) + .thenReturn(new HashSet<>(Collections.singletonList(0x9000))); + when(originalRequest.getMaxExpectedResponseTime()).thenReturn(null); + + // When + InternalDto.ApduRequest dto = new InternalDto.ApduRequest(originalRequest); + + // Then + assertThat(dto.getMaxExpectedResponseTime()).isNull(); + } + + @Test + public void apduRequest_withZeroMaxExpectedResponseTime_shouldReturnZero() { + // Given + ApduRequestSpi originalRequest = mock(ApduRequestSpi.class); + when(originalRequest.getApdu()).thenReturn(new byte[] {0x00, (byte) 0xA4}); + when(originalRequest.getSuccessfulStatusWords()) + .thenReturn(new HashSet<>(Collections.singletonList(0x9000))); + when(originalRequest.getMaxExpectedResponseTime()).thenReturn(0); + + // When + InternalDto.ApduRequest dto = new InternalDto.ApduRequest(originalRequest); + + // Then + assertThat(dto.getMaxExpectedResponseTime()).isEqualTo(0); + } + + @Test + public void apduRequest_jsonSerialization_shouldPreserveMaxExpectedResponseTime() { + // Given + ApduRequestSpi originalRequest = mock(ApduRequestSpi.class); + when(originalRequest.getApdu()).thenReturn(new byte[] {0x00, (byte) 0xA4, 0x04, 0x00, 0x05}); + when(originalRequest.getSuccessfulStatusWords()) + .thenReturn(new HashSet<>(Arrays.asList(0x9000, 0x6200))); + when(originalRequest.getInfo()).thenReturn("Select AID"); + when(originalRequest.getMaxExpectedResponseTime()).thenReturn(500); + + InternalDto.ApduRequest dto = new InternalDto.ApduRequest(originalRequest); + + // When: Serialize to JSON + String json = JsonUtil.toJson(dto); + + // Then: JSON should contain maxExpectedResponseTime field + assertThat(json).contains("maxExpectedResponseTime"); + // Value is serialized as hex string "01F4" which equals 500 in decimal + assertThat(json).containsAnyOf("500", "01F4"); + } + + @Test + public void apduRequest_jsonSerialization_withNullMaxTime_shouldNotIncludeField() { + // Given + ApduRequestSpi originalRequest = mock(ApduRequestSpi.class); + when(originalRequest.getApdu()).thenReturn(new byte[] {0x00, (byte) 0xA4}); + when(originalRequest.getSuccessfulStatusWords()) + .thenReturn(new HashSet<>(Collections.singletonList(0x9000))); + when(originalRequest.getMaxExpectedResponseTime()).thenReturn(null); + + InternalDto.ApduRequest dto = new InternalDto.ApduRequest(originalRequest); + + // When: Serialize to JSON + String json = JsonUtil.toJson(dto); + + // Then: JSON should still be valid (Gson handles null gracefully) + assertThat(json).isNotNull(); + assertThat(json).isNotEmpty(); + } + + @Test + public void apduRequest_defaultConstructor_shouldHaveNullMaxExpectedResponseTime() { + // When + InternalDto.ApduRequest dto = new InternalDto.ApduRequest(); + + // Then + assertThat(dto.getMaxExpectedResponseTime()).isNull(); + } +} diff --git a/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterResponseTimeTest.java b/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterResponseTimeTest.java new file mode 100644 index 00000000..0e4ed1ce --- /dev/null +++ b/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterResponseTimeTest.java @@ -0,0 +1,106 @@ +/* ************************************************************************************** + * 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.core.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.keyple.core.service.util.PluginAdapterTestUtils.PLUGIN_NAME; +import static org.eclipse.keyple.core.service.util.ReaderAdapterTestUtils.getReaderSpi; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashSet; +import org.eclipse.keyple.core.service.util.ReaderAdapterTestUtils; +import org.eclipse.keyple.core.util.HexUtil; +import org.eclipse.keypop.card.CardResponseApi; +import org.eclipse.keypop.card.ChannelControl; +import org.eclipse.keypop.card.spi.ApduRequestSpi; +import org.eclipse.keypop.card.spi.CardRequestSpi; +import org.junit.Before; +import org.junit.Test; + +public class LocalReaderAdapterResponseTimeTest { + + private ReaderAdapterTestUtils.ReaderSpiMock readerSpi; + private LocalReaderAdapter localReaderAdapter; + private ApduRequestSpi apduRequest; + private CardRequestSpi cardRequest; + + @Before + public void setUp() throws Exception { + readerSpi = getReaderSpi(); + when(readerSpi.isContactless()).thenReturn(true); + when(readerSpi.isPhysicalChannelOpen()).thenReturn(true); + + localReaderAdapter = new LocalReaderAdapter(readerSpi, PLUGIN_NAME); + localReaderAdapter.register(); + + apduRequest = mock(ApduRequestSpi.class); + when(apduRequest.getApdu()).thenReturn(HexUtil.toByteArray("00A4040005AABBCCDDEE00")); + when(apduRequest.getSuccessfulStatusWords()) + .thenReturn(new HashSet<>(Collections.singletonList(0x9000))); + when(apduRequest.getInfo()).thenReturn("Test APDU"); + + cardRequest = mock(CardRequestSpi.class); + when(cardRequest.getApduRequests()).thenReturn(Collections.singletonList(apduRequest)); + when(cardRequest.stopOnUnsuccessfulStatusWord()).thenReturn(false); + } + + @Test + public void processCardRequest_withNullMaxExpectedTime_shouldNotValidate() throws Exception { + // Given: No max time constraint + when(apduRequest.getMaxExpectedResponseTime()).thenReturn(null); + when(readerSpi.transmitApdu(any(byte[].class))).thenReturn(HexUtil.toByteArray("123456789000")); + + // When + CardResponseApi response = + localReaderAdapter.processCardRequest(cardRequest, ChannelControl.KEEP_OPEN); + + // Then: Should succeed without exception + assertThat(response).isNotNull(); + assertThat(response.getApduResponses()).hasSize(1); + } + + @Test + public void processCardRequest_responseTimeIsRecorded() throws Exception { + // Given + when(apduRequest.getMaxExpectedResponseTime()).thenReturn(1000); // 1 second max + when(readerSpi.transmitApdu(any(byte[].class))).thenReturn(HexUtil.toByteArray("123456789000")); + + // When + CardResponseApi response = + localReaderAdapter.processCardRequest(cardRequest, ChannelControl.KEEP_OPEN); + + // Then: Response should have a response time recorded + assertThat(response.getApduResponses()).hasSize(1); + assertThat(response.getApduResponses().get(0).getResponseTime()).isNotNull(); + assertThat(response.getApduResponses().get(0).getResponseTime()).isGreaterThanOrEqualTo(0); + } + + @Test + public void processCardRequest_withVeryHighMaxTime_shouldAlwaysSucceed() throws Exception { + // Given: Very high max time (1 hour) + when(apduRequest.getMaxExpectedResponseTime()).thenReturn(3600000); + when(readerSpi.transmitApdu(any(byte[].class))).thenReturn(HexUtil.toByteArray("123456789000")); + + // When + CardResponseApi response = + localReaderAdapter.processCardRequest(cardRequest, ChannelControl.KEEP_OPEN); + + // Then: Should succeed + assertThat(response).isNotNull(); + assertThat(response.getApduResponses()).hasSize(1); + assertThat(response.getApduResponses().get(0).getResponseTime()) + .isLessThan(3600000); // Much less than 1 hour + } +} diff --git a/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterTest.java b/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterTest.java index b5519fdb..8c2ce479 100644 --- a/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterTest.java +++ b/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterTest.java @@ -53,6 +53,7 @@ public void setUp() throws Exception { apduRequestSpi = mock(ApduRequestSpi.class); when(apduRequestSpi.getSuccessfulStatusWords()) .thenReturn(new HashSet(Collections.singletonList(0x9000))); + when(apduRequestSpi.getMaxExpectedResponseTime()).thenReturn(null); cardRequestSpi = mock(CardRequestSpi.class); when(cardRequestSpi.getApduRequests()).thenReturn(Collections.singletonList(apduRequestSpi)); }