@icedevml/react-native-host-card-emulation 0.2.1 → 0.3.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 +89 -4
- 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 +33 -21
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/IntentKeys.java +2 -0
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/RTNHCEAndroidModule.java +77 -18
- package/android/src/main/java/com/itsecrnd/rtnhceandroid/RTNHCEAndroidPackage.java +1 -1
- package/js/NativeHCEModule.ts +65 -7
- package/package.json +1 -1
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -8,10 +8,95 @@ 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
|
-
|
|
12
|
-
|
|
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
|
+
### iOS
|
|
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
|
+
2. Ensure that you meet other [non-technical requirements imposed by Apple](https://developer.apple.com/support/hce-transactions-in-apps/#requirements-and-availability).
|
|
18
|
+
3. Find out the Bundle ID of your existing application or [register a new Bundle ID](https://developer.apple.com/account/resources/identifiers/bundleId/add/bundle).
|
|
19
|
+
4. [Request the HCE Entitlement](https://developer.apple.com/contact/request/hce-entitlement/) for your application's bundle ID.
|
|
20
|
+
5. Wait until the entitlement is approved by Apple.
|
|
21
|
+
|
|
22
|
+
### Android
|
|
23
|
+
*(no prerequisite steps are required for Android)*
|
|
24
|
+
|
|
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.
|
|
27
|
+
|
|
28
|
+
### iOS
|
|
29
|
+
1. Make sure to set up a proper signing team and bundle identifier in "Signing & Capabilities" configuration section of your project.
|
|
30
|
+
2. Add Host Card Emulation (HCE) entitlement with desired AIDs. This could be done either through XCode (by clicking "+ Capability") or within the entitlements XML file directly. Example:
|
|
31
|
+
```xml
|
|
32
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
33
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
34
|
+
<plist version="1.0">
|
|
35
|
+
<dict>
|
|
36
|
+
<key>com.apple.developer.nfc.hce</key>
|
|
37
|
+
<true/>
|
|
38
|
+
<key>com.apple.developer.nfc.hce.iso7816.select-identifier-prefixes</key>
|
|
39
|
+
<array>
|
|
40
|
+
<string>F001020304</string>
|
|
41
|
+
<string>F00102030405</string>
|
|
42
|
+
<string>F0010203040506</string>
|
|
43
|
+
</array>
|
|
44
|
+
</dict>
|
|
45
|
+
</plist>
|
|
46
|
+
```
|
|
47
|
+
Replace `F001020304`, `F00102030405`, `F0010203040506` with the ISO7816 AIDs that you want to support.
|
|
48
|
+
3. In the `ios/` app's subdirectory, run `bundle install && bundle exec pod install`.
|
|
49
|
+
|
|
50
|
+
### Android
|
|
51
|
+
1. In `android/app/src/main/AndroidManifest.xml` add:
|
|
52
|
+
```diff
|
|
53
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
+ <uses-permission android:name="android.permission.NFC" />
|
|
57
|
+
+ <uses-feature android:name="android.hardware.nfc.hce" android:required="true" />
|
|
58
|
+
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
<application
|
|
62
|
+
...
|
|
63
|
+
>
|
|
64
|
+
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
+ <service
|
|
68
|
+
+ android:name="com.itsecrnd.rtnhceandroid.HCEService"
|
|
69
|
+
+ android:exported="true"
|
|
70
|
+
+ android:enabled="false"
|
|
71
|
+
+ android:permission="android.permission.BIND_NFC_SERVICE" >
|
|
72
|
+
+ <intent-filter>
|
|
73
|
+
+ <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
|
74
|
+
+ <category android:name="android.intent.category.DEFAULT"/>
|
|
75
|
+
+ </intent-filter>
|
|
76
|
+
+
|
|
77
|
+
+ <meta-data
|
|
78
|
+
+ android:name="android.nfc.cardemulation.host_apdu_service"
|
|
79
|
+
+ android:resource="@xml/aid_list" />
|
|
80
|
+
+ </service>
|
|
81
|
+
</application>
|
|
82
|
+
</manifest>
|
|
83
|
+
```
|
|
84
|
+
2. Create file `android/app/src/main/res/xml/aid_list.xml`:
|
|
85
|
+
```xml
|
|
86
|
+
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
|
|
87
|
+
android:description="@string/app_name"
|
|
88
|
+
android:requireDeviceUnlock="false">
|
|
89
|
+
<aid-group android:category="other"
|
|
90
|
+
android:description="@string/app_name">
|
|
91
|
+
<aid-filter android:name="F001020304" />
|
|
92
|
+
<aid-filter android:name="F00102030405" />
|
|
93
|
+
<aid-filter android:name="F0010203040506" />
|
|
94
|
+
</aid-group>
|
|
95
|
+
</host-apdu-service>
|
|
96
|
+
```
|
|
97
|
+
Replace `F001020304`, `F00102030405`, `F0010203040506` with the ISO7816 AIDs that you want to support.
|
|
13
98
|
|
|
14
99
|
## API Specification & Demo App
|
|
15
100
|
|
|
16
|
-
* [module API (declarations)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/
|
|
17
|
-
* [demo app's code (example usage)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/demo-hce-module-
|
|
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)
|
|
@@ -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,5 +1,7 @@
|
|
|
1
1
|
package com.itsecrnd.rtnhceandroid;
|
|
2
2
|
|
|
3
|
+
import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_READER_DETECT;
|
|
4
|
+
import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_READER_LOST;
|
|
3
5
|
import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_RECEIVE_C_APDU;
|
|
4
6
|
import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_SEND_R_APDU;
|
|
5
7
|
|
|
@@ -8,15 +10,12 @@ import android.content.Context;
|
|
|
8
10
|
import android.content.Intent;
|
|
9
11
|
import android.content.IntentFilter;
|
|
10
12
|
import android.nfc.cardemulation.HostApduService;
|
|
11
|
-
import android.nfc.cardemulation.PollingFrame;
|
|
12
13
|
import android.os.Build;
|
|
13
14
|
import android.os.Bundle;
|
|
14
15
|
import android.util.Log;
|
|
15
16
|
|
|
16
|
-
import androidx.annotation.NonNull;
|
|
17
17
|
import androidx.annotation.RequiresApi;
|
|
18
18
|
|
|
19
|
-
import java.util.List;
|
|
20
19
|
import java.util.Locale;
|
|
21
20
|
|
|
22
21
|
import javax.crypto.SecretKey;
|
|
@@ -31,12 +30,20 @@ public class HCEService extends HostApduService {
|
|
|
31
30
|
private final BroadcastReceiver receiver = new BroadcastReceiver() {
|
|
32
31
|
@Override
|
|
33
32
|
public void onReceive(Context context, Intent intent) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
};
|
|
@@ -47,27 +54,20 @@ public class HCEService extends HostApduService {
|
|
|
47
54
|
|
|
48
55
|
Intent intent = new Intent(INTENT_RECEIVE_C_APDU);
|
|
49
56
|
intent.setPackage(getApplicationContext().getPackageName());
|
|
57
|
+
intent.putExtra("auth", AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
|
|
50
58
|
intent.putExtra("capdu", capdu);
|
|
51
59
|
getApplicationContext().sendBroadcast(intent);
|
|
52
60
|
|
|
53
61
|
return null;
|
|
54
62
|
}
|
|
55
63
|
|
|
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
64
|
@Override
|
|
63
65
|
public void onCreate() {
|
|
64
|
-
Log.d(TAG, "
|
|
65
|
-
|
|
66
|
-
IntentFilter filter = new IntentFilter();
|
|
67
|
-
filter.addAction(INTENT_SEND_R_APDU);
|
|
66
|
+
Log.d(TAG, "HCEService onCreate");
|
|
68
67
|
|
|
68
|
+
String prefsName = getApplicationContext().getPackageName() + ".nativehcemodule";
|
|
69
69
|
String encKey = getApplicationContext()
|
|
70
|
-
.getSharedPreferences(
|
|
70
|
+
.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
|
71
71
|
.getString("encKey", "");
|
|
72
72
|
|
|
73
73
|
if (encKey.isEmpty()) {
|
|
@@ -80,13 +80,25 @@ public class HCEService extends HostApduService {
|
|
|
80
80
|
/*
|
|
81
81
|
* See a comment about registerReceiver() in RTNHCEAndroidModule.
|
|
82
82
|
*/
|
|
83
|
+
IntentFilter filter = new IntentFilter();
|
|
84
|
+
filter.addAction(INTENT_SEND_R_APDU);
|
|
83
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);
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
@Override
|
|
87
94
|
public void onDeactivated(int reason) {
|
|
88
|
-
Log.d(TAG, "
|
|
95
|
+
Log.d(TAG, "HCEService onDeactivated: " + reason);
|
|
89
96
|
|
|
90
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);
|
|
91
103
|
}
|
|
92
104
|
}
|
|
@@ -3,4 +3,6 @@ package com.itsecrnd.rtnhceandroid;
|
|
|
3
3
|
public class IntentKeys {
|
|
4
4
|
public static final String INTENT_RECEIVE_C_APDU = "com.itsecrnd.rtnhceandroid.receivecapdu";
|
|
5
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";
|
|
6
8
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
package com.itsecrnd.rtnhceandroid;
|
|
2
2
|
|
|
3
3
|
import static android.content.Context.RECEIVER_EXPORTED;
|
|
4
|
-
|
|
4
|
+
import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_READER_DETECT;
|
|
5
|
+
import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_READER_LOST;
|
|
5
6
|
import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_RECEIVE_C_APDU;
|
|
6
7
|
import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_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
|
|
|
@@ -28,25 +31,56 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
|
|
|
28
31
|
public static final String NAME = "NativeHCEModule";
|
|
29
32
|
|
|
30
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
|
+
}
|
|
47
|
+
|
|
48
|
+
private void sendEvent(final String type, final String arg) {
|
|
49
|
+
WritableMap map = Arguments.createMap();
|
|
50
|
+
map.putString("type", type);
|
|
51
|
+
map.putString("arg", arg);
|
|
52
|
+
emitOnEvent(map);
|
|
53
|
+
}
|
|
31
54
|
|
|
32
55
|
private final BroadcastReceiver receiver = new BroadcastReceiver() {
|
|
33
56
|
@Override
|
|
34
57
|
public void onReceive(Context context, Intent intent) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
try {
|
|
59
|
+
String action = intent.getAction();
|
|
60
|
+
|
|
61
|
+
if (action != null) {
|
|
62
|
+
AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("auth"));
|
|
63
|
+
|
|
64
|
+
if (action.equals(INTENT_RECEIVE_C_APDU)) {
|
|
65
|
+
String capdu = AESGCMUtil.decryptData(encSecretKey, intent.getStringExtra("capdu"));
|
|
66
|
+
sendEvent("received", capdu);
|
|
67
|
+
} else if (action.equals(INTENT_READER_DETECT)) {
|
|
68
|
+
sendEvent("readerDetected", "");
|
|
69
|
+
} else if (action.equals(INTENT_READER_LOST)) {
|
|
70
|
+
sendEvent("readerDeselected", "");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (Exception e) {
|
|
74
|
+
Log.e(TAG, "Failed to handle broadcast in RTNHCEAndroidModule.", e);
|
|
44
75
|
}
|
|
45
76
|
}
|
|
46
77
|
};
|
|
47
78
|
|
|
48
79
|
RTNHCEAndroidModule(ReactApplicationContext context) {
|
|
49
80
|
super(context);
|
|
81
|
+
|
|
82
|
+
this.sessionOpen = false;
|
|
83
|
+
|
|
50
84
|
String encKey;
|
|
51
85
|
|
|
52
86
|
try {
|
|
@@ -56,14 +90,17 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
|
|
|
56
90
|
throw new RuntimeException("Failed to generate encryption key.", e);
|
|
57
91
|
}
|
|
58
92
|
|
|
93
|
+
String prefsName = getReactApplicationContext().getPackageName() + ".nativehcemodule";
|
|
59
94
|
getReactApplicationContext()
|
|
60
|
-
.getSharedPreferences(
|
|
95
|
+
.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
|
61
96
|
.edit()
|
|
62
97
|
.putString("encKey", encKey)
|
|
63
98
|
.commit();
|
|
64
99
|
|
|
65
100
|
IntentFilter filter = new IntentFilter();
|
|
66
101
|
filter.addAction(INTENT_RECEIVE_C_APDU);
|
|
102
|
+
filter.addAction(INTENT_READER_DETECT);
|
|
103
|
+
filter.addAction(INTENT_READER_LOST);
|
|
67
104
|
|
|
68
105
|
/*
|
|
69
106
|
* FIXME It seems like those intents are not going through across this module and HostApduService.
|
|
@@ -86,55 +123,73 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
|
|
|
86
123
|
|
|
87
124
|
@Override
|
|
88
125
|
public boolean isPlatformSupported() {
|
|
89
|
-
|
|
126
|
+
PackageManager pm = getReactApplicationContext().getPackageManager();
|
|
127
|
+
return pm.hasSystemFeature(PackageManager.FEATURE_NFC)
|
|
128
|
+
&& pm.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION);
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
@Override
|
|
93
132
|
public void acquireExclusiveNFC(Promise promise) {
|
|
94
|
-
promise.
|
|
133
|
+
promise.reject("err_platform_unsupported", "acquireExclusiveNFC() is not supported on Android.");
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
@Override
|
|
98
137
|
public boolean isExclusiveNFC() {
|
|
138
|
+
// unsupported on Android
|
|
99
139
|
return false;
|
|
100
140
|
}
|
|
101
141
|
|
|
102
142
|
@Override
|
|
103
143
|
public void beginSession(Promise promise) {
|
|
104
|
-
|
|
144
|
+
if (this.sessionOpen) {
|
|
145
|
+
promise.reject("err_card_session_exists", "Session already exists.");
|
|
146
|
+
} else {
|
|
147
|
+
this.sessionOpen = true;
|
|
148
|
+
promise.resolve(null);
|
|
149
|
+
sendEvent("sessionStarted", "");
|
|
150
|
+
}
|
|
105
151
|
}
|
|
106
152
|
|
|
107
153
|
@Override
|
|
108
154
|
public void setSessionAlertMessage(String message) {
|
|
109
|
-
|
|
155
|
+
// unsupported on Android, no-op
|
|
110
156
|
}
|
|
111
157
|
|
|
112
158
|
@Override
|
|
113
159
|
public void invalidateSession() {
|
|
114
|
-
|
|
160
|
+
toggleHceService(false);
|
|
161
|
+
this.sessionOpen = false;
|
|
162
|
+
sendEvent("sessionInvalidated", "");
|
|
115
163
|
}
|
|
116
164
|
|
|
117
165
|
@Override
|
|
118
166
|
public boolean isSessionRunning() {
|
|
119
|
-
return
|
|
167
|
+
return this.sessionOpen;
|
|
120
168
|
}
|
|
121
169
|
|
|
122
170
|
@Override
|
|
123
171
|
public void startHCE(Promise promise) {
|
|
172
|
+
toggleHceService(true);
|
|
124
173
|
promise.resolve(null);
|
|
125
174
|
}
|
|
126
175
|
|
|
127
176
|
@Override
|
|
128
177
|
public void stopHCE(String status, Promise promise) {
|
|
178
|
+
toggleHceService(false);
|
|
129
179
|
promise.resolve(null);
|
|
130
180
|
}
|
|
131
181
|
|
|
132
182
|
@Override
|
|
133
183
|
public void respondAPDU(String rapdu, Promise promise) {
|
|
184
|
+
if (!this.sessionOpen) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
134
188
|
String encRapdu = AESGCMUtil.encryptData(encSecretKey, rapdu);
|
|
135
189
|
|
|
136
190
|
Intent intent = new Intent(INTENT_SEND_R_APDU);
|
|
137
191
|
intent.setPackage(getReactApplicationContext().getPackageName());
|
|
192
|
+
intent.putExtra("auth", AESGCMUtil.encryptData(encSecretKey, AESGCMUtil.randomString()));
|
|
138
193
|
intent.putExtra("rapdu", encRapdu);
|
|
139
194
|
getReactApplicationContext().getApplicationContext().sendBroadcast(intent);
|
|
140
195
|
|
|
@@ -143,6 +198,10 @@ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
|
|
|
143
198
|
|
|
144
199
|
@Override
|
|
145
200
|
public void isHCERunning(Promise promise) {
|
|
146
|
-
|
|
201
|
+
int enabledSetting = getReactApplicationContext()
|
|
202
|
+
.getPackageManager()
|
|
203
|
+
.getComponentEnabledSetting(new ComponentName(getReactApplicationContext(), HCEService.class));
|
|
204
|
+
|
|
205
|
+
promise.resolve(enabledSetting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED);
|
|
147
206
|
}
|
|
148
207
|
}
|
package/js/NativeHCEModule.ts
CHANGED
|
@@ -1,31 +1,89 @@
|
|
|
1
1
|
import type {TurboModule, CodegenTypes} from 'react-native';
|
|
2
2
|
import {TurboModuleRegistry} from 'react-native';
|
|
3
3
|
|
|
4
|
-
export type
|
|
5
|
-
|
|
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';
|
|
20
|
+
export type HCEModuleStopReason = 'success' | 'failure';
|
|
6
21
|
|
|
7
|
-
export type
|
|
8
|
-
type:
|
|
22
|
+
export type HCEModuleEvent = {
|
|
23
|
+
type: HCEModuleEventType
|
|
9
24
|
arg: string | null,
|
|
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>;
|
|
24
|
-
|
|
71
|
+
/**
|
|
72
|
+
* iOS: Stop the card emulation with "success"/"failure" UI status.
|
|
73
|
+
* Android: Disable HCE service.
|
|
74
|
+
*/
|
|
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
|
-
readonly onEvent: CodegenTypes.EventEmitter<
|
|
86
|
+
readonly onEvent: CodegenTypes.EventEmitter<HCEModuleEvent>;
|
|
29
87
|
}
|
|
30
88
|
|
|
31
89
|
export default TurboModuleRegistry.getEnforcing<Spec>(
|
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.
|