@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 +65 -5
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/AESGCMUtil.java +16 -16
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/BinaryUtils.java +2 -2
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/HCEService.java +40 -25
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/IntentKeys.java +7 -2
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/RTNHCEAndroidModule.java +101 -21
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/RTNHCEAndroidPackage.java +1 -1
- package/ios/RTNHCEModuleIOSImpl.swift +8 -1
- package/js/NativeHCEModule.ts +60 -2
- package/package.json +1 -1
- package/LICENSE +0 -21
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
import static com.itsecrnd.rtnhceandroid.IntentKeys.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
58
|
+
Intent intent = new Intent(ACTION_RECEIVE_C_APDU);
|
|
49
59
|
intent.setPackage(getApplicationContext().getPackageName());
|
|
50
|
-
intent.putExtra(
|
|
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, "
|
|
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(
|
|
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, "
|
|
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
|
|
5
|
-
public static final String
|
|
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.
|
|
6
|
-
import static com.itsecrnd.rtnhceandroid.IntentKeys.
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
package/js/NativeHCEModule.ts
CHANGED
|
@@ -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'
|
|
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
|
-
|
|
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
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.
|