@icedevml/react-native-host-card-emulation 0.3.0 → 0.4.1

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
@@ -23,7 +23,17 @@ Make sure that your project has it enabled. Note that the New Architecture is en
23
23
  *(no prerequisite steps are required for Android)*
24
24
 
25
25
  ## Installation
26
- Install `@icedevml/react-native-host-card-emulation` package within your React Native project. Then, follow the subsections below for each platform that you need to support.
26
+ Install this package within your React Native project:
27
+
28
+ ```
29
+ yarn add @icedevml/react-native-host-card-emulation
30
+ ```
31
+ or using npm
32
+ ```
33
+ npm install --save @icedevml/react-native-host-card-emulation
34
+ ```
35
+
36
+ Afterwards, follow the subsections below for each platform that you need to support.
27
37
 
28
38
  ### iOS
29
39
  1. Make sure to set up a proper signing team and bundle identifier in "Signing & Capabilities" configuration section of your project.
@@ -54,7 +64,9 @@ Install `@icedevml/react-native-host-card-emulation` package within your React N
54
64
  ...
55
65
 
56
66
  + <uses-permission android:name="android.permission.NFC" />
57
- + <uses-feature android:name="android.hardware.nfc.hce" android:required="true" />
67
+ + <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"/>
58
70
 
59
71
  ...
60
72
 
@@ -65,10 +77,10 @@ Install `@icedevml/react-native-host-card-emulation` package within your React N
65
77
  ...
66
78
 
67
79
  + <service
68
- + android:name="com.itsecrnd.rtnhceandroid.HCEService"
80
+ + android:name="com.itsecrnd.rtnhceandroid.service.HCEService"
69
81
  + android:exported="true"
70
- + android:enabled="false"
71
- + android:permission="android.permission.BIND_NFC_SERVICE" >
82
+ + android:enabled="true"
83
+ + android:permission="android.permission.BIND_NFC_SERVICE">
72
84
  + <intent-filter>
73
85
  + <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
74
86
  + <category android:name="android.intent.category.DEFAULT"/>
@@ -98,5 +110,92 @@ Install `@icedevml/react-native-host-card-emulation` package within your React N
98
110
 
99
111
  ## API Specification & Demo App
100
112
 
