@ledgerhq/device-transport-kit-react-native-hid 0.0.0-web-ble-29-08---20250829104351 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/android/src/main/kotlin/com/ledger/androidtransporthid/TransportHidModule.kt +1 -1
  2. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMain/transport/usb/DefaultAndroidUsbTransport.kt +29 -14
  3. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMain/transport/usb/connection/AndroidUsbApduSender.kt +33 -34
  4. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/deviceconnection/DeviceConnectionStateMachine.kt +100 -31
  5. package/android/src/test/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/deviceconnection/DeviceConnectionStateMachineTest.kt +142 -15
  6. package/lib/cjs/api/bridge/mapper.js +1 -1
  7. package/lib/cjs/api/bridge/mapper.js.map +2 -2
  8. package/lib/cjs/api/bridge/mapper.test.js +1 -1
  9. package/lib/cjs/api/bridge/mapper.test.js.map +2 -2
  10. package/lib/cjs/api/transport/Errors.js +1 -1
  11. package/lib/cjs/api/transport/Errors.js.map +3 -3
  12. package/lib/cjs/api/transport/RNHidTransport.js +1 -1
  13. package/lib/cjs/api/transport/RNHidTransport.js.map +2 -2
  14. package/lib/cjs/api/transport/RNHidTransport.test.js +1 -1
  15. package/lib/cjs/api/transport/RNHidTransport.test.js.map +2 -2
  16. package/lib/cjs/package.json +1 -1
  17. package/lib/esm/api/bridge/mapper.js +1 -1
  18. package/lib/esm/api/bridge/mapper.js.map +3 -3
  19. package/lib/esm/api/bridge/mapper.test.js +1 -1
  20. package/lib/esm/api/bridge/mapper.test.js.map +3 -3
  21. package/lib/esm/api/transport/Errors.js +1 -1
  22. package/lib/esm/api/transport/Errors.js.map +3 -3
  23. package/lib/esm/api/transport/RNHidTransport.js +1 -1
  24. package/lib/esm/api/transport/RNHidTransport.js.map +3 -3
  25. package/lib/esm/api/transport/RNHidTransport.test.js +1 -1
  26. package/lib/esm/api/transport/RNHidTransport.test.js.map +3 -3
  27. package/lib/esm/package.json +1 -1
  28. package/lib/types/api/bridge/mapper.d.ts.map +1 -1
  29. package/lib/types/api/transport/Errors.d.ts +1 -1
  30. package/lib/types/api/transport/Errors.d.ts.map +1 -1
  31. package/lib/types/tsconfig.prod.tsbuildinfo +1 -1
  32. package/package.json +4 -4
@@ -49,7 +49,7 @@ class TransportHidModule(
49
49
  private val loggerService: LoggerService =
50
50
  LoggerService { info ->
51
51
  Timber.tag("LDMKTransportHIDModule " + info.tag).d(info.message)
52
- sendEvent(reactContext, BridgeEvents.TransportLog(info))
52
+ // sendEvent(reactContext, BridgeEvents.TransportLog(info))
53
53
  }
54
54
 
55
55
  private val transport: AndroidUsbTransport? by lazy {
@@ -123,9 +123,10 @@ internal class DefaultAndroidUsbTransport(
123
123
  "Device disconnected (sessionId=${deviceConnection.sessionId})"
124
124
  )
125
125
  )
126
- deviceConnection.handleDeviceDisconnected()
127
126
  usbConnections.remove(key)
128
127
  usbConnectionsPendingReconnection.add(deviceConnection)
128
+ deviceConnection.handleDeviceDisconnected()
129
+ (deviceConnection.getApduSender() as AndroidUsbApduSender).release()
129
130
  }
130
131
  }
131
132
  }
@@ -186,19 +187,30 @@ internal class DefaultAndroidUsbTransport(
186
187
  "Reconnecting device (sessionId=${deviceConnection.sessionId})"
187
188
  )
188
189
  )
