@icedevml/react-native-host-card-emulation 0.2.2 → 0.4.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
@@ -8,10 +8,10 @@ Host Card Emulation Module for React Native on iOS and Android platforms.
8
8
  > in order to use this feature, which is managed by Apple. Read more in the official support article:
9
9
  > "[HCE-based contactless NFC transactions for apps in the European Economic Area (EEA)](https://developer.apple.com/support/hce-transactions-in-apps/)".
10
10
 
11
- > [!NOTE]
12
- > Kindly please reference this project in your README if you are willing to create a derivative library.
13
-
14
11
  ## Prerequisites
12
+ This package was designed for React Native [New Architecture](https://reactnative.dev/architecture/landing-page) only.
13
+ Make sure that your project has it enabled. Note that the New Architecture is enabled by default for React Native 0.76+ projects.
14
+
15
15
  ### iOS
16
16
  1. Ensure that you are enrolled as organization (not individual) in Apple Developer Program, and that you are established in the European Economic Area (EEA).
17
17
  2. Ensure that you meet other [non-technical requirements imposed by Apple](https://developer.apple.com/support/hce-transactions-in-apps/#requirements-and-availability).
@@ -98,5 +98,65 @@ Install `@icedevml/react-native-host-card-emulation` package within your React N
98
98
 
99
99
  ## API Specification & Demo App
100
100
 
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)
101
+ This module provides a uniform low-level HCE API for both mobile platforms.
102
+
103
+ 1. Subscribe to the event stream (there is a single global event stream for the entire module):
104
+ ```typescript
105
+ NativeHCEModule?.onEvent(async (event: HCEModuleEvent) => {
106
+ switch (event.type) {
107
+ /* ... your handler here ... */
108
+ }
109
+ });
110
+ ```
111
+ 2. When user indicates that he wants to perform the HCE action, call the following function from the button's onClick routine:
112
+ ```typescript
113
+ await NativeHCEModule?.beginSession();
114
+ ```
115
+ This will emit `sessionStarted` event on both platforms. You can start the HCE straight from that event:
116
+ ```typescript
117
+ // inside NativeHCEModule?.onEvent handler's switch
118
+ case 'sessionStarted':
119
+ NativeHCEModule?.setSessionAlertMessage('Tap towards the reader'); // only for iOS, no-op in Android
120
+ await NativeHCEModule?.startHCE();
121
+ break;
122
+ ```
123
+ 3. Calling `startHCE()` causes:
124
+ * iOS: Launch of the NFC scan system UI. The smartphone will start listening for C-APDUs from the readers right after that UI appears on screen. 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.
125
+ * Android: No user interface appears (you have to implement it on your own scanning UI, within your app). 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.
126
+ 4. Optionally, you can handle `readerDetected` and `readerDeselected events to enhance user's experience.
127
+ ```typescript
128
+ // inside NativeHCEModule?.onEvent handler's switch
129
+ case 'readerDetected':
130
+ NativeHCEModule?.setSessionAlertMessage('Reader detected'); // only for iOS, no-op in Android
131
+ break;
132
+
133
+ case 'readerDeselected':
134
+ NativeHCEModule?.setSessionAlertMessage('Lost reader'); // only for iOS, no-op in Android
135
+ break;
136
+ ```
137
+ 5. Handle CAPDU
138
+ ```typescript
139
+ // inside NativeHCEModule?.onEvent handler's switch
140
+ case 'received':
141
+ NativeHCEModule?.setSessionAlertMessage('Keep holding the tag');
142
+
143
+ const capdu = Buffer.from(event.arg!, 'hex');
144
+ console.log('Received C-APDU, capdu.toString('hex'));
145
+
146
+ // let's say we always want to respond with [0x0A] + Status Word 0x9000
147
+ await NativeHCEModule?.respondAPDU(Buffer.from([0x0A, 0x90, 0x00], "hex"));
148
+ break;
149
+ ```
150
+ 6. (iOS) Optionally, if you need to utilize `NFCPresentmentIntentAssertion` then at any time you can call:
151
+ ```typescript
152
+ NativeHCEModule?.acquireExclusiveNFC();
153
+ ```
154
+ Which will throw an exception if the presentment intent assertion was already acquired and is still valid
155
+ (usually 15 seconds) or if the cooldown period for acquiring new assertion was not yet expired (usually also 15 seconds).
156
+
157
+ > [!NOTE]
158
+ > 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.
159
+
160
+ > [!NOTE]
161
+ > 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).
162
+ > Check it out in order to understand what methods you can call against the module and what are the expected parameters/return values.
@@ -2,6 +2,13 @@ package com.itsecrnd.rtnhceandroid;
2
2
 
3
3
  import android.annotation.SuppressLint;
4
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
+
5
12
  import javax.crypto.BadPaddingException;
6
13
  import javax.crypto.Cipher;
7
14
  import javax.crypto.IllegalBlockSizeException;
@@ -10,15 +17,14 @@ import javax.crypto.NoSuchPaddingException;
10
17
  import javax.crypto.SecretKey;
11
18
  import javax.crypto.spec.GCMParameterSpec;
12
19
 
13
- import java.nio.charset.StandardCharsets;
14
- import java.security.InvalidAlgorithmParameterException;
15
- import java.security.InvalidKeyException;
16
- import java.security.NoSuchAlgorithmException;
17
- import java.security.SecureRandom;
18
- import java.util.Base64;
19
-
20
20
  @SuppressLint("NewApi")
21
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
+
22
28
  public static SecretKey generateKey() {
23
29
  KeyGenerator keyGen;
24
30
  try {
@@ -30,12 +36,6 @@ public class AESGCMUtil {
30
36
  return keyGen.generateKey();
31
37
  }
32
38
 
33
- public static byte[] generateNonce(int size) {
34
- byte[] nonce = new byte[size];
35
- new SecureRandom().nextBytes(nonce); // Fill nonce with random bytes
36
- return nonce;
37
- }
38
-
39
39
  public static String encryptData(SecretKey key, String plaintext) {
40
40
  try {
41
41
  byte[] iv = new byte[32];
@@ -49,14 +49,14 @@ public class AESGCMUtil {
49
49
  byte[] ciphertextBytes = cipher.doFinal(plaintextBytes);
50
50
 
51
51
  return Base64.getEncoder().encodeToString(iv) + ';' +
52
- Base64.getEncoder().encodeToString(ciphertextBytes);
52
+ Base64.getEncoder().encodeToString(ciphertextBytes);
53
53
  } catch (NoSuchPaddingException
54
54
  | NoSuchAlgorithmException
55
55
  | IllegalBlockSizeException
56
56
  | InvalidAlgorithmParameterException
57
57
  | BadPaddingException
58
58
  | InvalidKeyException e) {
59
- throw new RuntimeException(e);
59
+ throw new RuntimeException("Crypto error.", e);
60
60
  }
61
61
  }
62
62
 
@@ -83,7 +83,7 @@ public class AESGCMUtil {
83
83
  | BadPaddingException
84
84
  | InvalidKeyException
85
85
  | NoSuchAlgorithmException e) {
86
- throw new RuntimeException(e);
86
+ throw new RuntimeException("Crypto error.", e);
87
87
  }
88
88
  }
89
89
  }
@@ -30,7 +30,7 @@ public class BinaryUtils {
30
30
  * @return String, containing hexadecimal representation.
31
31
  */
32
32
  public static String ByteArrayToHexString(byte[] bytes) {
33
- final char[] hexArray = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
33
+ final char[] hexArray = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
34
34
  char[] hexChars = new char[bytes.length * 2]; // Each byte has two hex characters (nibbles)
35
35
  int v;
36
36
  for (int j = 0; j < bytes.length; j++) {
@@ -59,7 +59,7 @@ public class BinaryUtils {
59
59
  for (int i = 0; i < len; i += 2) {
60
60
  // Convert each character into a integer (base-16), then bit-shift into place
61
61
  data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
62
- + Character.digit(s.charAt(i+1), 16));
62
+ + Character.digit(s.charAt(i + 1), 16));
63
63
  }
64
64
  return data;
65
65
  }
@@ -1,22 +1,24 @@
1
1
  package com.itsecrnd.rtnhceandroid;
2
2
 
3
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_RECEIVE_C_APDU;
4
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_SEND_R_APDU;
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_AUTH;
8
+ import static com.itsecrnd.rtnhceandroid.IntentKeys.KEY_CAPDU;
9
+ import static com.itsecrnd.rtnhceandroid.IntentKeys.KEY_RAPDU;
5
10
 
6
11
  import android.content.BroadcastReceiver;
7
12
  import android.content.Context;
8
13
  import android.content.Intent;
9
14
  import android.content.IntentFilter;
10
15
  import android.nfc.cardemulation.HostApduService;
11
- import android.nfc.cardemulation.PollingFrame;
12
16
  import android.os.Build;
13
17
  import android.os.Bundle;
14
18
  import android.util.Log;
15
19
 
16
- import androidx.annotation.NonNull;
17
20
  import androidx.annotation.RequiresApi;
18
21
 
19
- import java.util.List;
20
22
  import java.util.Locale;
21
23
 
22
24
  import javax.crypto.SecretKey;
@@ -31,12 +33,20 @@ public class HCEService extends HostApduService {
31
33
  private final BroadcastReceiver receiver = new BroadcastReceiver() {
32
34
  @Override
33
35
  public void onReceive(Context context, Intent intent) {
34
- String action = intent.getAction();
35
-
36
- if (action != null && action.equals(INTENT_SEND_R_APDU)) {
37
- String rapdu = AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("rapdu"));
38
- byte[] dec = BinaryUtils.HexStringToByteArray(rapdu.toUpperCase(Locale.ROOT));
39
- sendResponseApdu(dec);
36
+ try {
37
+ String action = intent.getAction();
38
+
39
+ if (action != null) {
40
+ AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra(KEY_AUTH));
41
+
42
+ if (action.equals(ACTION_SEND_R_APDU)) {
43
+ String rapdu = AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra(KEY_RAPDU));
44
+ byte[] dec = BinaryUtils.HexStringToByteArray(rapdu.toUpperCase(Locale.ROOT));
45
+ sendResponseApdu(dec);
46
+ }
47
+ }
48
+ } catch (Exception e) {
49
+ Log.e(TAG, "Failed to handle broadcast in HCEService.", e);
40
50
  }
41
51
  }
42
52
  };
@@ -45,29 +55,22 @@ public class HCEService extends HostApduService {
45
55
  public byte[] processCommandApdu(byte[] command, Bundle extras) {
46
56
  String capdu = AESGCMUtil.encryptData(encSecretKey, BinaryUtils.ByteArrayToHexString(command));
47
57
 
48
- Intent intent = new Intent(INTENT_RECEIVE_C_APDU);
58
+ Intent intent = new Intent(ACTION_RECEIVE_C_APDU);
49
59
  intent.setPackage(getApplicationContext().getPackageName());
50
- intent.putExtra("capdu", capdu);
60
+ intent.putExtra(KEY_AUTH, AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
61
+ intent.putExtra(KEY_CAPDU, capdu);
51
62
  getApplicationContext().sendBroadcast(intent);
52
63
 
53
64
  return null;
54
65
  }
55
66
 
56
- @Override
57
- public void processPollingFrames(@NonNull List<PollingFrame> frames) {
58
- super.processPollingFrames(frames);
59
- Log.i(TAG, "Polling frames: " + frames.size());
60
- }
61
-
62
67
  @Override
63
68
  public void onCreate() {
64
- Log.d(TAG, "Starting service");
65
-
66
- IntentFilter filter = new IntentFilter();
67
- filter.addAction(INTENT_SEND_R_APDU);
69
+ Log.d(TAG, "HCEService onCreate");
68
70
 
71
+ String prefsName = getApplicationContext().getPackageName() + ".nativehcemodule";
69
72
  String encKey = getApplicationContext()
70
- .getSharedPreferences("RTNHCEAndroidModuleBroadcastEnc", Context.MODE_PRIVATE)
73
+ .getSharedPreferences(prefsName, Context.MODE_PRIVATE)
71
74
  .getString("encKey", "");
72
75
 
73
76
  if (encKey.isEmpty()) {
@@ -80,13 +83,25 @@ public class HCEService extends HostApduService {
80
83
  /*
81
84
  * See a comment about registerReceiver() in RTNHCEAndroidModule.
82
85
  */
86
+ IntentFilter filter = new IntentFilter();
87
+ filter.addAction(ACTION_SEND_R_APDU);
83
88
  getApplicationContext().registerReceiver(receiver, filter, RECEIVER_EXPORTED);
89
+
90
+ Intent intent = new Intent(ACTION_READER_DETECT);
91
+ intent.setPackage(getApplicationContext().getPackageName());
92
+ intent.putExtra(KEY_AUTH, AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
93
+ getApplicationContext().sendBroadcast(intent);
84
94
  }
85
95
 
86
96
  @Override
87
97
  public void onDeactivated(int reason) {
88
- Log.d(TAG, "Finishing service: " + reason);
98
+ Log.d(TAG, "HCEService onDeactivated: " + reason);
89
99
 
90
100
  getApplicationContext().unregisterReceiver(receiver);
101
+
102
+ Intent intent = new Intent(ACTION_READER_LOST);
103
+ intent.setPackage(getApplicationContext().getPackageName());
104
+ intent.putExtra(KEY_AUTH, AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
105
+ getApplicationContext().sendBroadcast(intent);
91
106
  }
92
107
  }
@@ -1,6 +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";
4
+ public static final String ACTION_RECEIVE_C_APDU = "com.itsecrnd.rtnhceandroid.ACTION_RECEIVE_C_APDU";
5
+ public static final String ACTION_SEND_R_APDU = "com.itsecrnd.rtnhceandroid.ACTION_SEND_R_APDU";
6
+ public static final String ACTION_READER_DETECT = "com.itsecrnd.rtnhceandroid.ACTION_READER_DETECT";
7
+ public static final String ACTION_READER_LOST = "com.itsecrnd.rtnhceandroid.ACTION_READER_LOST";
8
+ public static final String KEY_AUTH = "auth";
9
+ public static final String KEY_CAPDU = "capdu";
10
+ public static final String KEY_RAPDU = "rapdu";
6
11
  }
@@ -1,14 +1,17 @@
1
1
  package com.itsecrnd.rtnhceandroid;
2
2
 
3
3
  import static android.content.Context.RECEIVER_EXPORTED;
4
-
5
- import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_RECEIVE_C_APDU;
6
- 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;
7
8
 
8
9
  import android.content.BroadcastReceiver;
10
+ import android.content.ComponentName;
9
11
  import android.content.Context;
10
12
  import android.content.Intent;
11
13
  import android.content.IntentFilter;
14
+ import android.content.pm.PackageManager;
12
15
  import android.os.Build;
13
16
  import android.util.Log;
14
17
 
@@ -27,20 +30,55 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
27
30
  public static final String TAG = "RTNHCEAndroidModule";
28
31
  public static final String NAME = "NativeHCEModule";
29
32
 
33
+ private volatile boolean sessionRunning;
34
+ private volatile boolean hceRunning;
35
+ private volatile boolean hceBreakConnection;
36
+ private volatile boolean hceDeselected;
30
37
  private final SecretKey encSecretKey;
31
38
 
39
+ private void sendEvent(final String type, final String arg) {
40
+ WritableMap map = Arguments.createMap();
41
+ map.putString("type", type);
42
+ map.putString("arg", arg);
43
+ emitOnEvent(map);
44
+ }
45
+
32
46
  private final BroadcastReceiver receiver = new BroadcastReceiver() {
33
47
  @Override
34
48
  public void onReceive(Context context, Intent intent) {
35
- String action = intent.getAction();
36
-
37
- if (action != null && action.equals(INTENT_RECEIVE_C_APDU)) {
38
- String capdu = AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("capdu"));
39
-
40
- WritableMap map = Arguments.createMap();
41
- map.putString("type", "received");
42
- map.putString("arg", capdu);
43
- emitOnEvent(map);
49
+ try {
50
+ String action = intent.getAction();
51
+
52
+ if (action != null) {
53
+ AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("auth"));
54
+
55
+ if (action.equals(ACTION_RECEIVE_C_APDU)) {
56
+ String capdu = AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("capdu"));
57
+
58
+ if (!hceBreakConnection && hceRunning) {
59
+ sendEvent("received", capdu);
60
+ } else {
61
+ Intent rintent = new Intent(ACTION_SEND_R_APDU);
62
+ rintent.setPackage(getReactApplicationContext().getPackageName());
63
+ rintent.putExtra("auth", AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
64
+ rintent.putExtra("rapdu", AESGCMUtil.encryptData(encSecretKey, "6999"));
65
+ getReactApplicationContext().getApplicationContext().sendBroadcast(rintent);
66
+ }
67
+ } else if (hceRunning && action.equals(ACTION_READER_DETECT)) {
68
+ hceDeselected = false;
69
+ sendEvent("readerDetected", "");
70
+ } else if (action.equals(ACTION_READER_LOST)) {
71
+ if (hceRunning && !hceBreakConnection && !hceDeselected) {
72
+ // only if we haven't send a fake disconnect event already
73
+ hceDeselected = true;
74
+ sendEvent("readerDeselected", "");
75
+ }
76
+
77
+ hceBreakConnection = false;
78
+ }
79
+ }
80
+ } catch (Exception e) {
81
+ Log.e(TAG, "Failed to handle broadcast in RTNHCEAndroidModule.", e);
44
82
  }
45
83
  }
46
84
  };
@@ -48,6 +86,10 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
48
86
  RTNHCEAndroidModule(ReactApplicationContext context) {
49
87
  super(context);
50
88
  String encKey;
89
+ this.sessionRunning = false;
90
+ this.hceRunning = false;
91
+ this.hceBreakConnection = false;
92
+ this.hceDeselected = true;
51
93
 
52
94
  try {
53
95
  encSecretKey = AESGCMUtil.generateKey();
@@ -56,14 +98,17 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
56
98
  throw new RuntimeException("Failed to generate encryption key.", e);
57
99
  }
58
100
 
101
+ String prefsName = getReactApplicationContext().getPackageName() + ".nativehcemodule";
59
102
  getReactApplicationContext()
60
- .getSharedPreferences("RTNHCEAndroidModuleBroadcastEnc", Context.MODE_PRIVATE)
103
+ .getSharedPreferences(prefsName, Context.MODE_PRIVATE)
61
104
  .edit()
62
105
  .putString("encKey", encKey)
63
106
  .commit();
64
107
 
65
108
  IntentFilter filter = new IntentFilter();
66
- filter.addAction(INTENT_RECEIVE_C_APDU);
109
+ filter.addAction(ACTION_RECEIVE_C_APDU);
110
+ filter.addAction(ACTION_READER_DETECT);
111
+ filter.addAction(ACTION_READER_LOST);
67
112
 
68
113
  /*
69
114
  * FIXME It seems like those intents are not going through across this module and HostApduService.
@@ -78,6 +123,11 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
78
123
  getReactApplicationContext().getApplicationContext().registerReceiver(receiver, filter, RECEIVER_EXPORTED);
79
124
  }
80
125
 
126
+ @Override
127
+ public void invalidate() {
128
+ getReactApplicationContext().getApplicationContext().unregisterReceiver(receiver);
129
+ }
130
+
81
131
  @Override
82
132
  @NonNull
83
133
  public String getName() {
@@ -86,46 +136,74 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
86
136
 
87
137
  @Override
88
138
  public boolean isPlatformSupported() {
89
- return true;
139
+ PackageManager pm = getReactApplicationContext().getPackageManager();
140
+ return pm.hasSystemFeature(PackageManager.FEATURE_NFC)
141
+ && pm.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION);
90
142
  }
91
143
 
92
144
  @Override
93
145
  public void acquireExclusiveNFC(Promise promise) {
94
- promise.resolve(null);
146
+ promise.reject("err_platform_unsupported", "acquireExclusiveNFC() is not supported on Android.");
95
147
  }
96
148
 
97
149
  @Override
98
150
  public boolean isExclusiveNFC() {
151
+ // unsupported on Android, always false
99
152
  return false;
100
153
  }
101
154
 
102
155
  @Override
103
156
  public void beginSession(Promise promise) {
104
- promise.resolve(null);
157
+ if (!this.sessionRunning) {
158
+ this.sessionRunning = true;
159
+ sendEvent("sessionStarted", "");
160
+ promise.resolve(null);
161
+ } else {
162
+ promise.reject("err_card_session_exists", "Session already exists.");
163
+ }
105
164
  }
106
165
 
107
166
  @Override
108
167
  public void setSessionAlertMessage(String message) {
109
-
168
+ // unsupported on Android, no-op
110
169
  }
111
170
 
112
171
  @Override
113
172
  public void invalidateSession() {
114
-
173
+ if (this.sessionRunning) {
174
+ this.sessionRunning = false;
175
+ sendEvent("sessionInvalidated", "userInvalidated");
176
+ }
115
177
  }
116
178
 
117
179
  @Override
118
180
  public boolean isSessionRunning() {
119
- return true;
181
+ return this.sessionRunning;
120
182
  }
121
183
 
122
184
  @Override
123
185
  public void startHCE(Promise promise) {
186
+ if (this.hceRunning) {
187
+ promise.reject("err_start_emulation", "Error trying to start emulation. Emulation already started.");
188
+ return;
189
+ }
190
+
191
+ this.hceRunning = true;
124
192
  promise.resolve(null);
125
193
  }
126
194
 
127
195
  @Override
128
196
  public void stopHCE(String status, Promise promise) {
197
+ if (this.hceRunning) {
198
+ this.hceRunning = false;
199
+
200
+ if (!this.hceDeselected) {
201
+ this.hceDeselected = true;
202
+ this.hceBreakConnection = true;
203
+ sendEvent("readerDeselected", "");
204
+ }
205
+ }
206
+
129
207
  promise.resolve(null);
130
208
  }
131
209
 
@@ -133,8 +211,9 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
133
211
  public void respondAPDU(String rapdu, Promise promise) {
134
212
  String encRapdu = AESGCMUtil.encryptData(encSecretKey, rapdu);
135
213
 
136
- Intent intent = new Intent(INTENT_SEND_R_APDU);
214
+ Intent intent = new Intent(ACTION_SEND_R_APDU);
137
215
  intent.setPackage(getReactApplicationContext().getPackageName());
216
+ intent.putExtra("auth", AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
138
217
  intent.putExtra("rapdu", encRapdu);
139
218
  getReactApplicationContext().getApplicationContext().sendBroadcast(intent);
140
219
 
@@ -143,6 +222,7 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
143
222
 
144
223
  @Override
145
224
  public void isHCERunning(Promise promise) {
225
+ // unsupported on Android, always true
146
226
  promise.resolve(true);
147
227
  }
148
228
  }
@@ -40,7 +40,7 @@ public class RTNHCEAndroidPackage extends TurboReactPackage {
40
40
  true, // hasConstants
41
41
  false, // isCxxModule
42
42
  true // isTurboModule
43
- ));
43
+ ));
44
44
  return moduleInfos;
45
45
  };
46
46
  }
@@ -170,7 +170,14 @@ extension Data {
170
170
 
171
171
  @objc public func startHCE(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
172
172
  Task.init {
173
- try await self.cardSession?.startEmulation()
173
+ do {
174
+ try await self.cardSession?.startEmulation()
175
+ } catch(let error) {
176
+ reject("err_start_emulation", "Error trying to start emulation. \(error)", nil)
177
+ return
178
+ }
179
+
180
+ resolve(nil)
174
181
  }
175
182
  }
176
183
 
@@ -1,7 +1,22 @@
1
1
  import type {TurboModule, CodegenTypes} from 'react-native';
2
2
  import {TurboModuleRegistry} from 'react-native';
3
3
 
4
- export type HCEModuleEventType = 'sessionStarted' | 'readerDetected' | 'readerDeselected' | 'received' | 'sessionInvalidated';
4
+ export type HCEModuleEventType = 'sessionStarted'
5
+ | 'sessionInvalidated'
6
+ /**
7
+ * iOS: Whether the reader field was detected.
8
+ * Android: Whether the reader had sent the select command to us.
9
+ */
10
+ | 'readerDetected'
11
+ /**
12
+ * Whether our HCE app was deselected or the reader was lost.
13
+ */
14
+ | 'readerDeselected'
15
+ /**
16
+ * Received C-APDU (hex-encoded string in "arg" key) from the reader.
17
+ * Respond by calling respondAPDU(rapdu).
18
+ */
19
+ | 'received';
5
20
  export type HCEModuleStopReason = 'success' | 'failure';
6
21
 
7
22
  export type HCEModuleEvent = {
@@ -10,19 +25,62 @@ export type HCEModuleEvent = {
10
25
  }
11
26
 
12
27
  export interface Spec extends TurboModule {
28
+ /**
29
+ * Checks if a given smartphone supports required HCE features.
30
+ */
13
31
  isPlatformSupported(): boolean;
14
32
 
15
- acquireExclusiveNFC(): Promise<boolean>;
33
+ /**
34
+ * iOS: Tries to acquire NFCPresentmentIntentAssertion.
35
+ * Android: Not supported (throws an exception).
36
+ */
37
+ acquireExclusiveNFC(): Promise<void>;
38
+ /**
39
+ * iOS: Whether the NFCPresentmentIntentAssertion is acquired and still valid.
40
+ * Android: Always false.
41
+ */
16
42
  isExclusiveNFC(): boolean;
17
43
 
44
+ /**
45
+ * Begin card operation session.
46
+ * iOS: Create CardSession.
47
+ * Android: Sets an internal flag that the session is open.
48
+ */
18
49
  beginSession(): Promise<void>;
50
+ /**
51
+ * iOS: Set alert message on the NFC system UI.
52
+ * Android: NO-OP.
53
+ */
19
54
  setSessionAlertMessage(message: string): void;
55
+ /**
56
+ * iOS: Invalidate CardSession.
57
+ * Android: Set an internal flag that the session is closed and disable HCE service.
58
+ */
20
59
  invalidateSession(): void;
60
+ /**
61
+ * iOS: Checks if CardSession is running.
62
+ * Android: Checks if an internal flag is set.
63
+ */
21
64
  isSessionRunning(): boolean;
22
65
 
66
+ /**
67
+ * iOS: Start the card emulation.
68
+ * Android: Enable HCE service.
69
+ */
23
70
  startHCE(): Promise<void>;
71
+ /**
72
+ * iOS: Stop the card emulation with "success"/"failure" UI status.
73
+ * Android: Disable HCE service.
74
+ */
24
75
  stopHCE(status: HCEModuleStopReason): Promise<void>;
76
+ /**
77
+ * Send R-APDU (hex-encoded string) as a response to the last received C-APDU event.
78
+ */
25
79
  respondAPDU(rapdu: string): Promise<void>;
80
+ /**
81
+ * iOS: Checks if the card emulation is running.
82
+ * Android: Checks if the HCE service is enabled.
83
+ */
26
84
  isHCERunning(): Promise<boolean>;
27
85
 
28
86
  readonly onEvent: CodegenTypes.EventEmitter<HCEModuleEvent>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icedevml/react-native-host-card-emulation",
3
- "version": "0.2.2",
3
+ "version": "0.4.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",
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 Michal Leszczynski
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.