@ledgerhq/device-transport-kit-react-native-hid 0.0.0-hid-candidate-4-20250528125313 → 0.0.0-intent-queue-2-20251112131539

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 (25) hide show
  1. package/android/src/main/kotlin/com/ledger/androidtransporthid/TransportHidModule.kt +45 -14
  2. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMain/transport/usb/DefaultAndroidUsbTransport.kt +34 -11
  3. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMain/transport/usb/utils/UsbDeviceMapper.kt +3 -0
  4. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/deviceconnection/DeviceConnectionStateMachine.kt +93 -21
  5. package/android/src/main/kotlin/com/ledger/devicesdk/shared/api/device/LedgerDevice.kt +16 -0
  6. package/android/src/test/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/deviceconnection/DeviceConnectionStateMachineTest.kt +142 -15
  7. package/lib/cjs/api/bridge/mapper.js +1 -1
  8. package/lib/cjs/api/bridge/mapper.js.map +2 -2
  9. package/lib/cjs/api/bridge/mapper.test.js +1 -1
  10. package/lib/cjs/api/bridge/mapper.test.js.map +2 -2
  11. package/lib/cjs/index.js +1 -1
  12. package/lib/cjs/index.js.map +3 -3
  13. package/lib/cjs/package.json +2 -2
  14. package/lib/esm/api/bridge/mapper.js +1 -1
  15. package/lib/esm/api/bridge/mapper.js.map +3 -3
  16. package/lib/esm/api/bridge/mapper.test.js +1 -1
  17. package/lib/esm/api/bridge/mapper.test.js.map +3 -3
  18. package/lib/esm/index.js +1 -1
  19. package/lib/esm/index.js.map +3 -3
  20. package/lib/esm/package.json +2 -2
  21. package/lib/types/api/bridge/mapper.d.ts.map +1 -1
  22. package/lib/types/index.d.ts +1 -0
  23. package/lib/types/index.d.ts.map +1 -1
  24. package/lib/types/tsconfig.prod.tsbuildinfo +1 -1
  25. package/package.json +9 -9
@@ -17,11 +17,14 @@ import com.ledger.devicesdk.shared.androidMain.transport.usb.controller.ACTION_U
17
17
  import com.ledger.devicesdk.shared.androidMain.transport.usb.controller.UsbAttachedReceiverController
18
18
  import com.ledger.devicesdk.shared.androidMain.transport.usb.controller.UsbDetachedReceiverController
19
19
  import com.ledger.devicesdk.shared.androidMain.transport.usb.controller.UsbPermissionReceiver
20
+ import com.ledger.devicesdk.shared.api.apdu.SendApduFailureReason
21
+ import com.ledger.devicesdk.shared.api.apdu.SendApduResult
20
22
  import com.ledger.devicesdk.shared.api.discovery.DiscoveryDevice
21
23
  import com.ledger.devicesdk.shared.internal.connection.InternalConnectedDevice
22
24
  import com.ledger.devicesdk.shared.internal.connection.InternalConnectionResult
23
25
  import com.ledger.devicesdk.shared.internal.event.SdkEventDispatcher
24
26
  import com.ledger.devicesdk.shared.internal.service.logger.LoggerService
27
+ import com.ledger.devicesdk.shared.internal.service.logger.buildSimpleDebugLogInfo
25
28
  import com.ledger.devicesdk.shared.internal.transport.TransportEvent
26
29
  import kotlinx.coroutines.CoroutineScope
27
30
  import kotlinx.coroutines.Dispatchers
@@ -34,6 +37,8 @@ import kotlin.random.Random
34
37
  import kotlin.time.Duration
35
38
  import kotlin.time.Duration.Companion.milliseconds
36
39
 