189
- deviceConnection.handleDeviceConnected(
190
- AndroidUsbApduSender(
191
- dependencies = AndroidUsbApduSender.Dependencies(
192
- usbDevice = usbDevice,
193
- ledgerUsbDevice = state.ledgerUsbDevice,
194
- ),
195
- usbManager = usbManager,
196
- ioDispatcher = Dispatchers.IO,
197
- framerService = FramerService(loggerService),
198
- request = UsbRequest(),
199
- loggerService = loggerService
200
- )
190
+
191
+ val apduSender = AndroidUsbApduSender(
192
+ dependencies = AndroidUsbApduSender.Dependencies(
193
+ usbDevice = usbDevice,
194
+ ledgerUsbDevice = state.ledgerUsbDevice,
195
+ ),
196
+ usbManager = usbManager,
197
+ ioDispatcher = Dispatchers.IO,
198
+ framerService = FramerService(loggerService),
199
+ request = UsbRequest(),
200
+ loggerService = loggerService
201
201
  )
202
+
203
+ if (!usbConnectionsPendingReconnection.contains(deviceConnection)) {
204
+ /**
205
+ * We check this because maybe by the time we get here,
206
+ * the reconnection has timed out and the session has been terminated.
207
+ * Easy to reproduce for instance if the permission request requires
208
+ * a user interaction.
209
+ */
210
+ apduSender.release()
211
+ return@launch
212
+ }
213
+ deviceConnection.handleDeviceConnected(apduSender)
202
214
  usbConnectionsPendingReconnection.remove(deviceConnection)
203
215
  usbConnections[deviceConnection.sessionId] = deviceConnection
204
216
  }