101
- * [module API (declarations)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/native-hce-module/js/NativeHCEModule.ts)
102
- * [demo app's code (example usage)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/demo-hce-module-app/App.tsx)
113
+ This module provides a uniform low-level HCE API for both mobile platforms.
114
+
115
+ > [!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).
117
+ > Check it out in order to understand what methods you can call against the module and what are the expected parameters/return values.
118
+
119
+ > [!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.
121
+
122
+ ### Quick start guide
123
+
124
+ 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
+ ```typescript
126
+ useEffect(() => {
127
+ const removeListener = NativeHCEModule?.onEvent(async (event: HCEModuleEvent) => {
128
+ switch (event.type) {
129
+ /* ... implement event handlers here ... */
130
+ }
131
+ });
132
+
133
+ // cleanup the event listener when the effect is unmounted
134
+ return () => {
135
+ removeListener();
136
+ }
137
+ }, []);
138
+ ```
139
+ 2. When user indicates that he/she wants to perform the HCE action, call the following function from the button's onClick routine:
140
+ ```typescript
141
+ await NativeHCEModule?.beginSession();
142
+ ```
143
+ This will emit `sessionStarted` event right after the function call, on both iOS and Android platforms. After the session is started, you can decide to start HCE emulation right away in the event handler:
144
+ ```typescript
145
+ // inside NativeHCEModule?.onEvent handler's switch
146
+ case 'sessionStarted':
147
+ NativeHCEModule?.setSessionAlertMessage('Tap towards the reader'); // only for iOS, no-op in Android
148
+ await NativeHCEModule?.startHCE();
149
+ break;
150
+ ```
151
+ Calling `await NativeHCEModule?.startHCE()` causes:
152
+ * iOS: Your smartphone will start listening for C-APDUs (Command APDUs originating from reader devices) right after that function is called, which will be additionally indicated by the operating system popping out the NFC scanning user interface prompt.
153
+ * Android: HCE commands will be forwarded to your application from that point on. No specific user interface is displayed (you have to implement it on your own, within your app).
154
+ 3. Optionally, you can handle `readerDetected` and `readerDeselected` events to enhance user's experience.
155
+ ```typescript
156
+ // inside NativeHCEModule?.onEvent handler's switch
157
+ case 'readerDetected':
158
+ NativeHCEModule?.setSessionAlertMessage('Reader detected'); // only for iOS, no-op in Android
159
+ break;
160
+
161
+ case 'readerDeselected':
162
+ NativeHCEModule?.setSessionAlertMessage('Lost reader'); // only for iOS, no-op in Android
163
+ break;
164
+ ```
165
+ 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.
167
+ * 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
+ 4. Receive incoming C-APDU and respond to it:
169
+ ```typescript
170
+ // inside NativeHCEModule?.onEvent handler's switch
171
+ case 'received':
172
+ NativeHCEModule?.setSessionAlertMessage('Keep holding the tag'); // only for iOS, no-op on Android
173
+
174
+ // decode incoming C-APDU to bytes
175
+ const capdu = Buffer.from(event.arg!, 'hex');
176
+ console.log('Received C-APDU, capdu.toString('hex'));
177
+
178
+ // 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"));
180
+ break;
181
+ ```
182
+ 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
+
184
+ ### iOS: Acquiring exclusive NFC access
185
+ If you need to utilize `NFCPresentmentIntentAssertion` for enhanced user experience, call:
186
+ ```typescript
187
+ NativeHCEModule?.acquireExclusiveNFC();
188
+ ```
189
+ This function will acquire an exclusive NFC access for 15 seconds. On system services or other applications will be able to interfere with NFC during that period. For example, the NFC background tag reading will be disabled so it would not generate any distracting notifications.
190
+
191
+ This function will throw an exception if:
192
+ * the presentment intent assertion was already acquired and is still active;
193
+ * you are in the cooldown period where you are not allowed to acquire the presentment intent assertion (cooldown is 15 seconds after the previous assertion had expired);
194
+ * the feature is not supported or the device is not eligible for whatever reason;
195
+
196
+ Call `NativeHCEModule?.isExclusiveNFC()` to check if exclusive NFC access is still active.
197
+
198
+ ### More resources
199
+
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)
@@ -1,8 +1,11 @@
1
1
  package com.itsecrnd.rtnhceandroid;
2
2
 
3
3
  public class IntentKeys {
4
- public static final String INTENT_RECEIVE_C_APDU = "com.itsecrnd.rtnhceandroid.receivecapdu";
5
- public static final String INTENT_SEND_R_APDU = "com.itsecrnd.rtnhceandroid.sendrapdu";
6
- public static final String INTENT_READER_DETECT = "com.itsecrnd.rtnhceandroid.readerdetect";
7
- public static final String INTENT_READER_LOST = "com.itsecrnd.rtnhceandroid.readerlost";
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";
8
11
  }
@@ -1,13 +1,13 @@
1
1
  package com.itsecrnd.rtnhceandroid;
2
2
 
3
3
  import static android.content.Context.RECEIVER_EXPORTED;
4
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_READER_DETECT;
5
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_READER_LOST;
6
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_RECEIVE_C_APDU;
7
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_SEND_R_APDU;
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;
8
9
 
9
10
  import android.content.BroadcastReceiver;
10
- import android.content.ComponentName;
11
11
  import android.content.Context;
12
12
  import android.content.Intent;
13
13
  import android.content.IntentFilter;
@@ -23,27 +23,14 @@ import com.facebook.react.bridge.Promise;
23
23
  import com.facebook.react.bridge.ReactApplicationContext;
24
24
  import com.facebook.react.bridge.WritableMap;
25
25
 
26
- import javax.crypto.SecretKey;
27
-
28
- @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
29
26
  public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
30
27
  public static final String TAG = "RTNHCEAndroidModule";
31
28
  public static final String NAME = "NativeHCEModule";
32
29
 
33
- private final SecretKey encSecretKey;
34
- private boolean sessionOpen;
35
-
36
- private void toggleHceService(Boolean enabled) {
37
- getReactApplicationContext()
38
- .getPackageManager()
39
- .setComponentEnabledSetting(
40
- new ComponentName(getReactApplicationContext(), HCEService.class),
41
- enabled
42
- ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED
43
- : PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
44
- PackageManager.DONT_KILL_APP
45
- );
46
- }
30
+ private volatile boolean sessionRunning;
31
+ private volatile boolean hceRunning;
32
+ private volatile boolean hceBreakConnection;
33
+ private volatile boolean hceDeselected;
47
34
 
48
35
  private void sendEvent(final String type, final String arg) {
49
36
  WritableMap map = Arguments.createMap();
@@ -52,23 +39,46 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
52
39
  emitOnEvent(map);
53
40
  }
54
41
 
42
+ @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
55
43
  private final BroadcastReceiver receiver = new BroadcastReceiver() {
56
44
  @Override
57
45
  public void onReceive(Context context, Intent intent) {
58
46
  try {
59
47
  String action = intent.getAction();
60
48
 
61
- if (action != null) {
62
- AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("auth"));
49
+ if (action == null) {
50
+ Log.w(TAG, "Received intent with null action in module receiver.");
51
+ return;
52
+ }
63
53
 
64
- if (action.equals(INTENT_RECEIVE_C_APDU)) {
65
- String capdu = AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("capdu"));
54
+ if (action.equals(ACTION_RECEIVE_C_APDU)) {
55
+ String capdu = intent.getStringExtra("capdu");
56
+
57
+ if (!hceBreakConnection && hceRunning) {
66
58
  sendEvent("received", capdu);
67
- } else if (action.equals(INTENT_READER_DETECT)) {
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;
68
70
  sendEvent("readerDetected", "");
69
- } else if (action.equals(INTENT_READER_LOST)) {
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;
70
76
  sendEvent("readerDeselected", "");
71
77
  }
78
+
79
+ hceBreakConnection = false;
80
+ } else {
81
+ Log.w(TAG, "Received unknown action intent in module receiver: " + action);
72
82
  }
73
83
  } catch (Exception e) {
74
84
  Log.e(TAG, "Failed to handle broadcast in RTNHCEAndroidModule.", e);
@@ -79,40 +89,32 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
79
89
  RTNHCEAndroidModule(ReactApplicationContext context) {
80
90
  super(context);
81
91
 
82
- this.sessionOpen = false;
83
-
84
- String encKey;
85
-
86
- try {
87
- encSecretKey = AESGCMUtil.generateKey();
88
- encKey = BinaryUtils.ByteArrayToHexString(encSecretKey.getEncoded());
89
- } catch (Exception e) {
90
- throw new RuntimeException("Failed to generate encryption key.", e);
92
+ this.sessionRunning = false;
93
+ this.hceRunning = false;
94
+ this.hceBreakConnection = false;
95
+ 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
+ );
91
110
  }
111
+ }
92
112
 
93
- String prefsName = getReactApplicationContext().getPackageName() + ".nativehcemodule";
94
- getReactApplicationContext()
95
- .getSharedPreferences(prefsName, Context.MODE_PRIVATE)
96
- .edit()
97
- .putString("encKey", encKey)
98
- .commit();
99
-
100
- IntentFilter filter = new IntentFilter();
101
- filter.addAction(INTENT_RECEIVE_C_APDU);
102
- filter.addAction(INTENT_READER_DETECT);
103
- filter.addAction(INTENT_READER_LOST);
104
-
105
- /*
106
- * FIXME It seems like those intents are not going through across this module and HostApduService.
107
- * This happens even though those things are within the same application and package.
108
- * It also seems like there is no way to bind HostApduService (or it's a skill issue).
109
- *
110
- * For now we will just encrypt&authenticate all messages using AES/GCM and use exported
111
- * receivers. This is not a perfect solution but it is going to be secure and sufficient for now.
112
- *
113
- * If somebody knows a better way then any pull requests would be greatly appreciated.
114
- */
115
- getReactApplicationContext().getApplicationContext().registerReceiver(receiver, filter, RECEIVER_EXPORTED);
113
+ @Override
114
+ public void invalidate() {
115
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
116
+ getReactApplicationContext().getApplicationContext().unregisterReceiver(receiver);
117
+ }
116
118
  }
117
119
 
118
120
  @Override
@@ -123,9 +125,13 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
123
125
 
124
126
  @Override
125
127
  public boolean isPlatformSupported() {
128
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
129
+ return false;
130
+ }
131
+
126
132
  PackageManager pm = getReactApplicationContext().getPackageManager();
127
133
  return pm.hasSystemFeature(PackageManager.FEATURE_NFC)
128
- && pm.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION);
134
+ && pm.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION);
129
135
  }
