@ledgerhq/device-transport-kit-react-native-hid 0.0.0-rn-hid-20250221115747 → 0.0.0-rnble-transport-20250422084848

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 (28) hide show
  1. package/README.md +1 -1
  2. package/android/build.gradle +3 -1
  3. package/android/src/main/kotlin/com/ledger/androidtransporthid/TransportHidModule.kt +3 -3
  4. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMain/transport/usb/DefaultAndroidUsbTransport.kt +16 -22
  5. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMain/transport/usb/controller/UsbPermissionReceiver.kt +1 -1
  6. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMain/transport/usb/utils/UsbDeviceMapper.kt +27 -36
  7. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/UsbConst.android.kt +1 -1
  8. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/deviceconnection/DeviceConnection.kt +6 -7
  9. package/android/src/main/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/deviceconnection/DeviceConnectionStateMachine.kt +5 -2
  10. package/android/src/main/kotlin/com/ledger/devicesdk/shared/api/device/LedgerDevice.kt +64 -49
  11. package/android/src/main/kotlin/com/ledger/devicesdk/shared/api/device/UsbInfo.kt +4 -3
  12. package/android/src/test/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/deviceconnection/DeviceConnectionStateMachineTest.kt +713 -0
  13. package/android/src/test/kotlin/com/ledger/devicesdk/shared/androidMainInternal/transport/deviceconnection/DeviceConnectionTest.kt +218 -0
  14. package/lib/cjs/api/bridge/NativeTransportModule.js +1 -1
  15. package/lib/cjs/api/bridge/NativeTransportModule.js.map +1 -1
  16. package/lib/cjs/package.json +7 -2
  17. package/lib/esm/api/bridge/NativeTransportModule.js +1 -1
  18. package/lib/esm/api/bridge/NativeTransportModule.js.map +1 -1
  19. package/lib/esm/package.json +7 -2
  20. package/lib/types/api/bridge/NativeTransportModule.d.ts.map +1 -1
  21. package/lib/types/tsconfig.prod.tsbuildinfo +1 -1
  22. package/package.json +10 -5
  23. package/android/.settings/org.eclipse.buildship.core.prefs +0 -13
  24. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  25. package/android/gradle/wrapper/gradle-wrapper.properties +0 -7
  26. package/android/gradlew +0 -252
  27. package/android/gradlew.bat +0 -94
  28. package/android/src/main/kotlin/com/ledger/devicesdk/shared/api/DeviceAction.kt +0 -23