@@ -304,9 +316,10 @@ internal class DefaultAndroidUsbTransport(
304
316
  val deviceConnection = DeviceConnection(
305
317
  sessionId = sessionId,
306
318
  deviceApduSender = apduSender,
307
- isFatalSendApduFailure = { false }, // TODO: refine this
319
+ isFatalSendApduFailure = { false },
308
320
  reconnectionTimeoutDuration = 5.seconds,
309
321
  onTerminated = {
322
+ (it.getApduSender() as AndroidUsbApduSender).release()
310
323
  usbConnections.remove(sessionId)
311
324
  usbConnectionsPendingReconnection.remove(it)
312
325
  eventDispatcher.dispatch(TransportEvent.DeviceConnectionLost(sessionId))
@@ -333,7 +346,9 @@ internal class DefaultAndroidUsbTransport(
333
346
  }
334
347
 
335
348
  override suspend fun disconnect(deviceId: String) {
349
+ // The DeviceConnection is either in usbConnections or usbConnectionsPendingReconnection
336
350
  usbConnections[deviceId]?.requestCloseConnection()
351
+ usbConnectionsPendingReconnection.find { it.sessionId == deviceId }?.requestCloseConnection()
337
352
  }
338
353
 
339
354
  private fun generateSessionId(usbDevice: UsbDevice): String = "usb_${usbDevice.deviceId}"
@@ -22,7 +22,6 @@ import com.ledger.devicesdk.shared.internal.service.logger.buildSimpleErrorLogIn
22
22
  import com.ledger.devicesdk.shared.internal.transport.framer.FramerService
23
23
  import com.ledger.devicesdk.shared.internal.transport.framer.to2BytesArray
24
24
  import kotlinx.coroutines.CoroutineDispatcher
25
- import kotlinx.coroutines.cancel
26
25
  import kotlinx.coroutines.delay
27
26
  import kotlinx.coroutines.launch
28
27
  import kotlinx.coroutines.withContext
@@ -36,7 +35,7 @@ private const val DEFAULT_USB_INTERFACE = 0
36
35
 
37
36
  internal class AndroidUsbApduSender(
38
37
  override val dependencies: Dependencies,
39
- private val usbManager: UsbManager,
38
+ usbManager: UsbManager,
40
39
  private val framerService: FramerService,
41
40
  private val request: UsbRequest,
42
41
  private val ioDispatcher: CoroutineDispatcher,
@@ -47,49 +46,48 @@ internal class AndroidUsbApduSender(
47
46
  val ledgerUsbDevice: LedgerUsbDevice,
48
47
  )
49
48
 
49
+ private val usbDevice = dependencies.usbDevice
50
+ private val usbInterface = usbDevice.getInterface(DEFAULT_USB_INTERFACE)
51
+ private val androidToUsbEndpoint =
52
+ usbInterface.firstEndpointOrThrow { it == UsbConstants.USB_DIR_OUT }
53
+ private val usbToAndroidEndpoint =
54
+ usbInterface.firstEndpointOrThrow { it == UsbConstants.USB_DIR_IN }
55
+ private val usbConnection = usbManager.openDevice(usbDevice)
56
+ .apply { claimInterface(usbInterface, true) }
57
+
58
+
59
+ fun release() {
60
+ usbConnection.releaseInterface(usbInterface)
61
+ usbConnection.close()
62
+ }
63
+
50
64
  override suspend fun send(apdu: ByteArray, abortTimeoutDuration: Duration): SendApduResult =
51
65
  try {
52
- val usbDevice = dependencies.usbDevice
53
66
  withContext(context = ioDispatcher) {
54
- val usbInterface = usbDevice.getInterface(DEFAULT_USB_INTERFACE)
55
- val androidToUsbEndpoint =
56
- usbInterface.firstEndpointOrThrow { it == UsbConstants.USB_DIR_OUT }
57
- val usbToAndroidEndpoint =
58
- usbInterface.firstEndpointOrThrow { it == UsbConstants.USB_DIR_IN }
59
- val usbConnection = usbManager.openDevice(usbDevice)
60
- .apply { claimInterface(usbInterface, true) }
61
67
 
62
68
  val timeoutJob = launch {
63
69
  delay(abortTimeoutDuration)
64
- usbConnection.releaseInterface(usbInterface)
65
- usbConnection.close()
66
70
  throw SendApduTimeoutException
67
71
  }
68
72
 
69
- try {
73
+ transmitApdu(
74
+ usbConnection = usbConnection,
75
+ androidToUsbEndpoint = androidToUsbEndpoint,
76
+ rawApdu = apdu,
77
+ )
70
78
 
71
- transmitApdu(
79
+ val apduResponse =
80
+ receiveApdu(
72
81
  usbConnection = usbConnection,
73
- androidToUsbEndpoint = androidToUsbEndpoint,
74
- rawApdu = apdu,
82
+ usbToAndroidEndpoint = usbToAndroidEndpoint,
75
83
  )
84
+ timeoutJob.cancel()
76
85
 
77
- val apduResponse =
78
- receiveApdu(
79
- usbConnection = usbConnection,
80
- usbToAndroidEndpoint = usbToAndroidEndpoint,
81
- )
82
- timeoutJob.cancel()
83
-
84
- if (apduResponse.isEmpty()) {
85
- return@withContext SendApduResult.Failure(reason = SendApduFailureReason.EmptyResponse)
86
- }
87
-
88
- return@withContext SendApduResult.Success(apdu = apduResponse)
89
- } finally {
90
- usbConnection.releaseInterface(usbInterface)
91
- usbConnection.close()
86
+ if (apduResponse.isEmpty()) {
87
+ return@withContext SendApduResult.Failure(reason = SendApduFailureReason.EmptyResponse)
92
88
  }
89
+
90
+ return@withContext SendApduResult.Success(apdu = apduResponse)
93
91
  }
94
92
  } catch (e: SendApduTimeoutException) {
95
93
  loggerService.log(
@@ -132,8 +130,8 @@ internal class AndroidUsbApduSender(
132
130
  private fun receiveApdu(
133
131
  usbConnection: UsbDeviceConnection,
134
132
  usbToAndroidEndpoint: UsbEndpoint,
135
- ): ByteArray =
136
- if (!request.initialize(usbConnection, usbToAndroidEndpoint)) {
133
+ ): ByteArray {
134
+ return if (!request.initialize(usbConnection, usbToAndroidEndpoint)) {
137
135
  request.close()
138
136
  byteArrayOf()
139
137
  } else {
@@ -154,6 +152,7 @@ internal class AndroidUsbApduSender(
154
152
  }
155
153
  framerService.deserialize(mtu = USB_MTU, frames)
156
154
  }
155
+ }
157
156
 
158
157
  private fun UsbInterface.firstEndpointOrThrow(predicate: (Int) -> Boolean): UsbEndpoint {
159
158
  for (endp in 0..this.endpointCount) {
@@ -169,7 +168,7 @@ internal class AndroidUsbApduSender(
169
168
  private fun generateChannelId(): ByteArray =
170
169
  Random.nextInt(0, until = Int.MAX_VALUE).to2BytesArray()
171
170
 
172
- private data object SendApduTimeoutException: Exception() {
171
+ private data object SendApduTimeoutException : Exception() {
173
172
  private fun readResolve(): Any = SendApduTimeoutException
174
173
  }
175
174
  }
@@ -2,9 +2,9 @@ package com.ledger.devicesdk.shared.androidMainInternal.transport.deviceconnecti
2
2
 
3
3
  import com.ledger.devicesdk.shared.api.apdu.SendApduFailureReason
4
4
  import com.ledger.devicesdk.shared.api.apdu.SendApduResult
5
+ import com.ledger.devicesdk.shared.api.utils.fromHexStringToBytesOrThrow
5
6
  import com.ledger.devicesdk.shared.internal.service.logger.LoggerService
6
7
  import com.ledger.devicesdk.shared.internal.service.logger.buildSimpleDebugLogInfo
7
- import com.ledger.devicesdk.shared.internal.service.logger.buildSimpleInfoLogInfo
8
8
  import kotlinx.coroutines.CoroutineDispatcher
9
9
  import kotlinx.coroutines.CoroutineScope
10
10
  import kotlinx.coroutines.Job
@@ -26,12 +26,31 @@ internal class DeviceConnectionStateMachine(
26
26
  fun getState() = state
27
27
 
28
28
  private fun pushState(newState: State) {
29
+
30
+ val currentState = state
31
+
32
+ /* STATE EXIT EFFECTS */
33
+ if (newState != currentState) {
34
+ when (currentState) {
35
+ is State.WaitingForDisconnection -> {
36
+ currentState.requestContent.resultCallback(currentState.result)
37
+ }
38
+ else -> {}
39
+ }
40
+ }
41
+
42
+ /* STATE ENTRY EFFECTS */
29
43
  when (newState) {
30
44
  is State.Connected -> {}
31
45
  is State.SendingApdu -> {
32
46
  sendApduFn(newState.requestContent.apdu, newState.requestContent.abortTimeoutDuration)
33
47
  }
34
48
 
49
+ is State.WaitingForDisconnection -> {
50
+ // TODO: send getAppAndVersion
51
+ sendApduFn ("b0010000".fromHexStringToBytesOrThrow(), Duration.INFINITE)
52
+ }
53
+
35
54
  is State.WaitingForReconnection -> {
36
55
  startReconnectionTimeout()
37
56
  }
@@ -42,11 +61,16 @@ internal class DeviceConnectionStateMachine(
42
61
  }
43
62
  }
44
63
  this.state = newState
64
+ loggerService.log(buildSimpleDebugLogInfo("DeviceConnectionStateMachine", "-> New state: $newState"))
45
65
  }
46
66
 
47
67
  private fun handleEvent(event: Event) {
48
- val currentState = state
49
- when (currentState) {
68
+ val logMessage = """
69
+ -> Event received: $event
70
+ In state: $state
71
+ """.trimIndent()
72
+ loggerService.log(buildSimpleDebugLogInfo("DeviceConnectionStateMachine", logMessage))
73
+ when (val currentState = state) {
50
74
  is State.Connected -> {
51
75
  when (event) {
52
76
  is Event.SendApduRequested -> {
@@ -71,31 +95,27 @@ internal class DeviceConnectionStateMachine(
71
95
  when (event) {
72
96
  is Event.ApduResultReceived -> {
73
97
  when (event.result) {
74
- is SendApduResult.Failure -> {
75
- if (isFatalSendApduFailure(event.result)) {
76
- pushState(State.Terminated)
98
+ is SendApduResult.Success -> {
99
+ // check if last 2 bytes of APDU are [0x90,OxO0]
100
+ val apdu = event.result.apdu
101
+
102
+
103
+ if (isSuccessApdu(apdu) && currentState.requestContent.triggersDisconnection) {
104
+ pushState(State.WaitingForDisconnection(requestContent = currentState.requestContent, result = event.result))
77
105
  } else {
78
106
  pushState(State.Connected)
107
+ currentState.requestContent.resultCallback(event.result)
79
108
  }
80
109
  }
81
-
82
- is SendApduResult.Success -> {
83
- // check if last 2 bytes of APDU are [0x90,OxO0]
84
- val apdu = event.result.apdu
85
- val apduSize = apdu.size
86
- val isSuccessApdu =
87
- apdu.size >= 2 &&
88
- apdu[apduSize - 2] == 0x90.toByte() &&
89
- apdu[apduSize - 1] == 0x00.toByte()
90
-
91
- if (isSuccessApdu && currentState.requestContent.triggersDisconnection) {
92
- pushState(State.WaitingForReconnection)
110
+ is SendApduResult.Failure -> {
111
+ if (isFatalSendApduFailure(event.result)) {
112
+ pushState(State.Terminated)
93
113
  } else {
94
114
  pushState(State.Connected)
95
115
  }
116
+ currentState.requestContent.resultCallback(event.result)
96
117
  }
97
118
  }
98
- currentState.requestContent.resultCallback(event.result)
99
119
  }
100
120
 
101
121
  is Event.CloseConnectionRequested -> {
@@ -130,6 +150,42 @@ internal class DeviceConnectionStateMachine(
130
150
  }
131
151
  }
132
152
 
153
+ is State.WaitingForDisconnection -> {
154
+ when (event) {
155
+ is Event.ApduResultReceived -> {
156
+ when (event.result) {
157
+ is SendApduResult.Success -> {
158
+ val apdu = event.result.apdu
159
+ if (isSendApduBusyError(apdu)) {
160
+ pushState(state) // Loop on same state, will trigger a new send of GetAppAndVersion (entry effect)
161
+ } else {
162
+ pushState(State.Connected)
163
+ }
164
+ }
165
+ is SendApduResult.Failure -> {
166
+ pushState(State.WaitingForReconnection)
167
+ }
168
+ }
169
+ }
170
+ is Event.SendApduRequested -> {
171
+ event.requestContent.resultCallback(
172
+ SendApduResult.Failure(
173
+ SendApduFailureReason.DeviceBusy
174
+ )
175
+ )
176
+ }
177
+ is Event.DeviceDisconnected -> {
178
+ pushState(State.WaitingForReconnection)
179
+ }
180
+ is Event.CloseConnectionRequested -> {
181
+ pushState(State.Terminated)
182
+ }
183
+ else -> {
184
+ onError(Exception("Unhandled event: $event in state: $currentState"))
185
+ }
186
+ }
187
+ }
188
+
133
189
  is State.WaitingForReconnection -> {
134
190
  when (event) {
135
191
  is Event.DeviceConnected -> {
@@ -234,13 +290,24 @@ internal class DeviceConnectionStateMachine(
234
290
  onError(Exception("Unhandled event: $event in state: $currentState"))
235
291
  }
236
292
  }
237
- val logMessage = """
238
- Received event:
239
- In state: $currentState
240
- -> Event: $event
241
- -> New state: $state
242
- """.trimIndent()
243
- loggerService.log(buildSimpleDebugLogInfo("DeviceConnectionStateMachine", logMessage))
293
+ }
294
+
295
+ private fun isSuccessApdu(apdu: ByteArray): Boolean {
296
+ val apduSize = apdu.size
297
+ return apduSize >= 2 &&
298
+ apdu[apduSize - 2] == 0x90.toByte() &&
299
+ apdu[apduSize - 1] == 0x00.toByte()
300
+ }
301
+
302
+ private fun isSendApduBusyError(apdu: ByteArray): Boolean {
303
+ val apduSize = apdu.size
304
+ return apduSize >= 2 &&
305
+ apdu[apduSize - 2] == 0x66.toByte() &&
306
+ apdu[apduSize - 1] == 0x01.toByte()
307
+ }
308
+
309
+ private fun sendGetAppAndVersionApdu() {
310
+ sendApduFn("b0010000".fromHexStringToBytesOrThrow(), Duration.INFINITE)
244
311
  }
245
312
 
246
313
  private var timeoutJob: Job? = null
@@ -257,23 +324,23 @@ internal class DeviceConnectionStateMachine(
257
324
  timeoutJob = null
258
325
  }
259
326
 
260
- public fun requestSendApdu(requestContent: SendApduRequestContent) {
327
+ fun requestSendApdu(requestContent: SendApduRequestContent) {
261
328
  handleEvent(Event.SendApduRequested(requestContent))
262
329
  }
263
330
 
264
- public fun requestCloseConnection() {
331
+ fun requestCloseConnection() {
265
332
  handleEvent(Event.CloseConnectionRequested)
266
333
  }
267
334
 
268
- public fun handleApduResult(result: SendApduResult) {
335
+ fun handleApduResult(result: SendApduResult) {
269
336
  handleEvent(Event.ApduResultReceived(result))
270
337
  }
271
338
 
272
- public fun handleDeviceConnected() {
339
+ fun handleDeviceConnected() {
273
340
  handleEvent(Event.DeviceConnected)
274
341
  }
275
342
 
276
- public fun handleDeviceDisconnected() {
343
+ fun handleDeviceDisconnected() {
277
344
  handleEvent(Event.DeviceDisconnected)
278
345
  }
279
346
 
@@ -307,6 +374,8 @@ internal class DeviceConnectionStateMachine(
307
374
 
308
375
  data object WaitingForReconnection : State()
309
376
 
377
+ data class WaitingForDisconnection(val requestContent: SendApduRequestContent, val result: SendApduResult): State()
378
+
310
379
  data class WaitingForReconnectionWithQueuedApdu(val requestContent: SendApduRequestContent) :
311
380
  State()
312
381
 
@@ -2,6 +2,7 @@ package com.ledger.devicesdk.shared.androidMainInternal.transport.deviceconnecti
2
2
 
3
3
  import com.ledger.devicesdk.shared.api.apdu.SendApduFailureReason
4
4
  import com.ledger.devicesdk.shared.api.apdu.SendApduResult
5
+ import com.ledger.devicesdk.shared.api.utils.fromHexStringToBytesOrThrow
5
6
  import com.ledger.devicesdk.shared.internal.service.logger.LogInfo
6
7
  import com.ledger.devicesdk.shared.internal.service.logger.LoggerService
7
8
  import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -12,6 +13,7 @@ import kotlin.time.Duration
12
13
  import kotlin.time.Duration.Companion.seconds
13
14
  import kotlin.test.Test
14
15
  import org.junit.Assert.*
16
+ import kotlin.test.assertIs
15
17
  import kotlin.time.Duration.Companion.milliseconds
16
18
 
17
19
  @OptIn(ExperimentalCoroutinesApi::class)
@@ -88,7 +90,7 @@ class DeviceConnectionStateMachineTest {
88
90
  stateMachine.handleApduResult(mockedFailureResult)
89
91
 
90
92
  // Then
91
- assertEquals(sendApduResult, mockedFailureResult)
93
+ assertEquals(mockedFailureResult, sendApduResult)
92
94
  assertTrue(terminated)
93
95
  assertNull(error)
94
96
  }
@@ -134,14 +136,13 @@ class DeviceConnectionStateMachineTest {
134
136
  }
135
137
 
136
138
  @Test
137
- fun `GIVEN the device connection state machine WHEN an APDU with triggersDisconnection is sent THEN the machine moves to WaitingForReconnection and recovers when device reconnects`() =
139
+ fun `GIVEN the device connection state machine WHEN an APDU with triggersDisconnection is sent and triggers a disconnection THEN the machine moves to WaitingForReconnection and recovers when device reconnects`() =
138
140
  runTest {
139
141
  var sendApduCalled: ByteArray? = null
140
142
  var sendApduResult: SendApduResult? = null
141
143
  var terminated = false
142
144
  var error: Throwable? = null
143
145
 
144
- val dispatcher = StandardTestDispatcher(testScheduler)
145
146
  val stateMachine = DeviceConnectionStateMachine(
146
147
  sendApduFn = { apdu, _ -> sendApduCalled = apdu },
147
148
  onTerminated = { terminated = true },
@@ -167,6 +168,10 @@ class DeviceConnectionStateMachineTest {
167
168
  val mockedSuccessApduResult = SendApduResult.Success(mockedResultApduSuccessA)
168
169
  stateMachine.handleApduResult(mockedSuccessApduResult)
169
170
 
171
+ assertNull(sendApduResult)
172
+ // Simulate a disconnection
173
+ stateMachine.handleDeviceDisconnected()
174
+
170
175
  // Result should have been returned
171
176
  assertEquals(mockedSuccessApduResult, sendApduResult)
172
177
 
@@ -203,7 +208,118 @@ class DeviceConnectionStateMachineTest {
203
208
  }
204
209
 
205
210
  @Test
206
- fun `GIVEN the device connection state machine WHEN an APDU with triggersDisconnection is sent THEN the machine moves to WaitingForReconnection and the next APDU is queued until reconnection`() =
211
+ fun `GIVEN the device connection state machine WHEN an APDU with triggersDisconnection is sent and device does not disconnect THEN the machine moves to Connected`() =
212
+ runTest {
213
+ var sendApduCalled: ByteArray? = null
214
+ var sendApduResult: SendApduResult? = null
215
+ var terminated = false
216
+ var error: Throwable? = null
217
+
218
+ val stateMachine = DeviceConnectionStateMachine(
219
+ sendApduFn = { apdu, _ -> sendApduCalled = apdu },
220
+ onTerminated = { terminated = true },
221
+ isFatalSendApduFailure = { false },
222
+ reconnectionTimeoutDuration = reconnectionTimeout,
223
+ onError = { error = it },
224
+ loggerService = FakeLoggerService(),
225
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
226
+ )
227
+
228
+ // Request sending an APDU (with triggersDisconnection = true)
229
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
230
+ apdu = mockedRequestApduA,
231
+ triggersDisconnection = true,
232
+ resultCallback = { result -> sendApduResult = result },
233
+ abortTimeoutDuration = Duration.INFINITE
234
+ ))
235
+
236
+ // Send APDU should have been called
237
+ assertArrayEquals(mockedRequestApduA, sendApduCalled)
238
+
239
+ // Simulate a successful response
240
+ val mockedSuccessApduResult = SendApduResult.Success(mockedResultApduSuccessA)
241
+ stateMachine.handleApduResult(mockedSuccessApduResult)
242
+
243
+ assertNull(sendApduResult)
244
+
245
+ // Simulate Response from GetAppAndVersion
246
+ val mockedSuccessApduResultGetAppAndVersion = SendApduResult.Success(mockedGetAppAndVersionSuccessfulResponse)
247
+ stateMachine.handleApduResult(mockedSuccessApduResultGetAppAndVersion)
248
+
249
+ // Should be in Connected state
250
+ assertEquals(
251
+ DeviceConnectionStateMachine.State.Connected,
252
+ stateMachine.getState()
253
+ )
254
+
255
+ // Response should have been returned
256
+ assertEquals(mockedSuccessApduResult, sendApduResult)
257
+ }
258
+
259
+ @Test
260
+ fun `GIVEN the device connection state machine WHEN an APDU with triggersDisconnection is sent and device does not disconnect but is first busy THEN the machine retries and moves to Connected`() =
261
+ runTest {
262
+ var sendApduCalled: ByteArray? = null
263
+ var sendApduResult: SendApduResult? = null
264
+ var terminated = false
265
+ var error: Throwable? = null
266
+
267
+ val stateMachine = DeviceConnectionStateMachine(
268
+ sendApduFn = { apdu, _ -> sendApduCalled = apdu },
269
+ onTerminated = { terminated = true },
270
+ isFatalSendApduFailure = { false },
271
+ reconnectionTimeoutDuration = reconnectionTimeout,
272
+ onError = { error = it },
273
+ loggerService = FakeLoggerService(),
274
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
275
+ )
276
+
277
+ // Request sending an APDU (with triggersDisconnection = true)
278
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
279
+ apdu = mockedRequestApduA,
280
+ triggersDisconnection = true,
281
+ resultCallback = { result -> sendApduResult = result },
282
+ abortTimeoutDuration = Duration.INFINITE
283
+ ))
284
+
285
+ // Send APDU should have been called
286
+ assertArrayEquals(mockedRequestApduA, sendApduCalled)
287
+
288
+ // Simulate a successful response
289
+ val mockedSuccessApduResult = SendApduResult.Success(mockedResultApduSuccessA)
290
+ stateMachine.handleApduResult(mockedSuccessApduResult)
291
+
292
+ assertNull(sendApduResult)
293
+
294
+ assertIs<DeviceConnectionStateMachine.State.WaitingForDisconnection>(
295
+ stateMachine.getState()
296
+ )
297
+
298
+ // Simulate Busy Response from GetAppAndVersion
299
+ val mockedBusyApduResultGetAppAndVersion = SendApduResult.Success(mockedGetAppAndVersionBusyResponse)
300
+ stateMachine.handleApduResult(mockedBusyApduResultGetAppAndVersion)
301
+
302
+ assertIs<DeviceConnectionStateMachine.State.WaitingForDisconnection>(
303
+ stateMachine.getState()
304
+ )
305
+
306
+ // Simulate Successful Response from GetAppAndVersion
307
+ val mockedSuccessApduResultGetAppAndVersion = SendApduResult.Success(mockedGetAppAndVersionSuccessfulResponse)
308
+ stateMachine.handleApduResult(mockedSuccessApduResultGetAppAndVersion)
309
+
310
+ // Should be in Connected state
311
+ assertEquals(
312
+ DeviceConnectionStateMachine.State.Connected,
313
+ stateMachine.getState()
314
+ )
315
+
316
+ // Response should have been returned
317
+ assertEquals(mockedSuccessApduResult, sendApduResult)
318
+ }
319
+
320
+
321
+ @Test
322
+ fun `GIVEN the device connection state machine WHEN an APDU with triggersDisconnection is sent and triggers a disconnection THEN the machine moves to WaitingForReconnection and the next APDU is queued until reconnection`() =
207
323
  runTest {
208
324
  val sendApduCalled: MutableList<ByteArray> = mutableListOf()
209
325
  var terminated = false
@@ -231,10 +347,19 @@ class DeviceConnectionStateMachineTest {
231
347
  val mockedSuccessApduResult = SendApduResult.Success(mockedResultApduSuccessA)
232
348
  stateMachine.handleApduResult(mockedSuccessApduResult)
233
349
 
234
- // sendApduFn should have been called once
235
- assertEquals(1, sendApduCalled.size)
350
+ // sendApduFn should have been called twice (for the mockedRequestApduA and an extra call for the getAppAndVersion APDU)
351
+ assertEquals(2, sendApduCalled.size)
352
+
353
+ // Should be in WaitingForDisconnection state
354
+ assertIs<DeviceConnectionStateMachine.State.WaitingForDisconnection>(
355
+ stateMachine.getState()
356
+ )
357
+
358
+ // Simulate disconnection
359
+ stateMachine.handleDeviceDisconnected()
360
+
361
+ // Should be in WaitingForReconnection state
236
362
 
237
- // Should be in waiting state
238
363
  assertEquals(
239
364
  DeviceConnectionStateMachine.State.WaitingForReconnection,
240
365
  stateMachine.getState()
@@ -256,7 +381,7 @@ class DeviceConnectionStateMachineTest {
256
381
  )
257
382
 
258
383
  // sendApduFn should not have been called one more time
259
- assertEquals(1, sendApduCalled.size)
384
+ assertEquals(2, sendApduCalled.size)
260
385
 
261
386
  // Simulate reconnection
262
387
  stateMachine.handleDeviceConnected()
@@ -267,9 +392,9 @@ class DeviceConnectionStateMachineTest {
267
392
  stateMachine.getState()::class
268
393
  )
269
394
 
270
- // Send APDU should have been called a second time, and the result should have been returned
271
- assertEquals(2, sendApduCalled.size)
272
- assertArrayEquals(mockedRequestApduB, sendApduCalled[1])
395
+ // Send APDU should have been called a 3rd time, and the result should have been returned
396
+ assertEquals(3, sendApduCalled.size)
397
+ assertArrayEquals(mockedRequestApduB, sendApduCalled[2])
273
398
 
274
399
  // Simulate a successful response
275
400
  val mockedSuccessApduResultB = SendApduResult.Success(mockedResultApduSuccessB)
@@ -721,9 +846,11 @@ class DeviceConnectionStateMachineTest {
721
846
  }
722
847
 
723
848
  val reconnectionTimeout: Duration = 5.seconds
724
- val mockedRequestApduA: ByteArray = byteArrayOf(0x01, 0x02)
725
- val mockedRequestApduB: ByteArray = byteArrayOf(0x03, 0x04)
726
- val mockedResultApduSuccessA: ByteArray = byteArrayOf(0x05, 0x06, 0x90.toByte(), 0x00)
727
- val mockedResultApduSuccessB: ByteArray = byteArrayOf(0x07, 0x08, 0x90.toByte(), 0x00)
849
+ val mockedRequestApduA: ByteArray = "1234".fromHexStringToBytesOrThrow()
850
+ val mockedRequestApduB: ByteArray = "5678".fromHexStringToBytesOrThrow()
851
+ val mockedGetAppAndVersionSuccessfulResponse: ByteArray = "12349000".fromHexStringToBytesOrThrow()
852
+ val mockedGetAppAndVersionBusyResponse: ByteArray = "12346601".fromHexStringToBytesOrThrow()
853
+ val mockedResultApduSuccessA: ByteArray = "56789000".fromHexStringToBytesOrThrow()
854
+ val mockedResultApduSuccessB: ByteArray = "abcd9000".fromHexStringToBytesOrThrow()
728
855
  }
729
856
  }