@icedevml/react-native-host-card-emulation 0.9.2 → 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.
@@ -0,0 +1,558 @@
1
+ package com.itsecrnd.rtnhceandroid;
2
+
3
+ import android.app.Application;
4
+ import android.nfc.cardemulation.HostApduService;
5
+ import android.os.Bundle;
6
+ import android.os.Handler;
7
+ import android.os.Looper;
8
+ import android.util.Log;
9
+
10
+ import androidx.annotation.NonNull;
11
+ import androidx.annotation.Nullable;
12
+
13
+ import com.facebook.react.ReactApplication;
14
+ import com.facebook.react.ReactHost;
15
+ import com.facebook.react.ReactInstanceEventListener;
16
+ import com.facebook.react.bridge.Arguments;
17
+ import com.facebook.react.bridge.NativeModule;
18
+ import com.facebook.react.bridge.ReactContext;
19
+ import com.facebook.react.bridge.UiThreadUtil;
20
+ import com.facebook.react.common.LifecycleState;
21
+ import com.facebook.react.jstasks.HeadlessJsTaskConfig;
22
+ import com.facebook.react.jstasks.HeadlessJsTaskContext;
23
+
24
+ import java.util.UUID;
25
+
26
+ /**
27
+ * Process-wide state machine of the HCE interactions. The single source of truth for:
28
+ * - the foreground session (created by the JS app through beginSession());
29
+ * - the current activation, i.e. the link with the reader since the first C-APDU until deactivation.
30
+ * <p>
31
+ * All methods are synchronized, as they're called from the main thread (HostApduService callbacks,
32
+ * headless task lifecycle) and from the JS thread (module methods). None of them blocks on the
33
+ * other thread: event emission and sendResponseApdu() are both asynchronous.
34
+ */
35
+ final class HCEManager {
36
+ private static final String TAG = "HCEManager";
37
+
38
+ static final String BACKGROUND_TASK_KEY = "handleBackgroundHCECall";
39
+
40
+ // ISO 7816-4: file or application not found (response to SELECT that can't be served)
41
+ private static final byte[] SW_APPLICATION_NOT_FOUND = {(byte) 0x6A, (byte) 0x82};
42
+ // ISO 7816-4: conditions of use not satisfied (response to other commands that can't be served)
43
+ private static final byte[] SW_CONDITIONS_NOT_SATISFIED = {(byte) 0x69, (byte) 0x85};
44
+ // ISO 7816-4: no precise diagnosis (internal error)
45
+ static final byte[] SW_NO_PRECISE_DIAGNOSIS = {(byte) 0x6F, (byte) 0x00};
46
+
47
+ /*
48
+ How long a background interaction may wait for React Native to start and for the JS side to declare
49
+ whether it has a background handler. Generous, as a cold start on a slow device might take a while.
50
+ */
51
+ private static final long ROUTING_TIMEOUT_MS = 15_000;
52
+
53
+ private static final HCEManager INSTANCE = new HCEManager();
54
+
55
+ static HCEManager getInstance() {
56
+ return INSTANCE;
57
+ }
58
+
59
+ private static final class ForegroundSession {
60
+ final String id;
61
+ final RTNHCEAndroidModule module;
62
+ final boolean allowWhenLocked;
63
+ boolean emulationRunning;
64
+
65
+ ForegroundSession(String id, RTNHCEAndroidModule module, boolean allowWhenLocked) {
66
+ this.id = id;
67
+ this.module = module;
68
+ this.allowWhenLocked = allowWhenLocked;
69
+ }
70
+ }
71
+
72
+ private enum Target {
73
+ // C-APDUs are handled by the foreground session
74
+ SESSION,
75
+ // C-APDUs are handled by the background handler (headless JS task)
76
+ BACKGROUND,
77
+ // nobody is able to handle C-APDUs, they're rejected with an error status word
78
+ REJECTED,
79
+ }
80
+
81
+ private static final class Activation {
82
+ final HostApduService service;
83
+ Target target;
84
+ String sessionId;
85
+ RTNHCEAndroidModule module;
86
+ // whether the JS side is listening for the events of this activation
87
+ boolean ready;
88
+ // whether the activation waits for the JS side to declare whether it has a background handler
89
+ boolean awaitingJs;
90
+ int taskId = -1;
91
+ // whether the C-APDUs may be handled while the device is locked, null until the handler is known
92
+ @Nullable
93
+ Boolean allowWhenLocked;
94
+ // whether the device was locked when the most recent C-APDU was received
95
+ boolean deviceLocked;
96
+
97
+ // set while waiting for React Native to start
98
+ @Nullable
99
+ ReactHost reactHost;
100
+ @Nullable
101
+ ReactInstanceEventListener reactListener;
102
+ // set while waiting for the background handler to be known
103
+ @Nullable
104
+ Runnable routingTimeout;
105
+
106
+ long pendingApduId;
107
+ byte[] pendingCommand;
108
+ boolean pendingDeviceLocked;
109
+
110
+ Activation(HostApduService service, boolean deviceLocked) {
111
+ this.service = service;
112
+ this.deviceLocked = deviceLocked;
113
+ }
114
+ }
115
+
116
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
117
+ private ForegroundSession foregroundSession;
118
+ private Activation activation;
119
+ private long lastApduId;
120
+
121
+ private HCEManager() {
122
+ }
123
+
124
+ private static boolean isSelectByName(byte[] command) {
125
+ return command.length >= 4 && command[1] == (byte) 0xA4 && command[2] == (byte) 0x04;
126
+ }
127
+
128
+ private static byte[] notAvailableResponse(byte[] command) {
129
+ return isSelectByName(command) ? SW_APPLICATION_NOT_FOUND.clone() : SW_CONDITIONS_NOT_SATISFIED.clone();
130
+ }
131
+
132
+ @Nullable
133
+ private static ReactHost getReactHost(@NonNull Application application) {
134
+ if (!(application instanceof ReactApplication)) {
135
+ Log.e(TAG, "The Application class doesn't implement ReactApplication");
136
+ return null;
137
+ }
138
+
139
+ try {
140
+ return ((ReactApplication) application).getReactHost();
141
+ } catch (RuntimeException e) {
142
+ Log.e(TAG, "getReactHost() failed", e);
143
+ return null;
144
+ }
145
+ }
146
+
147
+ @Nullable
148
+ private static RTNHCEAndroidModule getModule(@NonNull ReactContext reactContext) {
149
+ try {
150
+ NativeModule module = reactContext.getNativeModule(RTNHCEAndroidModule.NAME);
151
+ return module instanceof RTNHCEAndroidModule ? (RTNHCEAndroidModule) module : null;
152
+ } catch (RuntimeException e) {
153
+ Log.e(TAG, "getNativeModule() failed", e);
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /* ---------------------------------------------------------------------------------------------
159
+ * HostApduService callbacks (main thread)
160
+ * ------------------------------------------------------------------------------------------- */
161
+
162
+ /**
163
+ * @return the response to send synchronously or null if the response will be sent asynchronously
164
+ */
165
+ synchronized byte[] onCommand(@NonNull HostApduService service, @NonNull byte[] command, boolean deviceLocked) {
166
+ if (activation != null && activation.service != service) {
167
+ Log.w(TAG, "onCommand: C-APDU from a different service instance, ending the previous activation");
168
+ endActivation(activation);
169
+ activation = null;
170
+ }
171
+
172
+ if (activation != null && activation.target == Target.REJECTED && isSelectByName(command)) {
173
+ // the reader re-selects the application, it might be possible to serve it now
174
+ activation = null;
175
+ }
176
+
177
+ if (activation == null) {
178
+ activation = startActivation(service, deviceLocked);
179
+ }
180
+
181
+ Activation a = activation;
182
+ a.deviceLocked = deviceLocked;
183
+
184
+ if (a.target == Target.REJECTED) {
185
+ return notAvailableResponse(command);
186
+ }
187
+
188
+ if (deviceLocked && Boolean.FALSE.equals(a.allowWhenLocked)) {
189
+ // the device was locked during the interaction, the C-APDU isn't delivered to the JS side
190
+ Log.d(TAG, "onCommand: the device is locked and the handler doesn't allow that, rejecting the C-APDU");
191
+ // a late response to the previous C-APDU must not be taken as the response to this one
192
+ a.pendingCommand = null;
193
+ return notAvailableResponse(command);
194
+ }
195
+
196
+ if (a.pendingCommand != null) {
197
+ Log.w(TAG, "onCommand: received a C-APDU while the previous one wasn't responded to, dropping the previous one");
198
+ }
199
+
200
+ a.pendingApduId = ++lastApduId;
201
+ a.pendingCommand = command;
202
+ a.pendingDeviceLocked = deviceLocked;
203
+
204
+ if (a.ready) {
205
+ emitPendingApdu(a);
206
+ }
207
+
208
+ return null;
209
+ }
210
+
211
+ synchronized void onDeactivated(@NonNull HostApduService service, int reason) {
212
+ Log.d(TAG, "onDeactivated: " + reason);
213
+
214
+ if (activation != null && activation.service == service) {
215
+ endActivation(activation);
216
+ activation = null;
217
+ }
218
+ }
219
+
220
+ synchronized void onServiceDestroyed(@NonNull HostApduService service) {
221
+ if (activation != null && activation.service == service) {
222
+ endActivation(activation);
223
+ activation = null;
224
+ }
225
+ }
226
+
227
+ /* ---------------------------------------------------------------------------------------------
228
+ * Activation handling
229
+ * ------------------------------------------------------------------------------------------- */
230
+
231
+ private Activation startActivation(@NonNull HostApduService service, boolean deviceLocked) {
232
+ Activation a = new Activation(service, deviceLocked);
233
+ ForegroundSession fg = foregroundSession;
234
+
235
+ if (fg != null && fg.emulationRunning) {
236
+ Log.d(TAG, "startActivation: foreground session " + fg.id);
237
+ a.target = Target.SESSION;
238
+ a.sessionId = fg.id;
239
+ a.module = fg.module;
240
+ a.allowWhenLocked = fg.allowWhenLocked;
241
+
242
+ if (deviceLocked && !fg.allowWhenLocked) {
243
+ Log.d(TAG, "startActivation: the device is locked and the session doesn't allow that, rejecting");
244
+ a.target = Target.REJECTED;
245
+ return a;
246
+ }
247
+
248
+ a.ready = true;
249
+ fg.module.emitEvent(fg.id, "readerDetected");
250
+ return a;
251
+ }
252
+
253
+ a.target = Target.BACKGROUND;
254
+ a.sessionId = UUID.randomUUID().toString();
255
+
256
+ final ReactHost reactHost = getReactHost(service.getApplication());
257
+
258
+ if (reactHost == null) {
259
+ a.target = Target.REJECTED;
260
+ return a;
261
+ }
262
+
263
+ ReactContext reactContext = reactHost.getCurrentReactContext();
264
+
265
+ if (reactContext != null) {
266
+ routeToBackgroundHandler(a, getModule(reactContext), reactContext);
267
+ } else {
268
+ Log.d(TAG, "startActivation: starting React Native for background handler " + a.sessionId);
269
+ final Activation pendingActivation = a;
270
+
271
+ a.reactHost = reactHost;
272
+ a.reactListener = new ReactInstanceEventListener() {
273
+ @Override
274
+ public void onReactContextInitialized(@NonNull ReactContext context) {
275
+ // one-shot listener, otherwise it would leak and fire again on every reload
276
+ reactHost.removeReactInstanceEventListener(this);
277
+ // HeadlessJsTaskContext must be used on the main thread
278
+ UiThreadUtil.runOnUiThread(() -> onReactContextReady(pendingActivation, context));
279
+ }
280
+ };
281
+ reactHost.addReactInstanceEventListener(a.reactListener);
282
+ reactHost.start();
283
+ }
284
+
285
+ if (a.target == Target.BACKGROUND && a.module == null) {
286
+ // don't hold the reader forever if React Native fails to start or the JS side never declares the handler
287
+ final Activation waitingActivation = a;
288
+ a.routingTimeout = () -> onRoutingTimeout(waitingActivation);
289
+ mainHandler.postDelayed(a.routingTimeout, ROUTING_TIMEOUT_MS);
290
+ }
291
+
292
+ return a;
293
+ }
294
+
295
+ private synchronized void onRoutingTimeout(Activation a) {
296
+ if (activation == a && a.target == Target.BACKGROUND && a.module == null) {
297
+ Log.w(TAG, "onRoutingTimeout: the background handler is still unknown, rejecting " + a.sessionId);
298
+ rejectActivation(a);
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Stop waiting for React Native and for the background handler declaration.
304
+ */
305
+ private void stopRouting(Activation a) {
306
+ if (a.routingTimeout != null) {
307
+ mainHandler.removeCallbacks(a.routingTimeout);
308
+ a.routingTimeout = null;
309
+ }
310
+
311
+ if (a.reactHost != null && a.reactListener != null) {
312
+ a.reactHost.removeReactInstanceEventListener(a.reactListener);
313
+ }
314
+
315
+ a.reactHost = null;
316
+ a.reactListener = null;
317
+ }
318
+
319
+ private synchronized void onReactContextReady(Activation a, ReactContext reactContext) {
320
+ // the listener has already removed itself
321
+ a.reactHost = null;
322
+ a.reactListener = null;
323
+
324
+ if (activation != a || a.target != Target.BACKGROUND || a.module != null) {
325
+ // the activation has already ended
326
+ return;
327
+ }
328
+
329
+ routeToBackgroundHandler(a, getModule(reactContext), reactContext);
330
+ }
331
+
332
+ /**
333
+ * The React context exists before the JS bundle is evaluated, so the module (or the JS side's
334
+ * declaration of the background handler) might not be there yet. In such case, the activation
335
+ * holds the C-APDU until onBackgroundHandlerDeclared() or the reader is lost.
336
+ */
337
+ // must be called on the main thread
338
+ private void routeToBackgroundHandler(Activation a, @Nullable RTNHCEAndroidModule module, ReactContext reactContext) {
339
+ RTNHCEAndroidModule.BackgroundHandlerDeclaration declaration =
340
+ module != null ? module.getBackgroundHandlerDeclaration() : null;
341
+
342
+ if (declaration == null) {
343
+ Log.d(TAG, "routeToBackgroundHandler: waiting for the JS side " + a.sessionId);
344
+ a.awaitingJs = true;
345
+ return;
346
+ }
347
+
348
+ a.awaitingJs = false;
349
+
350
+ if (!declaration.registered) {
351
+ Log.d(TAG, "routeToBackgroundHandler: no session and no background handler, rejecting");
352
+ rejectActivation(a);
353
+ return;
354
+ }
355
+
356
+ if (a.deviceLocked && !declaration.allowWhenLocked) {
357
+ Log.d(TAG, "routeToBackgroundHandler: the device is locked and the background handler doesn't allow that, rejecting");
358
+ rejectActivation(a);
359
+ return;
360
+ }
361
+
362
+ a.allowWhenLocked = declaration.allowWhenLocked;
363
+
364
+ Log.d(TAG, "routeToBackgroundHandler: background handler " + a.sessionId);
365
+ startBackgroundTask(a, module, reactContext);
366
+ }
367
+
368
+ // must be called on the main thread
369
+ private void startBackgroundTask(Activation a, RTNHCEAndroidModule module, ReactContext reactContext) {
370
+ stopRouting(a);
371
+ a.module = module;
372
+ ReactContext taskContext = module.getContext();
373
+
374
+ if (!taskContext.hasActiveReactInstance()) {
375
+ Log.e(TAG, "startBackgroundTask: no active React instance");
376
+ rejectActivation(a);
377
+ return;
378
+ }
379
+
380
+ boolean foreground = reactContext.getLifecycleState() == LifecycleState.RESUMED;
381
+ Bundle data = new Bundle();
382
+ data.putString("sessionId", a.sessionId);
383
+ data.putString("appState", foreground ? "foreground" : "background");
384
+
385
+ try {
386
+ a.taskId = HeadlessJsTaskContext.getInstance(taskContext).startTask(
387
+ new HeadlessJsTaskConfig(
388
+ BACKGROUND_TASK_KEY,
389
+ Arguments.fromBundle(data),
390
+ // no timeout, the JS side finishes the task when the reader is lost
391
+ 0,
392
+ // also used when the app is in foreground, but has no active session
393
+ true
394
+ ));
395
+ } catch (RuntimeException | AssertionError e) {
396
+ Log.e(TAG, "startBackgroundTask: failed to start the headless task", e);
397
+ rejectActivation(a);
398
+ }
399
+ }
400
+
401
+ private void emitPendingApdu(Activation a) {
402
+ a.module.emitEvent(
403
+ a.sessionId,
404
+ "apdu",
405
+ a.pendingApduId,
406
+ BinaryUtils.ByteArrayToHexString(a.pendingCommand),
407
+ a.pendingDeviceLocked,
408
+ "");
409
+ }
410
+
411
+ /**
412
+ * Stop routing C-APDUs to the JS side for the rest of the activation.
413
+ * The pending C-APDU (if any) is responded to with an error status word.
414
+ */
415
+ private void rejectActivation(Activation a) {
416
+ stopRouting(a);
417
+ a.target = Target.REJECTED;
418
+ a.ready = false;
419
+
420
+ if (a.pendingCommand != null) {
421
+ a.service.sendResponseApdu(notAvailableResponse(a.pendingCommand));
422
+ a.pendingCommand = null;
423
+ }
424
+ }
425
+
426
+ private void endActivation(Activation a) {
427
+ stopRouting(a);
428
+
429
+ if (a.ready && a.module != null) {
430
+ a.module.emitEvent(a.sessionId, "readerLost");
431
+ }
432
+
433
+ a.ready = false;
434
+ a.target = Target.REJECTED;
435
+ a.pendingCommand = null;
436
+ }
437
+
438
+ /* ---------------------------------------------------------------------------------------------
439
+ * Module callbacks (JS thread, main thread for headless task events)
440
+ * ------------------------------------------------------------------------------------------- */
441
+
442
+ synchronized void beginSession(RTNHCEAndroidModule module, String sessionId, boolean allowWhenLocked) {
443
+ if (foregroundSession != null) {
444
+ throw new IllegalStateException("A session already exists.");
445
+ }
446
+
447
+ foregroundSession = new ForegroundSession(sessionId, module, allowWhenLocked);
448
+ module.emitEvent(sessionId, "sessionStarted");
449
+ }
450
+
451
+ synchronized void invalidateSession(RTNHCEAndroidModule module, String sessionId) {
452
+ ForegroundSession fg = foregroundSession;
453
+
454
+ if (fg == null || fg.module != module || !fg.id.equals(sessionId)) {
455
+ return;
456
+ }
457
+
458
+ detachSessionActivation(fg);
459
+ foregroundSession = null;
460
+ module.emitEvent(sessionId, "sessionInvalidated", 0, "", false, "userInvalidated");
461
+ }
462
+
463
+ synchronized void startEmulation(RTNHCEAndroidModule module, String sessionId) {
464
+ requireSession(module, sessionId).emulationRunning = true;
465
+ }
466
+
467
+ synchronized void stopEmulation(RTNHCEAndroidModule module, String sessionId) {
468
+ ForegroundSession fg = requireSession(module, sessionId);
469
+ fg.emulationRunning = false;
470
+ detachSessionActivation(fg);
471
+ }
472
+
473
+ synchronized boolean isEmulationRunning(RTNHCEAndroidModule module, String sessionId) {
474
+ ForegroundSession fg = foregroundSession;
475
+ return fg != null && fg.module == module && fg.id.equals(sessionId) && fg.emulationRunning;
476
+ }
477
+
478
+ synchronized boolean hasRunningEmulation(RTNHCEAndroidModule module) {
479
+ ForegroundSession fg = foregroundSession;
480
+ return fg != null && fg.module == module && fg.emulationRunning;
481
+ }
482
+
483
+ private ForegroundSession requireSession(RTNHCEAndroidModule module, String sessionId) {
484
+ ForegroundSession fg = foregroundSession;
485
+
486
+ if (fg == null || fg.module != module || !fg.id.equals(sessionId)) {
487
+ throw new IllegalStateException("The session is not active.");
488
+ }
489
+
490
+ return fg;
491
+ }
492
+
493
+ private void detachSessionActivation(ForegroundSession fg) {
494
+ Activation a = activation;
495
+
496
+ if (a != null && a.target == Target.SESSION && fg.id.equals(a.sessionId)) {
497
+ a.module.emitEvent(a.sessionId, "readerLost");
498
+ rejectActivation(a);
499
+ }
500
+ }
501
+
502
+ // main thread
503
+ synchronized void onBackgroundHandlerDeclared(RTNHCEAndroidModule module) {
504
+ Activation a = activation;
505
+
506
+ if (a != null && a.target == Target.BACKGROUND && a.awaitingJs) {
507
+ routeToBackgroundHandler(a, module, module.getContext());
508
+ }
509
+ }
510
+
511
+ synchronized boolean backgroundSessionReady(RTNHCEAndroidModule module, String sessionId) {
512
+ Activation a = activation;
513
+
514
+ if (a == null || a.target != Target.BACKGROUND || a.module != module || !sessionId.equals(a.sessionId)) {
515
+ return false;
516
+ }
517
+
518
+ a.ready = true;
519
+
520
+ if (a.pendingCommand != null) {
521
+ emitPendingApdu(a);
522
+ }
523
+
524
+ return true;
525
+ }
526
+
527
+ synchronized void respondApdu(RTNHCEAndroidModule module, String sessionId, long apduId, byte[] response) {
528
+ Activation a = activation;
529
+
530
+ if (a == null || a.target == Target.REJECTED || a.module != module || !sessionId.equals(a.sessionId)
531
+ || a.pendingCommand == null || a.pendingApduId != apduId) {
532
+ throw new IllegalStateException("The C-APDU is not awaiting a response.");
533
+ }
534
+
535
+ a.pendingCommand = null;
536
+ a.service.sendResponseApdu(response);
537
+ }
538
+
539
+ synchronized void onHeadlessTaskFinished(RTNHCEAndroidModule module, int taskId) {
540
+ Activation a = activation;
541
+
542
+ if (a != null && a.target == Target.BACKGROUND && a.module == module && a.taskId == taskId) {
543
+ // the background handler has finished (or crashed) while the reader is still there
544
+ Log.w(TAG, "onHeadlessTaskFinished: the background handler finished before the reader was lost");
545
+ rejectActivation(a);
546
+ }
547
+ }
548
+
549
+ synchronized void onModuleInvalidated(RTNHCEAndroidModule module) {
550
+ if (foregroundSession != null && foregroundSession.module == module) {
551
+ foregroundSession = null;
552
+ }
553
+
554
+ if (activation != null && activation.module == module) {
555
+ rejectActivation(activation);
556
+ }
557
+ }
558
+ }