@@ -0,0 +1,713 @@
1
+ package com.ledger.devicesdk.shared.androidMainInternal.transport.deviceconnection
2
+
3
+ import com.ledger.devicesdk.shared.api.apdu.SendApduFailureReason
4
+ import com.ledger.devicesdk.shared.api.apdu.SendApduResult
5
+ import com.ledger.devicesdk.shared.internal.service.logger.LogInfo
6
+ import com.ledger.devicesdk.shared.internal.service.logger.LoggerService
7
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
8
+ import kotlinx.coroutines.test.StandardTestDispatcher
9
+ import kotlinx.coroutines.test.advanceTimeBy
10
+ import kotlinx.coroutines.test.runTest
11
+ import kotlin.time.Duration
12
+ import kotlin.time.Duration.Companion.seconds
13
+ import kotlin.test.Test
14
+ import org.junit.Assert.*
15
+ import kotlin.time.Duration.Companion.milliseconds
16
+
17
+ @OptIn(ExperimentalCoroutinesApi::class)
18
+ class DeviceConnectionStateMachineTest {
19
+ @Test
20
+ fun `GIVEN the device connection state machine in Connected state WHEN an APDU is sent THEN the APDU is processed`() =
21
+ runTest {
22
+ var sendApduCalled: ByteArray? = null
23
+ var terminated = false
24
+ var error: Throwable? = null
25
+ var sendApduResult: SendApduResult? = null
26
+
27
+ val stateMachine = DeviceConnectionStateMachine(
28
+ sendApduFn = { apdu -> sendApduCalled = apdu },
29
+ onTerminated = { terminated = true },
30
+ isFatalSendApduFailure = { false },
31
+ reconnectionTimeoutDuration = reconnectionTimeout,
32
+ onError = { error = it },
33
+ loggerService = FakeLoggerService(),
34
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
35
+ )
36
+
37
+ // Request sending an APDU
38
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
39
+ apdu = mockedRequestApduA,
40
+ triggersDisconnection = false,
41
+ resultCallback = { result -> sendApduResult = result }
42
+ ))
43
+ assertArrayEquals(mockedRequestApduA, sendApduCalled)
44
+
45
+ // Simulate a successful response
46
+ val mockedSuccessApduResult = SendApduResult.Success(mockedResultApduSuccessA)
47
+ stateMachine.handleApduResult(mockedSuccessApduResult)
48
+
49
+ // Then
50
+ assertEquals(mockedSuccessApduResult, sendApduResult)
51
+ assertFalse(terminated)
52
+ assertNull(error)
53
+
54
+ // In Connected state now; closing connection should terminate without an error.
55
+ stateMachine.requestCloseConnection()
56
+ assertTrue(terminated)
57
+ assertNull(error)
58
+ }
59
+
60
+ @Test
61
+ fun `GIVEN the device connection state machine in SendingApdu state WHEN a fatal failure occurs THEN the machine terminates and the APDU request fails`() =
62
+ runTest {
63
+ var terminated = false
64
+ var sendApduResult: SendApduResult? = null
65
+ var error: Throwable? = null
66
+
67
+ val stateMachine = DeviceConnectionStateMachine(
68
+ sendApduFn = { },
69
+ onTerminated = { terminated = true },
70
+ isFatalSendApduFailure = { true }, // All failures are fatal
71
+ reconnectionTimeoutDuration = reconnectionTimeout,
72
+ onError = { error = it },
73
+ loggerService = FakeLoggerService(),
74
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
75
+ )
76
+
77
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
78
+ apdu = mockedRequestApduA,
79
+ triggersDisconnection = false,
80
+ resultCallback = { result -> sendApduResult = result }
81
+ ))
82
+
83
+ // Simulate a failure
84
+ val mockedFailureResult =
85
+ SendApduResult.Failure(SendApduFailureReason.DeviceDisconnected)
86
+ stateMachine.handleApduResult(mockedFailureResult)
87
+
88
+ // Then
89
+ assertEquals(sendApduResult, mockedFailureResult)
90
+ assertTrue(terminated)
91
+ assertNull(error)
92
+ }
93
+
94
+ @Test
95
+ fun `GIVEN the device connection state machine in SendingApdu state WHEN a nonfatal failure occurs THEN the machine returns to Connected state`() =
96
+ runTest {
97
+ var terminated = false
98
+ var sendApduResult: SendApduResult? = null
99
+ var error: Throwable? = null
100
+
101
+ val stateMachine = DeviceConnectionStateMachine(
102
+ sendApduFn = { },
103
+ onTerminated = { terminated = true },
104
+ isFatalSendApduFailure = { false },
105
+ reconnectionTimeoutDuration = reconnectionTimeout,
106
+ onError = { error = it },
107
+ loggerService = FakeLoggerService(),
108
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
109
+ )
110
+
111
+ // Request sending APDU
112
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
113
+ apdu = mockedRequestApduA,
114
+ triggersDisconnection = false,
115
+ resultCallback = { result -> sendApduResult = result }
116
+ ))
117
+
118
+ // Simulate a failure
119
+ val mockedFailureResult =
120
+ SendApduResult.Failure(SendApduFailureReason.ApduNotWellFormatted)
121
+ stateMachine.handleApduResult(mockedFailureResult)
122
+
123
+ // Then
124
+ assertEquals(mockedFailureResult, sendApduResult)
125
+ assertFalse(terminated)
126
+
127
+ // In Connected state now; closing connection should terminate without an error.
128
+ stateMachine.requestCloseConnection()
129
+ assertTrue(terminated)
130
+ assertNull(error)
131
+ }
132
+
133
+ @Test
134
+ 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`() =
135
+ runTest {
136
+ var sendApduCalled: ByteArray? = null
137
+ var sendApduResult: SendApduResult? = null
138
+ var terminated = false
139
+ var error: Throwable? = null
140
+
141
+ val dispatcher = StandardTestDispatcher(testScheduler)
142
+ val stateMachine = DeviceConnectionStateMachine(
143
+ sendApduFn = { apdu -> sendApduCalled = apdu },
144
+ onTerminated = { terminated = true },
145
+ isFatalSendApduFailure = { false },
146
+ reconnectionTimeoutDuration = reconnectionTimeout,
147
+ onError = { error = it },
148
+ loggerService = FakeLoggerService(),
149
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
150
+ )
151
+
152
+ // Request sending an APDU (with triggersDisconnection = true)
153
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
154
+ apdu = mockedRequestApduA,
155
+ triggersDisconnection = true,
156
+ resultCallback = { result -> sendApduResult = result }
157
+ ))
158
+
159
+ // Send APDU should have been called
160
+ assertArrayEquals(mockedRequestApduA, sendApduCalled)
161
+
162
+ // Simulate a successful response
163
+ val mockedSuccessApduResult = SendApduResult.Success(mockedResultApduSuccessA)
164
+ stateMachine.handleApduResult(mockedSuccessApduResult)
165
+
166
+ // Result should have been returned
167
+ assertEquals(mockedSuccessApduResult, sendApduResult)
168
+
169
+ // Should be in waiting state
170
+ assertEquals(
171
+ DeviceConnectionStateMachine.State.WaitingForReconnection,
172
+ stateMachine.getState()
173
+ )
174
+
175
+ // Now in WaitingForReconnection; simulate reconnection.
176
+ stateMachine.handleDeviceConnected()
177
+
178
+ // A new APDU request in Connected state should call sendApduFn.
179
+ var secondSendApduResult: SendApduResult? = null
180
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
181
+ apdu = mockedRequestApduB,
182
+ triggersDisconnection = false,
183
+ resultCallback = { secondSendApduResult = it }
184
+ ))
185
+
186
+ // Send APDU should have been called
187
+ assertArrayEquals(mockedRequestApduB, sendApduCalled)
188
+
189
+ // Simulate a successful response
190
+ val mockedSuccessApduResultB = SendApduResult.Success(mockedResultApduSuccessB)
191
+ stateMachine.handleApduResult(mockedSuccessApduResultB)
192
+
193
+ // Result should have been returned
194
+ assertEquals(mockedSuccessApduResultB, secondSendApduResult)
195
+
196
+ assertFalse(terminated)
197
+ assertNull(error)
198
+ }
199
+
200
+ @Test
201
+ 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`() =
202
+ runTest {
203
+ val sendApduCalled: MutableList<ByteArray> = mutableListOf()
204
+ var terminated = false
205
+ var error: Throwable? = null
206
+
207
+ val stateMachine = DeviceConnectionStateMachine(
208
+ sendApduFn = { apdu -> sendApduCalled += apdu },
209
+ onTerminated = { terminated = true },
210
+ isFatalSendApduFailure = { false },
211
+ reconnectionTimeoutDuration = reconnectionTimeout,
212
+ onError = { error = it },
213
+ loggerService = FakeLoggerService(),
214
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
215
+ )
216
+
217
+ // Request sending an APDU (with triggersDisconnection = true)
218
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
219
+ apdu = mockedRequestApduA,
220
+ triggersDisconnection = true,
221
+ resultCallback = { }
222
+ ))
223
+
224
+ // Simulate a successful response
225
+ val mockedSuccessApduResult = SendApduResult.Success(mockedResultApduSuccessA)
226
+ stateMachine.handleApduResult(mockedSuccessApduResult)
227
+
228
+ // sendApduFn should have been called once
229
+ assertEquals(1, sendApduCalled.size)
230
+
231
+ // Should be in waiting state
232
+ assertEquals(
233
+ DeviceConnectionStateMachine.State.WaitingForReconnection,
234
+ stateMachine.getState()
235
+ )
236
+
237
+ // Now in WaitingForReconnection; simulate new APDU request
238
+ var secondSendApduResult: SendApduResult? = null
239
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
240
+ apdu = mockedRequestApduB,
241
+ triggersDisconnection = false,
242
+ resultCallback = { secondSendApduResult = it }
243
+ ))
244
+
245
+ // Should be in waiting state
246
+ assertEquals(
247
+ DeviceConnectionStateMachine.State.WaitingForReconnectionWithQueuedApdu::class,
248
+ stateMachine.getState()::class
249
+ )
250
+
251
+ // sendApduFn should not have been called one more time
252
+ assertEquals(1, sendApduCalled.size)
253
+
254
+ // Simulate reconnection
255
+ stateMachine.handleDeviceConnected()
256
+
257
+ // Should be in SendingApdu state
258
+ assertEquals(
259
+ DeviceConnectionStateMachine.State.SendingApdu::class,
260
+ stateMachine.getState()::class
261
+ )
262
+
263
+ // Send APDU should have been called a second time, and the result should have been returned
264
+ assertEquals(2, sendApduCalled.size)
265
+ assertArrayEquals(mockedRequestApduB, sendApduCalled[1])
266
+
267
+ // Simulate a successful response
268
+ val mockedSuccessApduResultB = SendApduResult.Success(mockedResultApduSuccessB)
269
+ stateMachine.handleApduResult(mockedSuccessApduResultB)
270
+
271
+ // Should be in Connected state
272
+ assertEquals(
273
+ DeviceConnectionStateMachine.State.Connected,
274
+ stateMachine.getState()
275
+ )
276
+
277
+ // Result should have been returned
278
+ assertEquals(mockedSuccessApduResultB, secondSendApduResult)
279
+
280
+ assertFalse(terminated)
281
+ assertNull(error)
282
+ }
283
+
284
+ @Test
285
+ fun `GIVEN the device connection state machine in SendingApdu state WHEN the connection is closed THEN the machine terminates and the APDU request fails`() =
286
+ runTest {
287
+ var terminated = false
288
+ var sendApduResult: SendApduResult? = null
289
+ var error: Throwable? = null
290
+
291
+ val stateMachine = DeviceConnectionStateMachine(
292
+ sendApduFn = { },
293
+ onTerminated = { terminated = true },
294
+ isFatalSendApduFailure = { false },
295
+ reconnectionTimeoutDuration = reconnectionTimeout,
296
+ onError = { error = it },
297
+ loggerService = FakeLoggerService(),
298
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
299
+ )
300
+
301
+ // Request sending an APDU
302
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
303
+ apdu = mockedRequestApduA,
304
+ triggersDisconnection = false,
305
+ resultCallback = { result -> sendApduResult = result }
306
+ ))
307
+
308
+ // Should be in sending state
309
+ assertEquals(
310
+ DeviceConnectionStateMachine.State.SendingApdu::class,
311
+ stateMachine.getState()::class
312
+ )
313
+
314
+ // Close connection
315
+ stateMachine.requestCloseConnection()
316
+
317
+ // Failure result should have been returned
318
+ assertEquals(
319
+ SendApduResult.Failure(SendApduFailureReason.DeviceDisconnected),
320
+ sendApduResult
321
+ )
322
+ assertTrue(terminated)
323
+ assertNull(error)
324
+ }
325
+
326
+ @Test
327
+ fun `GIVEN the device connection state machine in SendingApdu state WHEN the device disconnects THEN the APDU fails and the machine moves to WaitingForReconnection`() =
328
+ runTest {
329
+ var sendApduResult: SendApduResult? = null
330
+ var terminated = false
331
+ var error: Throwable? = null
332
+
333
+ val stateMachine = DeviceConnectionStateMachine(
334
+ sendApduFn = { },
335
+ onTerminated = { terminated = true },
336
+ isFatalSendApduFailure = { false },
337
+ reconnectionTimeoutDuration = reconnectionTimeout,
338
+ onError = { error = it },
339
+ loggerService = FakeLoggerService(),
340
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
341
+ )
342
+
343
+ // Request sending an APDU
344
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
345
+ apdu = mockedRequestApduA,
346
+ triggersDisconnection = false,
347
+ resultCallback = { result -> sendApduResult = result }
348
+ ))
349
+
350
+ // Simulate a disconnection
351
+ stateMachine.handleDeviceDisconnected()
352
+
353
+ // Should be in waiting state
354
+ assertEquals(
355
+ DeviceConnectionStateMachine.State.WaitingForReconnection,
356
+ stateMachine.getState()
357
+ )
358
+
359
+ // Failure result should have been returned
360
+ assertEquals(
361
+ SendApduResult.Failure(SendApduFailureReason.DeviceDisconnected),
362
+ sendApduResult
363
+ )
364
+
365
+ // Should be in waiting state
366
+ assertEquals(
367
+ DeviceConnectionStateMachine.State.WaitingForReconnection,
368
+ stateMachine.getState()
369
+ )
370
+
371
+ // Simulate reconnection to return to normal operation.
372
+ stateMachine.handleDeviceConnected()
373
+
374
+ assertFalse(terminated)
375
+ assertNull(error)
376
+ }
377
+
378
+ @Test
379
+ fun `GIVEN the device connection state machine in SendingApdu state WHEN an APDU request is received THEN it returns a DeviceBusy failure`() =
380
+ runTest {
381
+ var firstSendApduResult: SendApduResult? = null
382
+ var secondSendApduResult: SendApduResult? = null
383
+ var terminated = false
384
+ var error: Throwable? = null
385
+
386
+ val stateMachine = DeviceConnectionStateMachine(
387
+ sendApduFn = { },
388
+ onTerminated = { terminated = true },
389
+ isFatalSendApduFailure = { false },
390
+ reconnectionTimeoutDuration = reconnectionTimeout,
391
+ onError = { error = it },
392
+ loggerService = FakeLoggerService(),
393
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
394
+ )
395
+
396
+ // First request enters SendingApdu.
397
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
398
+ apdu = mockedRequestApduA,
399
+ triggersDisconnection = false,
400
+ resultCallback = { firstSendApduResult = it }
401
+ ))
402
+
403
+ // Should be in sending state.
404
+ assertEquals(
405
+ DeviceConnectionStateMachine.State.SendingApdu::class,
406
+ stateMachine.getState()::class
407
+ )
408
+
409
+ // Second request while busy.
410
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
411
+ apdu = mockedRequestApduB,
412
+ triggersDisconnection = false,
413
+ resultCallback = { secondSendApduResult = it }
414
+ ))
415
+
416
+ assertEquals(
417
+ SendApduResult.Failure(SendApduFailureReason.DeviceBusy),
418
+ secondSendApduResult
419
+ )
420
+
421
+ // Simulate response to first request
422
+ val mockedSuccessApduResult = SendApduResult.Success(mockedResultApduSuccessA)
423
+ stateMachine.handleApduResult(mockedSuccessApduResult)
424
+
425
+ // First request should have succeeded
426
+ assertEquals(mockedSuccessApduResult, firstSendApduResult)
427
+
428
+ assertFalse(terminated)
429
+ assertNull(error)
430
+ }
431
+
432
+ @Test
433
+ fun `GIVEN the device connection state machine in WaitingForReconnection state WHEN the device reconnects THEN it transitions to Connected state`() =
434
+ runTest {
435
+ var terminated = false
436
+
437
+ val stateMachine = DeviceConnectionStateMachine(
438
+ sendApduFn = { },
439
+ onTerminated = { terminated = true },
440
+ isFatalSendApduFailure = { false },
441
+ reconnectionTimeoutDuration = reconnectionTimeout,
442
+ onError = { },
443
+ loggerService = FakeLoggerService(),
444
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
445
+ )
446
+
447
+ // Simulate disconnection to move to waiting state.
448
+ stateMachine.handleDeviceDisconnected()
449
+
450
+ // Should be in waiting state.
451
+ assertEquals(
452
+ DeviceConnectionStateMachine.State.WaitingForReconnection,
453
+ stateMachine.getState()
454
+ )
455
+
456
+ // Then simulate reconnection.
457
+ stateMachine.handleDeviceConnected()
458
+
459
+ // Should be in connected state.
460
+ assertEquals(
461
+ DeviceConnectionStateMachine.State.Connected,
462
+ stateMachine.getState()
463
+ )
464
+
465
+ // If no error and no termination, we assume state is Connected.
466
+ assertFalse(terminated)
467
+ }
468
+
469
+ @Test
470
+ fun `GIVEN the device connection state machine in WaitingForReconnection state WHEN the reconnection times out THEN the machine terminates`() =
471
+ runTest {
472
+ var terminated = false
473
+
474
+ val stateMachine = DeviceConnectionStateMachine(
475
+ sendApduFn = { },
476
+ onTerminated = { terminated = true },
477
+ isFatalSendApduFailure = { false },
478
+ reconnectionTimeoutDuration = 5.seconds,
479
+ onError = { },
480
+ loggerService = FakeLoggerService(),
481
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
482
+ )
483
+
484
+ // Simulate disconnection to move to waiting state.
485
+ stateMachine.handleDeviceDisconnected()
486
+
487
+ // Should be in waiting state.
488
+ assertEquals(
489
+ DeviceConnectionStateMachine.State.WaitingForReconnection,
490
+ stateMachine.getState()
491
+ )
492
+
493
+ // Simulate timeout
494
+ advanceTimeBy(5.seconds)
495
+ assertFalse(terminated)
496
+
497
+ advanceTimeBy(1.milliseconds)
498
+ assertTrue(terminated)
499
+ }
500
+
501
+ @Test
502
+ fun `GIVEN the device connection state machine in WaitingForReconnection state WHEN an APDU is queued THEN on device connection the APDU is sent`() =
503
+ runTest {
504
+ var sendApduCalled: ByteArray? = null
505
+ var sendApduResult: SendApduResult? = null
506
+ var terminated = false
507
+ var error: Throwable? = null
508
+
509
+ val stateMachine = DeviceConnectionStateMachine(
510
+ sendApduFn = { apdu -> sendApduCalled = apdu },
511
+ onTerminated = { terminated = true },
512
+ isFatalSendApduFailure = { false },
513
+ reconnectionTimeoutDuration = 5.seconds,
514
+ onError = { error = it },
515
+ loggerService = FakeLoggerService(),
516
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
517
+ )
518
+
519
+ // Simulate disconnection
520
+ stateMachine.handleDeviceDisconnected()
521
+
522
+ // Request sending an APDU
523
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
524
+ apdu = mockedRequestApduA,
525
+ triggersDisconnection = false,
526
+ resultCallback = { sendApduResult = it }
527
+ ))
528
+
529
+ // Send APDU should not have been called
530
+ assertNull(sendApduCalled)
531
+
532
+ // Simulate reconnection
533
+ stateMachine.handleDeviceConnected()
534
+ assertEquals(mockedRequestApduA, sendApduCalled)
535
+
536
+ // Simulate response
537
+ val mockedApduResult = SendApduResult.Success(mockedResultApduSuccessA)
538
+ stateMachine.handleApduResult(mockedApduResult)
539
+
540
+ // Callback should have been called
541
+ assertEquals(sendApduResult, mockedApduResult)
542
+
543
+ assertNull(error)
544
+ assertFalse(terminated)
545
+ }
546
+
547
+ @Test
548
+ fun `GIVEN the device connection state machine with a queued APDU WHEN the connection is closed THEN the machine terminates and the APDU request fails`() =
549
+ runTest {
550
+ var terminated = false
551
+ var sendApduResult: SendApduResult? = null
552
+ var error: Throwable? = null
553
+
554
+ val stateMachine = DeviceConnectionStateMachine(
555
+ sendApduFn = { },
556
+ onTerminated = { terminated = true },
557
+ isFatalSendApduFailure = { false },
558
+ reconnectionTimeoutDuration = 5.seconds,
559
+ onError = { error = it },
560
+ loggerService = FakeLoggerService(),
561
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
562
+ )
563
+
564
+ // Simulate disconnection
565
+ stateMachine.handleDeviceDisconnected()
566
+
567
+ // Request sending an APDU
568
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
569
+ apdu = mockedRequestApduA,
570
+ triggersDisconnection = false,
571
+ resultCallback = { result -> sendApduResult = result }
572
+ ))
573
+
574
+ // Request closing the connection
575
+ stateMachine.requestCloseConnection()
576
+
577
+ // APDU request should have failed
578
+ assertEquals(
579
+ SendApduResult.Failure(SendApduFailureReason.DeviceDisconnected),
580
+ sendApduResult
581
+ )
582
+
583
+ assertTrue(terminated)
584
+ assertNull(error)
585
+ }
586
+
587
+ @Test
588
+ fun `GIVEN the device connection state machine with a queued APDU WHEN the reconnection times out THEN the machine terminates and the APDU request fails`() =
589
+ runTest {
590
+ var sendApduResult: SendApduResult? = null
591
+ var terminated = false
592
+ var error: Throwable? = null
593
+
594
+ val stateMachine = DeviceConnectionStateMachine(
595
+ sendApduFn = { },
596
+ onTerminated = { terminated = true },
597
+ isFatalSendApduFailure = { false },
598
+ reconnectionTimeoutDuration = 5.seconds,
599
+ onError = { error = it },
600
+ loggerService = FakeLoggerService(),
601
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
602
+ )
603
+
604
+ // Simulate disconnection
605
+ stateMachine.handleDeviceDisconnected()
606
+
607
+ // Request sending an APDU
608
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
609
+ apdu = byteArrayOf(0x0A),
610
+ triggersDisconnection = false,
611
+ resultCallback = { result -> sendApduResult = result }
612
+ ))
613
+
614
+ // Simulate timeout
615
+ advanceTimeBy(5.seconds + 1.milliseconds)
616
+ assertEquals(
617
+ SendApduResult.Failure(SendApduFailureReason.DeviceDisconnected),
618
+ sendApduResult
619
+ )
620
+
621
+ assertTrue(terminated)
622
+ assertNull(error)
623
+ }
624
+
625
+ @Test
626
+ fun `GIVEN the device connection state machine in queued state WHEN a new APDU request is received THEN it returns a DeviceBusy failure`() =
627
+ runTest {
628
+ var firstSendApduResult: SendApduResult? = null
629
+ var secondSendApduResult: SendApduResult? = null
630
+ var terminated = false
631
+ var error: Throwable? = null
632
+
633
+ val stateMachine = DeviceConnectionStateMachine(
634
+ sendApduFn = { },
635
+ onTerminated = { terminated = true },
636
+ isFatalSendApduFailure = { false },
637
+ reconnectionTimeoutDuration = 5.seconds,
638
+ onError = { error = it },
639
+ loggerService = FakeLoggerService(),
640
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
641
+ )
642
+
643
+ // Simulate disconnection
644
+ stateMachine.handleDeviceDisconnected()
645
+
646
+ // Request sending an APDU
647
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
648
+ apdu = byteArrayOf(0x0A),
649
+ triggersDisconnection = false,
650
+ resultCallback = { firstSendApduResult = it }
651
+ ))
652
+
653
+ // Second APDU sending request
654
+ stateMachine.requestSendApdu(DeviceConnectionStateMachine.SendApduRequestContent(
655
+ apdu = byteArrayOf(0x0B),
656
+ triggersDisconnection = false,
657
+ resultCallback = { secondSendApduResult = it }
658
+ ))
659
+
660
+ // Second request should immediately return busy.
661
+ assertEquals(
662
+ SendApduResult.Failure(SendApduFailureReason.DeviceBusy),
663
+ secondSendApduResult
664
+ )
665
+
666
+ // Simulate reconnection
667
+ stateMachine.handleDeviceConnected()
668
+
669
+ // Simulate response (to first request)
670
+ val mockedApduResult = SendApduResult.Success(mockedResultApduSuccessA)
671
+ stateMachine.handleApduResult(mockedApduResult)
672
+
673
+ // First Request should have received a result
674
+ assertEquals(mockedApduResult, firstSendApduResult)
675
+
676
+ assertFalse(terminated)
677
+ assertNull(error)
678
+ }
679
+
680
+ @Test
681
+ fun `GIVEN the device connection state machine in Terminated state WHEN any event occurs THEN it triggers onError`() =
682
+ runTest {
683
+ var errorCalled: Throwable? = null
684
+
685
+ val stateMachine = DeviceConnectionStateMachine(
686
+ sendApduFn = { },
687
+ onTerminated = { },
688
+ isFatalSendApduFailure = { false },
689
+ reconnectionTimeoutDuration = reconnectionTimeout,
690
+ onError = { errorCalled = it },
691
+ loggerService = FakeLoggerService(),
692
+ coroutineDispatcher = StandardTestDispatcher(testScheduler)
693
+ )
694
+
695
+ // Terminate machine.
696
+ stateMachine.requestCloseConnection()
697
+ // Any subsequent event in Terminated state (e.g. device connected) is unhandled.
698
+ stateMachine.handleDeviceConnected()
699
+ assertNotNull(errorCalled)
700
+ }
701
+
702
+ companion object {
703
+ internal class FakeLoggerService : LoggerService {
704
+ override fun log(info: LogInfo) {}
705
+ }
706
+
707
+ val reconnectionTimeout: Duration = 5.seconds
708
+ val mockedRequestApduA: ByteArray = byteArrayOf(0x01, 0x02)
709
+ val mockedRequestApduB: ByteArray = byteArrayOf(0x03, 0x04)
710
+ val mockedResultApduSuccessA: ByteArray = byteArrayOf(0x05, 0x06, 0x90.toByte(), 0x00)
711
+ val mockedResultApduSuccessB: ByteArray = byteArrayOf(0x07, 0x08, 0x90.toByte(), 0x00)
712
+ }
713
+ }