@icedevml/react-native-host-card-emulation 0.4.1 → 0.6.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.
package/README.md CHANGED
@@ -1,12 +1,17 @@
1
- # Package: React Native HCE Module
1
+ # React Native Package: @icedevml/react-native-host-card-emulation
2
2
 
3
3
  Host Card Emulation Module for React Native on iOS and Android platforms.
4
4
 
5
+ > [!NOTE]
6
+ > Pull requests are welcome! Please [report an issue](https://github.com/icedevml/react-native-host-card-emulation/issues) if you are running into any difficulties.
7
+
5
8
  > [!IMPORTANT]
6
9
  > As per Apple's policy, **Host Card Emulation feature is only available for users based in the European
7
10
  > Economic Area (EEA)** with an iPhone running iOS 17.4 or later. Applications require a special HCE entitlement
8
11
  > in order to use this feature, which is managed by Apple. Read more in the official support article:
9
12
  > "[HCE-based contactless NFC transactions for apps in the European Economic Area (EEA)](https://developer.apple.com/support/hce-transactions-in-apps/)".
13
+ >
14
+ > However, you can still use this package without any limitations if you only want to cover for Android implementation.
10
15
 
11
16
  ## Prerequisites
12
17
  This package was designed for React Native [New Architecture](https://reactnative.dev/architecture/landing-page) only.
@@ -64,9 +69,8 @@ Afterwards, follow the subsections below for each platform that you need to supp
64
69
  ...
65
70
 
66
71
  + <uses-permission android:name="android.permission.NFC" />
72
+ + <!-- set required="true" if your app would be entirely useless on devices without HCE support -->
67
73
  + <uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
68
- + <permission android:name="com.itsecrnd.rtnhceandroid.HCE_BROADCAST" android:protectionLevel="signature" />
69
- + <uses-permission android:name="com.itsecrnd.rtnhceandroid.HCE_BROADCAST"/>
70
74
 
71
75
  ...
72
76
 
@@ -77,7 +81,7 @@ Afterwards, follow the subsections below for each platform that you need to supp
77
81
  ...
78
82
 
79
83
  + <service
80
- + android:name="com.itsecrnd.rtnhceandroid.service.HCEService"
84
+ + android:name="com.itsecrnd.rtnhceandroid.HCEService"
81
85
  + android:exported="true"
82
86
  + android:enabled="true"
83
87
  + android:permission="android.permission.BIND_NFC_SERVICE">
@@ -113,18 +117,18 @@ Afterwards, follow the subsections below for each platform that you need to supp
113
117
  This module provides a uniform low-level HCE API for both mobile platforms.
114
118
 
115
119
  > [!NOTE]
116
- > Raw native module's API specification is available in [packages/native-hce-module/js/NativeHCEModule.ts](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/native-hce-module/js/NativeHCEModule.ts).
120
+ > Raw native module's API specification is available in [packages/host-card-emulation/js/NativeHCEModule.ts](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/host-card-emulation/js/NativeHCEModule.ts).
117
121
  > Check it out in order to understand what methods you can call against the module and what are the expected parameters/return values.
118
122
 
119
123
  > [!NOTE]
120
- > See [Demo App's Code (Example Library Usage)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/demo-hce-module-app/App.tsx) example for more insignt about the library's API.
124
+ > See [Demo App's Code (Example Library Usage)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/demo-ndef-app/App.tsx) example for more insignt about the library's API.
121
125
 
122
126
  ### Quick start guide
123
127
 
124
128
  1. Subscribe to the event stream. There is a single global event stream, so you can subscribe early and remain subscribed to events for the entire application's lifetime.
125
129
  ```typescript
126
130
  useEffect(() => {
127
- const removeListener = NativeHCEModule?.onEvent(async (event: HCEModuleEvent) => {
131
+ const subscription = NativeHCEModule?.onEvent(async (event: HCEModuleEvent) => {
128
132
  switch (event.type) {
129
133
  /* ... implement event handlers here ... */
130
134
  }
@@ -132,7 +136,7 @@ This module provides a uniform low-level HCE API for both mobile platforms.
132
136
 
133
137
  // cleanup the event listener when the effect is unmounted
134
138
  return () => {
135
- removeListener();
139
+ subscription.remove();
136
140
  }
137
141
  }, []);
138
142
  ```
@@ -163,7 +167,7 @@ This module provides a uniform low-level HCE API for both mobile platforms.
163
167
  break;
164
168
  ```
165
169
  For those events, trigger mechanisms are platform dependent:
166
- * iOS: The `readerDetected` event will be emitted as soon as the NFC tag field presence is observed. The `readerDeselected` event will be emitted if the reader is physically disconnected or a non-matching AID is selected by the reader.
170
+ * iOS: The `readerDetected` event will be emitted as soon as the NFC reader's field presence is observed. The `readerDeselected` event will be emitted if the reader is physically disconnected or a non-matching AID is selected by the reader.
167
171
  * Android: The `readerDetected` event will be emitted as soon as the first matching SELECT AID command is observed. The `readerDeselected` event will be emitted if the reader is physically disconnected or a non-matching AID is selected by the reader.
168
172
  4. Receive incoming C-APDU and respond to it:
169
173
  ```typescript
@@ -176,12 +180,13 @@ This module provides a uniform low-level HCE API for both mobile platforms.
176
180
  console.log('Received C-APDU, capdu.toString('hex'));
177
181
 
178
182
  // for the demo purposes, we always want to respond with [0x0A] + status code 0x9000 (success)
179
- await NativeHCEModule?.respondAPDU(Buffer.from([0x0A, 0x90, 0x00], "hex"));
183
+ await NativeHCEModule?.respondAPDU(null, Buffer.from([0x0A, 0x90, 0x00], "hex"));
180
184
  break;
181
185
  ```
182
186
  You don't have to respond to the APDU right away from within the event handler, but please remember that the reader might time out if you will be lingering with the response for too long.
183
187
 
184
188
  ### iOS: Acquiring exclusive NFC access
189
+
185
190
  If you need to utilize `NFCPresentmentIntentAssertion` for enhanced user experience, call:
186
191
  ```typescript
187
192
  NativeHCEModule?.acquireExclusiveNFC();
@@ -195,7 +200,57 @@ This function will throw an exception if:
195
200
 
196
201
  Call `NativeHCEModule?.isExclusiveNFC()` to check if exclusive NFC access is still active.
197
202
 
203
+ ### Android: Handle HCE calls when the app is not running
204
+
205
+ Exclusively on Android platform it is possible to handle Host Card Emulation even when the app is not running in foreground.
206
+ In such case, no user interface will be available, the handler will run in headless mode. This is still extremely useful in certain cases,
207
+ for instance - your app may emulate an NDEF tag even when it's not launched on the foreground.
208
+
209
+ 1. Register `handleBackgroundHCECall` headless task in your app's `index.js`:
210
+ ```typescript
211
+ import { createBackgroundHCE } from '@icedevml/react-native-host-card-emulation/js/hceBackground';
212
+
213
+ AppRegistry.registerHeadlessTask('handleBackgroundHCECall', () => {
214
+ return async (taskData) => {
215
+ return await runBackgroundHCETask(createBackgroundHCE(taskData.handle));
216
+ }
217
+ });
218
+ ```
219
+ 2. Register onBackgroundEvent listener in your `runBackground()` function and call to `await NativeHCEModule?.initBackgroundHCE()` at the very end, after the event listener is fully set up.
220
+ ```typescript
221
+ import { Buffer } from 'buffer/';
222
+ import {
223
+ ProcessBackgroundHCEFunc,
224
+ } from '@icedevml/react-native-host-card-emulation/js/hceBackground';
225
+
226
+ export default async function runBackgroundHCETask(processBackgroundHCE: ProcessBackgroundHCEFunc) {
227
+ processBackgroundHCE(async (event, respondAPDU) => {
228
+ switch (event.type) {
229
+ /* ... background HCE event handler here ... */
230
+ }
231
+ });
232
+ }
233
+ ```
234
+ 3. Cleanup your event listener in `readerDeselected` -- this is obligatory because lack of proper cleanup will result in bugs and unexpected behavior.
235
+ ```typescript
236
+ case 'readerDeselected':
237
+ // our application was deselected or the reader field was lost
238
+ // perform a cleanup of any resources here
239
+ break;
240
+ ```
241
+ 4. Implement the actual APDU handler that will process the C-APDU and generate a response.
242
+ ```typescript
243
+ case 'received':
244
+ // decode incoming C-APDU to bytes
245
+ const capdu = Buffer.from(event.arg!, 'hex');
246
+ console.log('Received C-APDU, capdu.toString('hex'));
247
+
248
+ // for the demo purposes, we always want to respond with [0x0A] + status code 0x9000 (success)
249
+ await respondAPDU(Buffer.from([0x0A, 0x90, 0x00]).toString("hex"));
250
+ break;
251
+ ```
252
+
198
253
  ### More resources
199
254
 
200
- * [Module's API specification](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/native-hce-module/js/NativeHCEModule.ts)
201
- * [Demo App's Code (Example Library Usage)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/demo-hce-module-app/App.tsx)
255
+ * [Module's API specification](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/host-card-emulation/js/NativeHCEModule.ts)
256
+ * [Demo App's Code (Example Library Usage)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/demo-ndef-app/App.tsx)
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- package com.itsecrnd.rtnhceandroid.util;
23
+ package com.itsecrnd.rtnhceandroid;
24
24
 
25
25
  public class BinaryUtils {
26
26
  /**
@@ -0,0 +1,257 @@
1
+ /*
2
+ * Some of the code inspired by:
3
+ * https://github.com/appidea/react-native-hce
4
+ * https://github.com/transistorsoft/react-native-background-fetch
5
+ */
6
+
7
+ package com.itsecrnd.rtnhceandroid;
8
+
9
+ import static com.facebook.react.jstasks.HeadlessJsTaskContext.Companion;
10
+
11
+ import android.app.ActivityManager;
12
+ import android.content.Context;
13
+ import android.nfc.cardemulation.HostApduService;
14
+ import android.os.Build;
15
+ import android.os.Bundle;
16
+ import android.util.Log;
17
+
18
+ import androidx.annotation.NonNull;
19
+ import androidx.annotation.RequiresApi;
20
+
21
+ import com.facebook.react.ReactApplication;
22
+ import com.facebook.react.ReactHost;
23
+ import com.facebook.react.ReactInstanceEventListener;
24
+ import com.facebook.react.bridge.Arguments;
25
+ import com.facebook.react.bridge.ReactContext;
26
+ import com.facebook.react.bridge.UiThreadUtil;
27
+ import com.facebook.react.jstasks.HeadlessJsTaskConfig;
28
+ import com.facebook.react.jstasks.HeadlessJsTaskContext;
29
+ import com.facebook.react.jstasks.HeadlessJsTaskEventListener;
30
+
31
+ import java.util.List;
32
+ import java.util.Locale;
33
+ import java.util.UUID;
34
+
35
+ @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
36
+ public class HCEService extends HostApduService implements ReactInstanceEventListener, HCEServiceCallback {
37
+ private static final String TAG = "HCEService";
38
+
39
+ private boolean isForeground;
40
+ private String backgroundSessionUUID;
41
+ private RTNHCEAndroidModule hceModule;
42
+ private byte[] pendingCAPDU;
43
+ private volatile boolean needsResponse;
44
+
45
+ private boolean isAppOnForeground(Context context) {
46
+ /*
47
+ We need to check if app is in foreground otherwise the app will crash.
48
+ https://stackoverflow.com/questions/8489993/check-android-application-is-in-foreground-or-not
49
+ */
50
+ ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
51
+ List<ActivityManager.RunningAppProcessInfo> appProcesses =
52
+ activityManager.getRunningAppProcesses();
53
+ if (appProcesses == null) {
54
+ return false;
55
+ }
56
+ final String packageName = context.getPackageName();
57
+ for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) {
58
+ if (appProcess.importance ==
59
+ ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
60
+ appProcess.processName.equals(packageName)) {
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+
67
+ @Override
68
+ public void onBackgroundHCEInit(String handle) {
69
+ Log.d(TAG, "HCEService:onBackgroundHCEInit");
70
+
71
+ if (handle == null && backgroundSessionUUID != null) {
72
+ Log.d(TAG, "HCEService:onBackgroundHCEInit foreground call to background session");
73
+ throw new IllegalStateException();
74
+ } else if (handle != null && !handle.equals(backgroundSessionUUID)) {
75
+ Log.d(TAG, "HCEService:onBackgroundHCEInit mismatched background session handle");
76
+ throw new IllegalStateException();
77
+ }
78
+
79
+ if (pendingCAPDU != null) {
80
+ Log.d(TAG, "HCEService:onBackgroundHCEInit send pendingCAPDU");
81
+ hceModule.sendBackgroundEvent(backgroundSessionUUID, "received", BinaryUtils.ByteArrayToHexString(pendingCAPDU));
82
+ pendingCAPDU = null;
83
+ }
84
+ }
85
+
86
+ @Override
87
+ public void onRespondAPDU(String handle, String rapdu) {
88
+ Log.d(TAG, "HCEService:onRespondAPDU");
89
+
90
+ if (handle == null && backgroundSessionUUID != null) {
91
+ Log.d(TAG, "HCEService:onRespondAPDU foreground call to background session");
92
+ throw new IllegalStateException();
93
+ } else if (handle != null && !handle.equals(backgroundSessionUUID)) {
94
+ Log.d(TAG, "HCEService:onRespondAPDU mismatched background session handle");
95
+ throw new IllegalStateException();
96
+ }
97
+
98
+ if (!needsResponse) {
99
+ Log.d(TAG, "HCEService:onRespondAPDU not waiting for a response");
100
+ throw new IllegalStateException();
101
+ }
102
+
103
+ needsResponse = false;
104
+ sendResponseApdu(BinaryUtils.HexStringToByteArray(rapdu));
105
+ }
106
+
107
+ @Override
108
+ public byte[] processCommandApdu(byte[] command, Bundle extras) {
109
+ Log.d(TAG, "HCEService:processCommandApdu");
110
+ String capdu = BinaryUtils.ByteArrayToHexString(command).toUpperCase(Locale.ROOT);
111
+
112
+ if (isForeground) {
113
+ if (hceModule._isHCERunning() && !hceModule.isHCEBrokenConnection()) {
114
+ Log.d(TAG, "HCEService:processCommandApdu foreground sendEvent received");
115
+ needsResponse = true;
116
+ hceModule.sendEvent("received", capdu);
117
+ } else {
118
+ Log.d(TAG, "HCEService:processCommandApdu foreground respond 6999");
119
+ return new byte[] { (byte) 0x69, (byte) 0x99 };
120
+ }
121
+ } else {
122
+ if (hceModule != null && hceModule.isHCEBackgroundHandlerReady()) {
123
+ Log.d(TAG, "HCEService:processCommandApdu background sendBackgroundEvent received");
124
+ needsResponse = true;
125
+ hceModule.sendBackgroundEvent(backgroundSessionUUID, "received", capdu);
126
+ } else {
127
+ Log.d(TAG, "HCEService:processCommandApdu background pendingCAPDU");
128
+ needsResponse = true;
129
+ pendingCAPDU = command;
130
+ }
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ @Override
137
+ public void onCreate() {
138
+ Log.d(TAG, "HCEService:onCreate");
139
+
140
+ ReactHost reactHost = ((ReactApplication) getApplication()).getReactHost();
141
+
142
+ if (reactHost == null) {
143
+ throw new RuntimeException("BUG! getReactHost() returned null.");
144
+ }
145
+
146
+ isForeground = isAppOnForeground(getApplicationContext());
147
+ pendingCAPDU = null;
148
+ hceModule = null;
149
+ needsResponse = false;
150
+
151
+ if (isForeground) {
152
+ Log.d(TAG, "HCEService:onCreate foreground");
153
+ this.backgroundSessionUUID = null;
154
+
155
+ ReactContext reactContext = reactHost.getCurrentReactContext();
156
+
157
+ if (reactContext == null) {
158
+ throw new RuntimeException("BUG! getCurrentReactContext() returned null in foreground.");
159
+ }
160
+
161
+ this.hceModule = ((RTNHCEAndroidModule) reactContext.getNativeModule("NativeHCEModule"));
162
+
163
+ if (this.hceModule == null) {
164
+ throw new RuntimeException("BUG! getNativeModule() returned null in foreground.");
165
+ }
166
+
167
+ this.hceModule.setHCEService(this);
168
+
169
+ if (this.hceModule._isHCERunning()) {
170
+ this.hceModule.sendEvent("readerDetected", "");
171
+ } else {
172
+ this.hceModule.setHCEBrokenConnection();
173
+ }
174
+ } else {
175
+ ReactContext reactContext = reactHost.getCurrentReactContext();
176
+ Log.d(TAG, "HCEService:onCreate background");
177
+ this.backgroundSessionUUID = UUID.randomUUID().toString();
178
+
179
+ if (reactContext == null) {
180
+ reactHost.addReactInstanceEventListener(this);
181
+ reactHost.start();
182
+ } else {
183
+ setupRunJSTask(reactContext);
184
+ }
185
+ }
186
+ }
187
+
188
+ @Override
189
+ public void onDeactivated(int reason) {
190
+ Log.d(TAG, "HCEService:onDeactivated: " + reason);
191
+ needsResponse = false;
192
+
193
+ if (isForeground) {
194
+ if (this.hceModule != null && !this.hceModule.isHCEBrokenConnection()) {
195
+ this.hceModule.sendEvent("readerDeselected", "");
196
+ }
197
+ } else {
198
+ this.hceModule.sendBackgroundEvent(backgroundSessionUUID, "readerDeselected", "");
199
+ }
200
+
201
+ if (this.hceModule != null) {
202
+ this.hceModule.setHCEService(null);
203
+ }
204
+ }
205
+
206
+ @Override
207
+ public void onReactContextInitialized(@NonNull ReactContext reactContext) {
208
+ HeadlessJsTaskContext headlessJsTaskContext = Companion.getInstance(reactContext);
209
+ headlessJsTaskContext.addTaskEventListener(new HeadlessJsTaskEventListener() {
210
+ @Override
211
+ public void onHeadlessJsTaskStart(int i) {
212
+ Log.d(TAG, "HCEService:HeadlessJsTaskEventListener:onHeadlessJsTaskStart: " + i);
213
+ }
214
+
215
+ @Override
216
+ public void onHeadlessJsTaskFinish(int i) {
217
+ Log.d(TAG, "HCEService:HeadlessJsTaskEventListener:onHeadlessJsTaskFinish: " + i);
218
+ }
219
+ });
220
+
221
+ setupRunJSTask(reactContext);
222
+ }
223
+
224
+ public void setupRunJSTask(@NonNull ReactContext reactContext) {
225
+ hceModule = (RTNHCEAndroidModule) reactContext.getNativeModule("NativeHCEModule");
226
+
227
+ if (hceModule == null) {
228
+ throw new RuntimeException("BUG! getNativeModule() returned null in background.");
229
+ }
230
+
231
+ hceModule.setHCEService(this);
232
+
233
+ UiThreadUtil.runOnUiThread(new Runnable() {
234
+ @Override
235
+ public void run() {
236
+ Log.d(TAG, "HCEService:setupRunJSTask:runOnUiThread startTask");
237
+ Bundle argBundle = new Bundle();
238
+ argBundle.putString("handle", backgroundSessionUUID);
239
+ HeadlessJsTaskContext headlessJsTaskContext = Companion.getInstance(reactContext);
240
+ headlessJsTaskContext.startTask(
241
+ new HeadlessJsTaskConfig(
242
+ "handleBackgroundHCECall",
243
+ Arguments.fromBundle(argBundle),
244
+ 15000,
245
+ false // not allowed in foreground
246
+ ));
247
+ }
248
+ });
249
+ }
250
+
251
+ @Override
252
+ public void onDestroy() {
253
+ super.onDestroy();
254
+
255
+ Log.d(TAG, "HCEService:onDestroy");
256
+ }
257
+ }
@@ -0,0 +1,6 @@
1
+ package com.itsecrnd.rtnhceandroid;
2
+
3
+ public interface HCEServiceCallback {
4
+ void onBackgroundHCEInit(String handle);
5
+ void onRespondAPDU(String handle, String rapdu);
6
+ }
@@ -1,16 +1,5 @@
1
1
  package com.itsecrnd.rtnhceandroid;
2
2
 
3
- import static android.content.Context.RECEIVER_EXPORTED;
4
- import static com.itsecrnd.rtnhceandroid.IntentKeys.ACTION_READER_DETECT;
5
- import static com.itsecrnd.rtnhceandroid.IntentKeys.ACTION_READER_LOST;
6
- import static com.itsecrnd.rtnhceandroid.IntentKeys.ACTION_RECEIVE_C_APDU;
7
- import static com.itsecrnd.rtnhceandroid.IntentKeys.ACTION_SEND_R_APDU;
8
- import static com.itsecrnd.rtnhceandroid.IntentKeys.PERMISSION_HCE_BROADCAST;
9
-
10
- import android.content.BroadcastReceiver;
11
- import android.content.Context;
12
- import android.content.Intent;
13
- import android.content.IntentFilter;
14
3
  import android.content.pm.PackageManager;
15
4
  import android.os.Build;
16
5
  import android.util.Log;
@@ -29,102 +18,82 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
29
18
 
30
19
  private volatile boolean sessionRunning;
31
20
  private volatile boolean hceRunning;
32
- private volatile boolean hceBreakConnection;
33
21
  private volatile boolean hceDeselected;
22
+ private volatile boolean hceBrokenConnection;
34
23
 
35
- private void sendEvent(final String type, final String arg) {
36
- WritableMap map = Arguments.createMap();
37
- map.putString("type", type);
38
- map.putString("arg", arg);
39
- emitOnEvent(map);
40
- }
24
+ private volatile boolean hceBackgroundReady;
41
25
 
42
- @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
43
- private final BroadcastReceiver receiver = new BroadcastReceiver() {
44
- @Override
45
- public void onReceive(Context context, Intent intent) {
46
- try {
47
- String action = intent.getAction();
48
-
49
- if (action == null) {
50
- Log.w(TAG, "Received intent with null action in module receiver.");
51
- return;
52
- }
53
-
54
- if (action.equals(ACTION_RECEIVE_C_APDU)) {
55
- String capdu = intent.getStringExtra("capdu");
56
-
57
- if (!hceBreakConnection && hceRunning) {
58
- sendEvent("received", capdu);
59
- } else {
60
- // HCE is already disabled or was just disabled while the HCEService was running
61
- // don't forward C-APDUs to the application and just respond 6999 to everything
62
- Intent rintent = new Intent(ACTION_SEND_R_APDU);
63
- rintent.setPackage(getReactApplicationContext().getPackageName());
64
- rintent.putExtra("rapdu", "6999");
65
- getReactApplicationContext().getApplicationContext().sendBroadcast(rintent, PERMISSION_HCE_BROADCAST);
66
- }
67
- } else if (action.equals(ACTION_READER_DETECT)) {
68
- if (hceRunning) {
69
- hceDeselected = false;
70
- sendEvent("readerDetected", "");
71
- }
72
- } else if (action.equals(ACTION_READER_LOST)) {
73
- if (hceRunning && !hceBreakConnection && !hceDeselected) {
74
- // only if we haven't send a fake disconnect event already
75
- hceDeselected = true;
76
- sendEvent("readerDeselected", "");
77
- }
78
-
79
- hceBreakConnection = false;
80
- } else {
81
- Log.w(TAG, "Received unknown action intent in module receiver: " + action);
82
- }
83
- } catch (Exception e) {
84
- Log.e(TAG, "Failed to handle broadcast in RTNHCEAndroidModule.", e);
85
- }
86
- }
87
- };
26
+ private HCEServiceCallback serviceCb;
88
27
 
89
28
  RTNHCEAndroidModule(ReactApplicationContext context) {
90
29
  super(context);
91
30
 
31
+ this.serviceCb = null;
32
+
92
33
  this.sessionRunning = false;
93
34
  this.hceRunning = false;
94
- this.hceBreakConnection = false;
95
35
  this.hceDeselected = true;
96
-
97
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
98
- IntentFilter filter = new IntentFilter();
99
- filter.addAction(ACTION_RECEIVE_C_APDU);
100
- filter.addAction(ACTION_READER_DETECT);
101
- filter.addAction(ACTION_READER_LOST);
102
-
103
- getReactApplicationContext().getApplicationContext().registerReceiver(
104
- receiver,
105
- filter,
106
- PERMISSION_HCE_BROADCAST,
107
- null,
108
- RECEIVER_EXPORTED
109
- );
110
- }
36
+ this.hceBrokenConnection = false;
37
+ this.hceBackgroundReady = false;
111
38
  }
112
39
 
113
40
  @Override
114
41
  public void invalidate() {
115
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
116
- getReactApplicationContext().getApplicationContext().unregisterReceiver(receiver);
117
- }
42
+ Log.d(TAG, "RTNHCEAndroidModule:invalidate");
118
43
  }
119
44
 
120
45
  @Override
121
46
  @NonNull
122
47
  public String getName() {
48
+ Log.d(TAG, "RTNHCEAndroidModule:getName");
123
49
  return RTNHCEAndroidModule.NAME;
124
50
  }
125
51
 
52
+ void sendEvent(final String type, final String arg) {
53
+ Log.d(TAG, "RTNHCEAndroidModule:sendEvent");
54
+ WritableMap map = Arguments.createMap();
55
+ map.putString("type", type);
56
+ map.putString("arg", arg);
57
+ emitOnEvent(map);
58
+ }
59
+
60
+ void sendBackgroundEvent(final String audience, final String type, final String arg) {
61
+ Log.d(TAG, "RTNHCEAndroidModule:sendBackgroundEvent");
62
+ WritableMap map = Arguments.createMap();
63
+ map.putString("audience", audience);
64
+ map.putString("type", type);
65
+ map.putString("arg", arg);
66
+ emitOnBackgroundEvent(map);
67
+ }
68
+
69
+ boolean _isHCERunning() {
70
+ return this.hceRunning;
71
+ }
72
+
73
+ void setHCEBrokenConnection() {
74
+ this.hceBrokenConnection = true;
75
+ }
76
+
77
+ boolean isHCEBackgroundHandlerReady() {
78
+ return this.hceBackgroundReady;
79
+ }
80
+
81
+ boolean isHCEBrokenConnection() {
82
+ return this.hceBrokenConnection;
83
+ }
84
+
85
+ void setHCEService(HCEServiceCallback serviceCallback) {
86
+ Log.d(TAG, "RTNHCEAndroidModule:setHCEService");
87
+
88
+ this.serviceCb = serviceCallback;
89
+ this.hceDeselected = true;
90
+ this.hceBackgroundReady = false;
91
+ this.hceBrokenConnection = false;
92
+ }
93
+
126
94
  @Override
127
95
  public boolean isPlatformSupported() {
96
+ Log.d(TAG, "RTNHCEAndroidModule:isPlatformSupported");
128
97
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
129
98
  return false;
130
99
  }
@@ -136,17 +105,21 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
136
105
 
137
106
  @Override
138
107
  public void acquireExclusiveNFC(Promise promise) {
108
+ Log.d(TAG, "RTNHCEAndroidModule:acquireExclusiveNFC");
139
109
  promise.reject("err_platform_unsupported", "acquireExclusiveNFC() is not supported on Android.");
140
110
  }
141
111
 
142
112
  @Override
143
113
  public boolean isExclusiveNFC() {
114
+ Log.d(TAG, "RTNHCEAndroidModule:isExclusiveNFC");
144
115
  // unsupported on Android, always false
145
116
  return false;
146
117
  }
147
118
 
148
119
  @Override
149
120
  public void beginSession(Promise promise) {
121
+ Log.d(TAG, "RTNHCEAndroidModule:beginSession");
122
+
150
123
  if (!isPlatformSupported()) {
151
124
  promise.reject("err_platform_unsupported", "Unsupported Android version or missing NFC/HCE feature.");
152
125
  return;
@@ -163,24 +136,44 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
163
136
 
164
137
  @Override
165
138
  public void setSessionAlertMessage(String message) {
139
+ Log.d(TAG, "RTNHCEAndroidModule:setSessionAlertMessage");
166
140
  // unsupported on Android, no-op
167
141
  }
168
142
 
169
143
  @Override
170
144
  public void invalidateSession() {
145
+ Log.d(TAG, "RTNHCEAndroidModule:invalidateSession");
171
146
  if (this.sessionRunning) {
147
+ this.hceRunning = false;
172
148
  this.sessionRunning = false;
149
+ this.hceBrokenConnection = true;
173
150
  sendEvent("sessionInvalidated", "userInvalidated");
174
151
  }
175
152
  }
176
153
 
177
154
  @Override
178
155
  public boolean isSessionRunning() {
156
+ Log.d(TAG, "RTNHCEAndroidModule:isSessionRunning");
179
157
  return this.sessionRunning;
180
158
  }
181
159
 
160
+ @Override
161
+ public boolean beginBackgroundHCE(String handle) {
162
+ Log.d(TAG, "RTNHCEAndroidModule:beginBackgroundHCE");
163
+
164
+ try {
165
+ this.serviceCb.onBackgroundHCEInit(handle);
166
+ } catch (IllegalStateException e) {
167
+ return false;
168
+ }
169
+
170
+ this.hceBackgroundReady = true;
171
+ return true;
172
+ }
173
+
182
174
  @Override
183
175
  public void startHCE(Promise promise) {
176
+ Log.d(TAG, "RTNHCEAndroidModule:startHCE");
184
177
  if (!this.sessionRunning) {
185
178
  promise.reject("err_no_session", "No session is active.");
186
179
  return;
@@ -192,11 +185,13 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
192
185
  }
193
186
 
194
187
  this.hceRunning = true;
188
+ this.hceBrokenConnection = false;
195
189
  promise.resolve(null);
196
190
  }
197
191
 
198
192
  @Override
199
193
  public void stopHCE(String status, Promise promise) {
194
+ Log.d(TAG, "RTNHCEAndroidModule:stopHCE");
200
195
  if (!this.sessionRunning) {
201
196
  promise.reject("err_no_session", "No session is active.");
202
197
  return;
@@ -207,7 +202,7 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
207
202
 
208
203
  if (!this.hceDeselected) {
209
204
  this.hceDeselected = true;
210
- this.hceBreakConnection = true;
205
+ this.hceBrokenConnection = true;
211
206
  sendEvent("readerDeselected", "");
212
207
  }
213
208
  }
@@ -217,25 +212,34 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
217
212
 
218
213
  @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
219
214
  @Override
220
- public void respondAPDU(String rapdu, Promise promise) {
221
- if (!this.sessionRunning) {
222
- promise.reject("err_no_session", "No session is active.");
223
- return;
224
- } else if (!this.hceRunning) {
225
- promise.reject("err_no_hce", "HCE is not running.");
226
- return;
215
+ public void respondAPDU(String handle, String rapdu, Promise promise) {
216
+ Log.d(TAG, "RTNHCEAndroidModule:respondAPDU");
217
+
218
+ if (!this.hceBackgroundReady) {
219
+ if (!this.sessionRunning) {
220
+ promise.reject("err_no_session", "No session is active.");
221
+ return;
222
+ } else if (!this.hceRunning) {
223
+ promise.reject("err_no_hce", "HCE is not running.");
224
+ return;
225
+ }
227
226
  }
228
227
 
229
- Intent intent = new Intent(ACTION_SEND_R_APDU);
230
- intent.setPackage(getReactApplicationContext().getPackageName());
231
- intent.putExtra("rapdu", rapdu);
232
- getReactApplicationContext().getApplicationContext().sendBroadcast(intent, PERMISSION_HCE_BROADCAST);
228
+ Log.d(TAG, "respondAPDU(): Send to service");
229
+
230
+ try {
231
+ serviceCb.onRespondAPDU(handle, rapdu);
232
+ } catch (IllegalStateException e) {
233
+ promise.reject("err_illegal_state", "There is no C-APDU to respond to at the time or the provided handle is invalid.");
234
+ return;
235
+ }
233
236
 
234
237
  promise.resolve(null);
235
238
  }
236
239
 
237
240
  @Override
238
241
  public void isHCERunning(Promise promise) {
242
+ Log.d(TAG, "RTNHCEAndroidModule:isHCERunning");
239
243
  promise.resolve(this.hceRunning);
240
244
  }
241
245
  }
@@ -86,6 +86,10 @@
86
86
  }
87
87
  }
88
88
 
89
+ - (nonnull NSNumber *)beginBackgroundHCE:(nonnull NSString *)handle {
90
+ return @0;
91
+ }
92
+
89
93
  - (void)startHCE:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
90
94
  if (@available(iOS 17.4, *)) {
91
95
  return [hcemoduleios startHCEWithResolve:resolve reject:reject];
@@ -102,9 +106,9 @@
102
106
  }
103
107
  }
104
108
 
105
- - (void)respondAPDU:(nonnull NSString *)rapdu resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
109
+ - (void)respondAPDU:(NSString * _Nullable)handle rapdu:(nonnull NSString *)rapdu resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
106
110
  if (@available(iOS 17.4, *)) {
107
- return [hcemoduleios respondAPDUWithRapdu:rapdu resolve:resolve reject:reject];
111
+ return [hcemoduleios respondAPDUWithHandle:handle rapdu:rapdu resolve:resolve reject:reject];
108
112
  } else {
109
113
  reject(@"err_platform_unsupported", @"This iOS version does not support HCE.", nil);
110
114
  }
@@ -145,8 +145,13 @@ extension Data {
145
145
  }
146
146
  }
147
147
 
148
- @objc public func respondAPDU(rapdu: NSString, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
148
+ @objc public func respondAPDU(handle: NSString?, rapdu: NSString, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
149
149
  Task.init {
150
+ guard handle == nil else {
151
+ reject("err_illegal_state", "The provided handle is invalid.", nil)
152
+ return
153
+ }
154
+
150
155
  guard let capdu = self.receivedCardAPDU else {
151
156
  reject("err_no_apdu_received", "There is no received APDU to respond to.", nil)
152
157
  return
@@ -1,7 +1,8 @@
1
1
  import type {TurboModule, CodegenTypes} from 'react-native';
2
2
  import {TurboModuleRegistry} from 'react-native';
3
3
 
4
- export type HCEModuleEventType = 'sessionStarted'
4
+ export type HCEModuleEventType =
5
+ 'sessionStarted'
5
6
  | 'sessionInvalidated'
6
7
  /**
7
8
  * iOS: Whether the reader field was detected.
@@ -19,11 +20,21 @@ export type HCEModuleEventType = 'sessionStarted'
19
20
  | 'received';
20
21
  export type HCEModuleStopReason = 'success' | 'failure';
21
22
 
23
+ export type HCEModuleBackgroundEventType =
24
+ 'readerDeselected'
25
+ | 'received';
26
+
22
27
  export type HCEModuleEvent = {
23
28
  type: HCEModuleEventType
24
29
  arg: string | null,
25
30
  }
26
31
 
32
+ export type HCEModuleBackgroundEvent = {
33
+ type: HCEModuleBackgroundEventType
34
+ arg: string | null,
35
+ audience: string,
36
+ }
37
+
27
38
  export interface Spec extends TurboModule {
28
39
  /**
29
40
  * Checks if a given smartphone supports required HCE features.
@@ -63,6 +74,8 @@ export interface Spec extends TurboModule {
63
74
  */
64
75
  isSessionRunning(): boolean;
65
76
 
77
+ beginBackgroundHCE(handle: string): boolean;
78
+
66
79
  /**
67
80
  * iOS: Start the card emulation.
68
81
  * Android: Enable HCE service.
@@ -76,7 +89,7 @@ export interface Spec extends TurboModule {
76
89
  /**
77
90
  * Send R-APDU (hex-encoded string) as a response to the last received C-APDU event.
78
91
  */
79
- respondAPDU(rapdu: string): Promise<void>;
92
+ respondAPDU(handle: string | null, rapdu: string): Promise<void>;
80
93
  /**
81
94
  * iOS: Checks if the card emulation is running.
82
95
  * Android: Checks if the HCE service is enabled.
@@ -84,6 +97,7 @@ export interface Spec extends TurboModule {
84
97
  isHCERunning(): Promise<boolean>;
85
98
 
86
99
  readonly onEvent: CodegenTypes.EventEmitter<HCEModuleEvent>;
100
+ readonly onBackgroundEvent: CodegenTypes.EventEmitter<HCEModuleBackgroundEvent>;
87
101
  }
88
102
 
89
103
  export default TurboModuleRegistry.getEnforcing<Spec>(
@@ -0,0 +1,43 @@
1
+ import NativeHCEModule, {HCEModuleBackgroundEvent} from "./NativeHCEModule";
2
+
3
+ export interface BackgroundEventHandler {
4
+ (
5
+ event: HCEModuleBackgroundEvent,
6
+ respondAPDU: (rapdu: string) => Promise<void>,
7
+ ): Promise<void>
8
+ }
9
+
10
+ export interface ProcessBackgroundHCEFunc {
11
+ (
12
+ handler: BackgroundEventHandler
13
+ ): void
14
+ }
15
+
16
+ export const createBackgroundHCE = (handle: string) => {
17
+ const respondAPDU = (rapdu: string) => {
18
+ return NativeHCEModule.respondAPDU(handle, rapdu);
19
+ }
20
+
21
+ return (handler: BackgroundEventHandler) => {
22
+ const subscription = NativeHCEModule.onBackgroundEvent(async (event) => {
23
+ if (event.audience !== handle) {
24
+ // ignore
25
+ return;
26
+ }
27
+
28
+ try {
29
+ await handler(event, respondAPDU);
30
+ } catch (e) {
31
+ throw e;
32
+ } finally {
33
+ if (event.type === "readerDeselected") {
34
+ subscription.remove();
35
+ }
36
+ }
37
+ });
38
+
39
+ if (!NativeHCEModule.beginBackgroundHCE(handle)) {
40
+ subscription.remove();
41
+ }
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icedevml/react-native-host-card-emulation",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Host Card Emulation (HCE) React Native Turbo Module for iOS and Android platforms.",
5
5
  "react-native": "js/index",
6
6
  "source": "js/index",
@@ -1,11 +0,0 @@
1
- package com.itsecrnd.rtnhceandroid;
2
-
3
- public class IntentKeys {
4
- public static final String PERMISSION_HCE_BROADCAST = "com.itsecrnd.rtnhceandroid.HCE_BROADCAST";
5
- public static final String ACTION_RECEIVE_C_APDU = "com.itsecrnd.rtnhceandroid.ACTION_RECEIVE_C_APDU";
6
- public static final String ACTION_SEND_R_APDU = "com.itsecrnd.rtnhceandroid.ACTION_SEND_R_APDU";
7
- public static final String ACTION_READER_DETECT = "com.itsecrnd.rtnhceandroid.ACTION_READER_DETECT";
8
- public static final String ACTION_READER_LOST = "com.itsecrnd.rtnhceandroid.ACTION_READER_LOST";
9
- public static final String KEY_CAPDU = "capdu";
10
- public static final String KEY_RAPDU = "rapdu";
11
- }
@@ -1,90 +0,0 @@
1
- package com.itsecrnd.rtnhceandroid.service;
2
-
3
- import static com.itsecrnd.rtnhceandroid.IntentKeys.ACTION_READER_DETECT;
4
- import static com.itsecrnd.rtnhceandroid.IntentKeys.ACTION_READER_LOST;
5
- import static com.itsecrnd.rtnhceandroid.IntentKeys.ACTION_RECEIVE_C_APDU;
6
- import static com.itsecrnd.rtnhceandroid.IntentKeys.ACTION_SEND_R_APDU;
7
- import static com.itsecrnd.rtnhceandroid.IntentKeys.KEY_CAPDU;
8
- import static com.itsecrnd.rtnhceandroid.IntentKeys.KEY_RAPDU;
9
- import static com.itsecrnd.rtnhceandroid.IntentKeys.PERMISSION_HCE_BROADCAST;
10
-
11
- import android.content.BroadcastReceiver;
12
- import android.content.Context;
13
- import android.content.Intent;
14
- import android.content.IntentFilter;
15
- import android.nfc.cardemulation.HostApduService;
16
- import android.os.Build;
17
- import android.os.Bundle;
18
- import android.util.Log;
19
-
20
- import androidx.annotation.RequiresApi;
21
-
22
- import com.itsecrnd.rtnhceandroid.util.BinaryUtils;
23
-
24
- import java.util.Locale;
25
-
26
- @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
27
- public class HCEService extends HostApduService {
28
- private static final String TAG = "HCEService";
29
-
30
- private final BroadcastReceiver receiver = new BroadcastReceiver() {
31
- @Override
32
- public void onReceive(Context context, Intent intent) {
33
- try {
34
- String action = intent.getAction();
35
-
36
- if (action != null && action.equals(ACTION_SEND_R_APDU)) {
37
- String rapdu = intent.getStringExtra(KEY_RAPDU);
38
-
39
- if (rapdu == null) {
40
- throw new RuntimeException("Bug! ACTION_SEND_RAPDU intent KEY_RAPDU is null.");
41
- }
42
-
43
- byte[] dec = BinaryUtils.HexStringToByteArray(rapdu.toUpperCase(Locale.ROOT));
44
- sendResponseApdu(dec);
45
- } else {
46
- Log.w(TAG, "Received unknown action: " + action);
47
- }
48
- } catch (Exception e) {
49
- Log.e(TAG, "Failed to handle broadcast in HCEService.", e);
50
- }
51
- }
52
- };
53
-
54
- @Override
55
- public byte[] processCommandApdu(byte[] command, Bundle extras) {
56
- String capdu = BinaryUtils.ByteArrayToHexString(command).toUpperCase(Locale.ROOT);
57
-
58
- Intent intent = new Intent(ACTION_RECEIVE_C_APDU);
59
- intent.setPackage(getApplicationContext().getPackageName());
60
- intent.putExtra(KEY_CAPDU, capdu);
61
- getApplicationContext().sendBroadcast(intent, PERMISSION_HCE_BROADCAST);
62
-
63
- return null;
64
- }
65
-
66
- @Override
67
- public void onCreate() {
68
- Log.d(TAG, "HCEService onCreate");
69
-
70
- IntentFilter filter = new IntentFilter();
71
- filter.addAction(ACTION_SEND_R_APDU);
72
- getApplicationContext().registerReceiver(
73
- receiver, filter, PERMISSION_HCE_BROADCAST, null, RECEIVER_EXPORTED);
74
-
75
- Intent intent = new Intent(ACTION_READER_DETECT);
76
- intent.setPackage(getApplicationContext().getPackageName());
77
- getApplicationContext().sendBroadcast(intent, PERMISSION_HCE_BROADCAST);
78
- }
79
-
80
- @Override
81
- public void onDeactivated(int reason) {
82
- Log.d(TAG, "HCEService onDeactivated: " + reason);
83
-
84
- getApplicationContext().unregisterReceiver(receiver);
85
-
86
- Intent intent = new Intent(ACTION_READER_LOST);
87
- intent.setPackage(getApplicationContext().getPackageName());
88
- getApplicationContext().sendBroadcast(intent, PERMISSION_HCE_BROADCAST);
89
- }
90
- }