130
136
 
131
137
  @Override
@@ -135,18 +141,23 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
135
141
 
136
142
  @Override
137
143
  public boolean isExclusiveNFC() {
138
- // unsupported on Android
144
+ // unsupported on Android, always false
139
145
  return false;
140
146
  }
141
147
 
142
148
  @Override
143
149
  public void beginSession(Promise promise) {
144
- if (this.sessionOpen) {
145
- promise.reject("err_card_session_exists", "Session already exists.");
146
- } else {
147
- this.sessionOpen = true;
148
- promise.resolve(null);
150
+ if (!isPlatformSupported()) {
151
+ promise.reject("err_platform_unsupported", "Unsupported Android version or missing NFC/HCE feature.");
152
+ return;
153
+ }
154
+
155
+ if (!this.sessionRunning) {
156
+ this.sessionRunning = true;
149
157
  sendEvent("sessionStarted", "");
158
+ promise.resolve(null);
159
+ } else {
160
+ promise.reject("err_card_session_exists", "Session already exists.");
150
161
  }
151
162
  }
152
163
 
@@ -157,51 +168,74 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
157
168
 
158
169
  @Override
159
170
  public void invalidateSession() {
160
- toggleHceService(false);
161
- this.sessionOpen = false;
162
- sendEvent("sessionInvalidated", "");
171
+ if (this.sessionRunning) {
172
+ this.sessionRunning = false;
173
+ sendEvent("sessionInvalidated", "userInvalidated");
174
+ }
163
175
  }
164
176
 
165
177
  @Override
166
178
  public boolean isSessionRunning() {
167
- return this.sessionOpen;
179
+ return this.sessionRunning;
168
180
  }
169
181
 
170
182
  @Override
171
183
  public void startHCE(Promise promise) {
172
- toggleHceService(true);
184
+ if (!this.sessionRunning) {
185
+ promise.reject("err_no_session", "No session is active.");
186
+ return;
187
+ }
188
+
189
+ if (this.hceRunning) {
190
+ promise.reject("err_start_emulation", "Error trying to start emulation. Emulation already started.");
191
+ return;
192
+ }
193
+
194
+ this.hceRunning = true;
173
195
  promise.resolve(null);
174
196
  }
175
197
 
176
198
  @Override
177
199
  public void stopHCE(String status, Promise promise) {
178
- toggleHceService(false);
200
+ if (!this.sessionRunning) {
201
+ promise.reject("err_no_session", "No session is active.");
202
+ return;
203
+ }
204
+
205
+ if (this.hceRunning) {
206
+ this.hceRunning = false;
207
+
208
+ if (!this.hceDeselected) {
209
+ this.hceDeselected = true;
210
+ this.hceBreakConnection = true;
211
+ sendEvent("readerDeselected", "");
212
+ }
213
+ }
214
+
179
215
  promise.resolve(null);
180
216
  }
181
217
 
218
+ @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
182
219
  @Override
183
220
  public void respondAPDU(String rapdu, Promise promise) {
184
- if (!this.sessionOpen) {
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.");
185
226
  return;
186
227
  }
187
228
 
188
- String encRapdu = AESGCMUtil.encryptData(encSecretKey, rapdu);
189
-
190
- Intent intent = new Intent(INTENT_SEND_R_APDU);
229
+ Intent intent = new Intent(ACTION_SEND_R_APDU);
191
230
  intent.setPackage(getReactApplicationContext().getPackageName());
192
- intent.putExtra("auth", AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
193
- intent.putExtra("rapdu", encRapdu);
194
- getReactApplicationContext().getApplicationContext().sendBroadcast(intent);
231
+ intent.putExtra("rapdu", rapdu);
232
+ getReactApplicationContext().getApplicationContext().sendBroadcast(intent, PERMISSION_HCE_BROADCAST);
195
233
 
196
234
  promise.resolve(null);
197
235
  }
198
236
 
199
237
  @Override
200
238
  public void isHCERunning(Promise promise) {
201
- int enabledSetting = getReactApplicationContext()
202
- .getPackageManager()
203
- .getComponentEnabledSetting(new ComponentName(getReactApplicationContext(), HCEService.class));
204
-
205
- promise.resolve(enabledSetting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED);
239
+ promise.resolve(this.hceRunning);
206
240
  }
207
241
  }
@@ -1,9 +1,6 @@
1
1
  package com.itsecrnd.rtnhceandroid;
2
2
 
3
- import android.os.Build;
4
-
5
3
  import androidx.annotation.Nullable;
6
- import androidx.annotation.RequiresApi;
7
4
 
8
5
  import com.facebook.react.TurboReactPackage;
9
6
  import com.facebook.react.bridge.NativeModule;
@@ -14,7 +11,6 @@ import com.facebook.react.module.model.ReactModuleInfoProvider;
14
11
  import java.util.HashMap;
15
12
  import java.util.Map;
16
13
 
17
- @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
18
14
  public class RTNHCEAndroidPackage extends TurboReactPackage {
19
15
  @Nullable
20
16
  @Override
@@ -0,0 +1,90 @@
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
+ }
@@ -20,7 +20,7 @@
20
20
  *
21
21
  */
22
22
 
23
- package com.itsecrnd.rtnhceandroid;
23
+ package com.itsecrnd.rtnhceandroid.util;
24
24
 
25
25
  public class BinaryUtils {
26
26
  /**
@@ -13,13 +13,8 @@ extension StringProtocol {
13
13
  }
14
14
 
15
15
  extension Data {
16
- struct HexEncodingOptions: OptionSet {
17
- let rawValue: Int
18
- static let upperCase = HexEncodingOptions(rawValue: 1 << 0)
19
- }
20
-
21
- func hexEncodedString(options: HexEncodingOptions = []) -> String {
22
- let format = options.contains(.upperCase) ? "%02hhX" : "%02hhx"
16
+ func hexEncodedString() -> String {
17
+ let format = "%02hhX"
23
18
  return map { String(format: format, $0) }.joined()
24
19
  }
25
20
  }
@@ -170,7 +165,14 @@ extension Data {
170
165
 
171
166
  @objc public func startHCE(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
172
167
  Task.init {
173
- try await self.cardSession?.startEmulation()
168
+ do {
169
+ try await self.cardSession?.startEmulation()
170
+ } catch(let error) {
171
+ reject("err_start_emulation", "Error trying to start emulation. \(error)", nil)
172
+ return
173
+ }
174
+
175
+ resolve(nil)
174
176
  }
175
177
  }
176
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icedevml/react-native-host-card-emulation",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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,89 +0,0 @@
1
- package com.itsecrnd.rtnhceandroid;
2
-
3
- import android.annotation.SuppressLint;
4
-
5
- import java.nio.charset.StandardCharsets;
6
- import java.security.InvalidAlgorithmParameterException;
7
- import java.security.InvalidKeyException;
8
- import java.security.NoSuchAlgorithmException;
9
- import java.security.SecureRandom;
10
- import java.util.Base64;
11
-
12
- import javax.crypto.BadPaddingException;
13
- import javax.crypto.Cipher;
14
- import javax.crypto.IllegalBlockSizeException;
15
- import javax.crypto.KeyGenerator;
16
- import javax.crypto.NoSuchPaddingException;
17
- import javax.crypto.SecretKey;
18
- import javax.crypto.spec.GCMParameterSpec;
19
-
20
- @SuppressLint("NewApi")
21
- public class AESGCMUtil {
22
- public static String randomString() {
23
- byte[] rnd = new byte[32];
24
- new SecureRandom().nextBytes(rnd);
25
- return BinaryUtils.ByteArrayToHexString(rnd);
26
- }
27
-
28
- public static SecretKey generateKey() {
29
- KeyGenerator keyGen;
30
- try {
31
- keyGen = KeyGenerator.getInstance("AES");
32
- } catch (NoSuchAlgorithmException e) {
33
- throw new RuntimeException("Failed to get AES algorithm.", e);
34
- }
35
- keyGen.init(256);
36
- return keyGen.generateKey();
37
- }
38
-
39
- public static String encryptData(SecretKey key, String plaintext) {
40
- try {
41
- byte[] iv = new byte[32];
42
- new SecureRandom().nextBytes(iv);
43
-
44
- Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
45
- GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(128, iv);
46
- cipher.init(Cipher.ENCRYPT_MODE, key, gcmParameterSpec);
47
-
48
- byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
49
- byte[] ciphertextBytes = cipher.doFinal(plaintextBytes);
50
-
51
- return Base64.getEncoder().encodeToString(iv) + ';' +
52
- Base64.getEncoder().encodeToString(ciphertextBytes);
53
- } catch (NoSuchPaddingException
54
- | NoSuchAlgorithmException
55
- | IllegalBlockSizeException
56
- | InvalidAlgorithmParameterException
57
- | BadPaddingException
58
- | InvalidKeyException e) {
59
- throw new RuntimeException("Crypto error.", e);
60
- }
61
- }
62
-
63
- public static String decryptData(SecretKey key, String ciphertext) {
64
- try {
65
- Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
66
- String[] parts = ciphertext.split(";");
67
-
68
- if (parts.length != 2) {
69
- throw new IllegalArgumentException("Invalid ciphertext parameter.");
70
- }
71
-
72
- byte[] iv = Base64.getDecoder().decode(parts[0]);
73
- byte[] ciphertextBytes = Base64.getDecoder().decode(parts[1]);
74
-
75
- GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(128, iv);
76
- cipher.init(Cipher.DECRYPT_MODE, key, gcmParameterSpec);
77
-
78
- byte[] plaintextBytes = cipher.doFinal(ciphertextBytes);
79
- return new String(plaintextBytes, StandardCharsets.UTF_8);
80
- } catch (NoSuchPaddingException
81
- | IllegalBlockSizeException
82
- | InvalidAlgorithmParameterException
83
- | BadPaddingException
84
- | InvalidKeyException
85
- | NoSuchAlgorithmException e) {
86
- throw new RuntimeException("Crypto error.", e);
87
- }
88
- }
89
- }
@@ -1,104 +0,0 @@
1
- package com.itsecrnd.rtnhceandroid;
2
-
3
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_READER_DETECT;
4
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_READER_LOST;
5
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_RECEIVE_C_APDU;
6
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_SEND_R_APDU;
7
-
8
- import android.content.BroadcastReceiver;
9
- import android.content.Context;
10
- import android.content.Intent;
11
- import android.content.IntentFilter;
12
- import android.nfc.cardemulation.HostApduService;
13
- import android.os.Build;
14
- import android.os.Bundle;
15
- import android.util.Log;
16
-
17
- import androidx.annotation.RequiresApi;
18
-
19
- import java.util.Locale;
20
-
21
- import javax.crypto.SecretKey;
22
- import javax.crypto.spec.SecretKeySpec;
23
-
24
- @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
25
- public class HCEService extends HostApduService {
26
- private static final String TAG = "HCEService";
27
-
28
- private SecretKey encSecretKey;
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) {
37
- AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("auth"));
38
-
39
- if (action.equals(INTENT_SEND_R_APDU)) {
40
- String rapdu = AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("rapdu"));
41
- byte[] dec = BinaryUtils.HexStringToByteArray(rapdu.toUpperCase(Locale.ROOT));
42
- sendResponseApdu(dec);
43
- }
44
- }
45
- } catch (Exception e) {
46
- Log.e(TAG, "Failed to handle broadcast in HCEService.", e);
47
- }
48
- }
49
- };
50
-
51
- @Override
52
- public byte[] processCommandApdu(byte[] command, Bundle extras) {
53
- String capdu = AESGCMUtil.encryptData(encSecretKey, BinaryUtils.ByteArrayToHexString(command));
54
-
55
- Intent intent = new Intent(INTENT_RECEIVE_C_APDU);
56
- intent.setPackage(getApplicationContext().getPackageName());
57
- intent.putExtra("auth", AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
58
- intent.putExtra("capdu", capdu);
59
- getApplicationContext().sendBroadcast(intent);
60
-
61
- return null;
62
- }
63
-
64
- @Override
65
- public void onCreate() {
66
- Log.d(TAG, "HCEService onCreate");
67
-
68
- String prefsName = getApplicationContext().getPackageName() + ".nativehcemodule";
69
- String encKey = getApplicationContext()
70
- .getSharedPreferences(prefsName, Context.MODE_PRIVATE)
71
- .getString("encKey", "");
72
-
73
- if (encKey.isEmpty()) {
74
- throw new RuntimeException("Failed to load encKey.");
75
- }
76
-
77
- byte[] bArr = BinaryUtils.HexStringToByteArray(encKey);
78
- encSecretKey = new SecretKeySpec(bArr, 0, bArr.length, "AES");
79
-
80
- /*
81
- * See a comment about registerReceiver() in RTNHCEAndroidModule.
82
- */
83
- IntentFilter filter = new IntentFilter();
84
- filter.addAction(INTENT_SEND_R_APDU);
85
- getApplicationContext().registerReceiver(receiver, filter, RECEIVER_EXPORTED);
86
-
87
- Intent intent = new Intent(INTENT_READER_DETECT);
88
- intent.setPackage(getApplicationContext().getPackageName());
89
- intent.putExtra("auth", AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
90
- getApplicationContext().sendBroadcast(intent);
91
- }
92
-
93
- @Override
94
- public void onDeactivated(int reason) {
95
- Log.d(TAG, "HCEService onDeactivated: " + reason);
96
-
97
- getApplicationContext().unregisterReceiver(receiver);
98
-
99
- Intent intent = new Intent(INTENT_READER_LOST);
100
- intent.setPackage(getApplicationContext().getPackageName());
101
- intent.putExtra("auth", AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
102
- getApplicationContext().sendBroadcast(intent);
103
- }
104
- }