40
+ private val TAG = "TransportHidModule"
41
+
37
42
  class TransportHidModule(
38
43
  private val reactContext: ReactApplicationContext,
39
44
  private val coroutineScope: CoroutineScope
@@ -133,12 +138,18 @@ class TransportHidModule(
133
138
  transport?.stopScan()
134
139
  }
135
140
 
136
- private var discoveryCount = 0
141
+ private var activeScanCount = 0
137
142
 
138
143
  @ReactMethod
139
144
  fun startScan(promise: Promise) {
140
- discoveryCount += 1
141
- if (discoveryCount > 1) {
145
+ loggerService.log(
146
+ buildSimpleDebugLogInfo(TAG, "[startScan] called")
147
+ )
148
+ activeScanCount += 1
149
+ if (activeScanCount > 1) {
150
+ loggerService.log(
151
+ buildSimpleDebugLogInfo(TAG, "[startScan] already scanning")
152
+ )
142
153
  promise.resolve(null)
143
154
  return
144
155
  }
@@ -156,16 +167,34 @@ class TransportHidModule(
156
167
 
157
168
  @ReactMethod
158
169
  fun stopScan(promise: Promise) {
159
- discoveryCount -= 1
160
- if (discoveryCount > 0) {
161
- promise.resolve(null)
162
- return
163
- }
164
- try {
165
- transport!!.stopScan()
166
- promise.resolve(null)
167
- } catch (e: Exception) {
168
- promise.reject(e);
170
+ loggerService.log(
171
+ buildSimpleDebugLogInfo(TAG, "[stopScan] called, activeScanCount=$activeScanCount")
172
+ )
173
+
174
+ when(activeScanCount) {
175
+ 0 -> {
176
+ loggerService.log(buildSimpleDebugLogInfo(TAG, "[stopScan] no active scan"))
177
+ promise.resolve(null)
178
+ }
179
+ 1 -> {
180
+ try {
181
+ transport!!.stopScan()
182
+ promise.resolve(null)
183
+ } catch (e: Exception) {
184
+ promise.reject(e);
185
+ }
186
+ activeScanCount = 0
187
+ }
188
+ else -> {
189
+ loggerService.log(
190
+ buildSimpleDebugLogInfo(
191
+ TAG,
192
+ "[stopScan] still scanning because there are active listeners"
193
+ )
194
+ )
195
+ activeScanCount -= 1
196
+ promise.resolve(null)
197
+ }
169
198
  }
170
199
  }
171
200
 
@@ -217,7 +246,9 @@ class TransportHidModule(
217
246
  try {
218
247
  val device = connectedDevices.firstOrNull() { it.id == sessionId }
219
248
  if (device == null) {
220
- promise.reject(Exception("[TransportHidModule][sendApdu] Device not found"))
249
+ promise.resolve(
250
+ SendApduResult.Failure(SendApduFailureReason.DeviceNotFound).toWritableMap()
251
+ )
221
252
  return
222
253
  }
223
254
  coroutineScope.launch {
@@ -43,8 +43,11 @@ import kotlinx.coroutines.flow.shareIn
43
43
  import kotlinx.coroutines.isActive
44
44
  import kotlinx.coroutines.launch
45
45
  import kotlin.time.Duration
46
+ import kotlin.time.Duration.Companion.milliseconds
46
47
  import kotlin.time.Duration.Companion.seconds
47
48
 
49
+ private val TAG = "DefaultAndroidUsbTransport"
50
+
48
51
  internal class DefaultAndroidUsbTransport(
49
52
  private val application: Application,
50
53
  private val usbManager: UsbManager,
@@ -67,22 +70,16 @@ internal class DefaultAndroidUsbTransport(
67
70
  private var discoveryJob: Job? = null
68
71
 
69
72
  override fun startScan(): Flow<List<DiscoveryDevice>> {
73
+ loggerService.log(buildSimpleDebugLogInfo(TAG, "[startScan] called"))
70
74
  val scanStateFlow = MutableStateFlow<List<DiscoveryDevice>>(emptyList())
71
75
  discoveryJob?.cancel()
72
76
  discoveryJob =
73
77
  scope.launch {
74
78
  while (isActive) {
75
- val usbDevices = usbManager.deviceList.values.toList()
76
- val devices =
77
- usbDevices
78
- .filter { device ->
79
- usbConnections.filter {
80
- device == it.value.getApduSender().dependencies.usbDevice
81
- }.isEmpty()
82
- }.toUsbDevices()
83
-
84
- scanStateFlow.value = devices.toScannedDevices()
85
-
79
+ loggerService.log(buildSimpleDebugLogInfo(TAG, "[startScan] isActive loop"))
80
+ val usbDevices = usbManager.deviceList.values.toList().toUsbDevices()
81
+ scanStateFlow.value = usbDevices.toScannedDevices()
82
+ loggerService.log(buildSimpleDebugLogInfo(TAG, "[startScan] scannedDevices=${scanStateFlow.value}"))
86
83
  delay(scanDelay)
87
84
  }
88
85
  }
@@ -90,6 +87,7 @@ internal class DefaultAndroidUsbTransport(
90
87
  }
91
88
 
92
89
  override fun stopScan() {
90
+ loggerService.log(buildSimpleDebugLogInfo(TAG, "[stopScan] called"))
93
91
  discoveryJob?.cancel()
94
92
  discoveryJob = null
95
93
  }
@@ -199,6 +197,7 @@ internal class DefaultAndroidUsbTransport(
199
197
  request = UsbRequest(),
200
198
  loggerService = loggerService
201
199
  )
200
+ delay(POST_CONNECTION_DELAY)
202
201
 
203
202
  if (!usbConnectionsPendingReconnection.contains(deviceConnection)) {
204
203
  /**
@@ -294,6 +293,27 @@ internal class DefaultAndroidUsbTransport(
294
293
  return if (usbDevice == null || ledgerUsbDevice == null) {
295
294
  InternalConnectionResult.ConnectionError(error = InternalConnectionResult.Failure.DeviceNotFound)
296
295
  } else {
296
+
297
+ val existingConnection = usbConnections.firstNotNullOfOrNull {
298
+ if (it.value.getApduSender().dependencies.usbDevice == usbDevice) it.value
299
+ else if (it.key == generateSessionId(usbDevice)) it.value
300
+ else null
301
+ }
302
+
303
+ if (existingConnection != null) {
304
+ val connectedDevice =
305
+ InternalConnectedDevice(
306
+ existingConnection.sessionId,
307
+ discoveryDevice.name,
308
+ discoveryDevice.ledgerDevice,
309
+ discoveryDevice.connectivityType,
310
+ sendApduFn = { apdu: ByteArray, triggersDisconnection: Boolean, abortTimeoutDuration: Duration ->
311
+ existingConnection.requestSendApdu(apdu, triggersDisconnection, abortTimeoutDuration)
312
+ }
313
+ )
314
+ return InternalConnectionResult.Connected(device = connectedDevice, sessionId = existingConnection.sessionId)
315
+ }
316
+
297
317
  val permissionResult = checkOrRequestPermission(usbDevice)
298
318
  if (permissionResult is PermissionResult.Denied) {
299
319
  return permissionResult.connectionError
@@ -312,6 +332,7 @@ internal class DefaultAndroidUsbTransport(
312
332
  request = UsbRequest(),
313
333
  loggerService = loggerService,
314
334
  )
335
+ delay(POST_CONNECTION_DELAY)
315
336
 
316
337
  val deviceConnection = DeviceConnection(
317
338
  sessionId = sessionId,
@@ -354,6 +375,8 @@ internal class DefaultAndroidUsbTransport(
354
375
  private fun generateSessionId(usbDevice: UsbDevice): String = "usb_${usbDevice.deviceId}"
355
376
  }
356
377
 
378
+ private val POST_CONNECTION_DELAY = 200.milliseconds
379
+
357
380
  private fun List<LedgerUsbDevice>.toScannedDevices(): List<DiscoveryDevice> =
358
381
  this.map {
359
382
  it.toScannedDevice()
@@ -25,6 +25,9 @@ internal fun ProductId.toLedgerDevice(): LedgerDevice? =
25
25
  this.id.isLedgerDeviceProductId(LedgerDevice.Flex) -> {
26
26
  LedgerDevice.Flex
27
27
  }
28
+ this.id.isLedgerDeviceProductId(LedgerDevice.Apex) -> {
29
+ LedgerDevice.Apex
30
+ }
28
31
  else -> {
29
32
  null
30
33
  }
@@ -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.LoggerService
6
7
  import com.ledger.devicesdk.shared.internal.service.logger.buildSimpleDebugLogInfo
7
8
  import kotlinx.coroutines.CoroutineDispatcher
@@ -25,12 +26,31 @@ internal class DeviceConnectionStateMachine(
25
26
  fun getState() = state
26
27
 
27
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 */
28
43
  when (newState) {
29
44
  is State.Connected -> {}
30
45
  is State.SendingApdu -> {
31
46
  sendApduFn(newState.requestContent.apdu, newState.requestContent.abortTimeoutDuration)
32
47
  }
33
48
 
49
+ is State.WaitingForDisconnection -> {
50
+ // TODO: send getAppAndVersion
51
+ sendApduFn ("b0010000".fromHexStringToBytesOrThrow(), Duration.INFINITE)
52
+ }
53
+
34
54
  is State.WaitingForReconnection -> {
35
55
  startReconnectionTimeout()
36
56
  }
@@ -75,31 +95,27 @@ internal class DeviceConnectionStateMachine(
75
95
  when (event) {
76
96
  is Event.ApduResultReceived -> {
77
97
  when (event.result) {
78
- is SendApduResult.Failure -> {
79
- if (isFatalSendApduFailure(event.result)) {
80
- 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))
81
105
  } else {
82
106
  pushState(State.Connected)
107
+ currentState.requestContent.resultCallback(event.result)
83
108
  }
84
109
  }
85
-
86
- is SendApduResult.Success -> {
87
- // check if last 2 bytes of APDU are [0x90,OxO0]
88
- val apdu = event.result.apdu
89
- val apduSize = apdu.size
90
- val isSuccessApdu =
91
- apdu.size >= 2 &&
92
- apdu[apduSize - 2] == 0x90.toByte() &&
93
- apdu[apduSize - 1] == 0x00.toByte()
94
-
95
- if (isSuccessApdu && currentState.requestContent.triggersDisconnection) {
96
- pushState(State.WaitingForReconnection)
110
+ is SendApduResult.Failure -> {
111
+ if (isFatalSendApduFailure(event.result)) {
112
+ pushState(State.Terminated)
97
113
  } else {
98
114
  pushState(State.Connected)
99
115
  }
116
+ currentState.requestContent.resultCallback(event.result)
100
117
  }
101
118
  }
102
- currentState.requestContent.resultCallback(event.result)
103
119
  }
104
120
 
105
121
  is Event.CloseConnectionRequested -> {
@@ -134,6 +150,42 @@ internal class DeviceConnectionStateMachine(
134
150
  }
135
151
  }
136
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
+
137
189
  is State.WaitingForReconnection -> {
138
190
  when (event) {
139
191
  is Event.DeviceConnected -> {
@@ -240,6 +292,24 @@ internal class DeviceConnectionStateMachine(
240
292
  }
241
293
  }
242
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)
311
+ }
312
+
243
313
  private var timeoutJob: Job? = null
244
314
  private fun startReconnectionTimeout() {
245
315
  // start a timeout and at the end, emit a WaitingForReconnectionTimedOut event
@@ -254,23 +324,23 @@ internal class DeviceConnectionStateMachine(
254
324
  timeoutJob = null
255
325
  }
256
326
 
257
- public fun requestSendApdu(requestContent: SendApduRequestContent) {
327
+ fun requestSendApdu(requestContent: SendApduRequestContent) {
258
328
  handleEvent(Event.SendApduRequested(requestContent))
259
329
  }
260
330
 
261
- public fun requestCloseConnection() {
331
+ fun requestCloseConnection() {
262
332
  handleEvent(Event.CloseConnectionRequested)
263
333
  }
264
334
 
265
- public fun handleApduResult(result: SendApduResult) {
335
+ fun handleApduResult(result: SendApduResult) {
266
336
  handleEvent(Event.ApduResultReceived(result))
267
337
  }
268
338
 
269
- public fun handleDeviceConnected() {
339
+ fun handleDeviceConnected() {
270
340
  handleEvent(Event.DeviceConnected)
271
341
  }
272
342
 
273
- public fun handleDeviceDisconnected() {
343
+ fun handleDeviceDisconnected() {
274
344
  handleEvent(Event.DeviceDisconnected)
275
345
  }
276
346
 
@@ -304,6 +374,8 @@ internal class DeviceConnectionStateMachine(
304
374
 
305
375
  data object WaitingForReconnection : State()
306
376
 
377
+ data class WaitingForDisconnection(val requestContent: SendApduRequestContent, val result: SendApduResult): State()
378
+
307
379
  data class WaitingForReconnectionWithQueuedApdu(val requestContent: SendApduRequestContent) :
308
380
  State()
309
381
 
@@ -5,6 +5,21 @@ public sealed class LedgerDevice(
5
5
  public val usbInfo: UsbInfo,
6
6
  public val bleInformation: BleInformation? = null,
7
7
  ) {
8
+ public data object Apex :
9
+ LedgerDevice(
10
+ name = "Ledger Apex",
11
+ usbInfo = UsbInfo(LEDGER_USB_VENDOR_ID, "0x80", "0x0008"),
12
+ bleInformation =
13
+ BleInformation(
14
+ serviceUuid = "13d63400-2c97-6004-0000-4c6564676572",
15
+ notifyCharacteristicUuid =
16
+ "13d63400-2c97-6004-0001-4c6564676572",
17
+ writeWithResponseCharacteristicUuid =
18
+ "13d63400-2c97-6004-0002-4c6564676572",
19
+ writeWithoutResponseCharacteristicUuid =
20
+ "13d63400-2c97-6004-0003-4c6564676572",
21
+ ),
22
+ )
8
23
  public data object Flex :
9
24
  LedgerDevice(
10
25
  name = "Ledger Flex",
@@ -77,6 +92,7 @@ public sealed class LedgerDevice(
77
92
  add(NanoX)
78
93
  add(NanoSPlus)
79
94
  add(NanoS)
95
+ add(Apex)
80
96
  }
81
97
 
82
98
  public fun getAllDevices(): List<LedgerDevice> {
@@ -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
  }