@icedevml/react-native-host-card-emulation 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
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.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Package: React Native HCE Module
2
+
3
+ Host Card Emulation Module for React Native on iOS and Android platforms.
4
+
5
+ > [!IMPORTANT]
6
+ > As per Apple's policy, **Host Card Emulation feature is only available for users based in the European
7
+ > Economic Area (EEA)** with an iPhone running iOS 17.4 or later. Applications require a special HCE entitlement
8
+ > in order to use this feature, which is managed by Apple. Read more in the official support article:
9
+ > "[HCE-based contactless NFC transactions for apps in the European Economic Area (EEA)](https://developer.apple.com/support/hce-transactions-in-apps/)".
10
+
11
+ > [!NOTE]
12
+ > Kindly please reference this project in your README if you are willing to create a derivative library.
13
+
14
+ ## API Specification & Demo App
15
+
16
+ * [module API (declarations)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/react-native-hce-module/js/NativeHCEModule.ts)
17
+ * [demo app's code (example usage)](https://github.com/icedevml/react-native-host-card-emulation/blob/master/packages/demo-hce-module-ios-app/App.tsx)
@@ -0,0 +1,19 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "RTNHCEModuleIOS"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.description = package["description"]
10
+ s.homepage = package["homepage"]
11
+ s.license = package["license"]
12
+ s.platforms = { :ios => "11.0" }
13
+ s.author = package["author"]
14
+ s.source = { :git => package["repository"], :tag => "#{s.version}" }
15
+
16
+ s.source_files = "ios/**/*.{h,m,mm,swift}"
17
+
18
+ install_modules_dependencies(s)
19
+ end
@@ -0,0 +1,33 @@
1
+ buildscript {
2
+ ext.safeExtGet = {prop, fallback ->
3
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
4
+ }
5
+ repositories {
6
+ google()
7
+ gradlePluginPortal()
8
+ }
9
+ dependencies {
10
+ classpath("com.android.tools.build:gradle:7.3.1")
11
+ }
12
+ }
13
+
14
+ apply plugin: 'com.android.library'
15
+ apply plugin: 'com.facebook.react'
16
+
17
+ android {
18
+ compileSdkVersion safeExtGet('compileSdkVersion', 33)
19
+ namespace "com.itsecrnd.rtnhceandroid"
20
+ }
21
+
22
+ repositories {
23
+ maven {
24
+ // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
25
+ url "$projectDir/../node_modules/react-native/android"
26
+ }
27
+ mavenCentral()
28
+ google()
29
+ }
30
+
31
+ dependencies {
32
+ implementation 'com.facebook.react:react-native:+'
33
+ }
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ package="com.itsecrnd.rtnhceandroid">
3
+ </manifest>
@@ -0,0 +1,89 @@
1
+ package com.itsecrnd.rtnhceandroid;
2
+
3
+ import android.annotation.SuppressLint;
4
+
5
+ import javax.crypto.BadPaddingException;
6
+ import javax.crypto.Cipher;
7
+ import javax.crypto.IllegalBlockSizeException;
8
+ import javax.crypto.KeyGenerator;
9
+ import javax.crypto.NoSuchPaddingException;
10
+ import javax.crypto.SecretKey;
11
+ import javax.crypto.spec.GCMParameterSpec;
12
+
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
+ @SuppressLint("NewApi")
21
+ public class AESGCMUtil {
22
+ public static SecretKey generateKey() {
23
+ KeyGenerator keyGen;
24
+ try {
25
+ keyGen = KeyGenerator.getInstance("AES");
26
+ } catch (NoSuchAlgorithmException e) {
27
+ throw new RuntimeException("Failed to get AES algorithm.", e);
28
+ }
29
+ keyGen.init(256);
30
+ return keyGen.generateKey();
31
+ }
32
+
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
+ 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(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(e);
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,66 @@
1
+ /*
2
+ * Content of "BinaryUtils" class:
3
+ * Original source: https://github.com/googlearchive/android-CardEmulation
4
+ *
5
+ * ---
6
+ *
7
+ * Copyright (C) 2013 The Android Open Source Project
8
+ *
9
+ * Licensed under the Apache License, Version 2.0 (the "License");
10
+ * you may not use this file except in compliance with the License.
11
+ * You may obtain a copy of the License at
12
+ *
13
+ * http://www.apache.org/licenses/LICENSE-2.0
14
+ *
15
+ * Unless required by applicable law or agreed to in writing, software
16
+ * distributed under the License is distributed on an "AS IS" BASIS,
17
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
+ * See the License for the specific language governing permissions and
19
+ * limitations under the License.
20
+ *
21
+ */
22
+
23
+ package com.itsecrnd.rtnhceandroid;
24
+
25
+ public class BinaryUtils {
26
+ /**
27
+ * Utility method to convert a byte array to a hexadecimal string.
28
+ *
29
+ * @param bytes Bytes to convert
30
+ * @return String, containing hexadecimal representation.
31
+ */
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'};
34
+ char[] hexChars = new char[bytes.length * 2]; // Each byte has two hex characters (nibbles)
35
+ int v;
36
+ for (int j = 0; j < bytes.length; j++) {
37
+ v = bytes[j] & 0xFF; // Cast bytes[j] to int, treating as unsigned value
38
+ hexChars[j * 2] = hexArray[v >>> 4]; // Select hex character from upper nibble
39
+ hexChars[j * 2 + 1] = hexArray[v & 0x0F]; // Select hex character from lower nibble
40
+ }
41
+ return new String(hexChars);
42
+ }
43
+
44
+ /**
45
+ * Utility method to convert a hexadecimal string to a byte string.
46
+ *
47
+ * <p>Behavior with input strings containing non-hexadecimal characters is undefined.
48
+ *
49
+ * @param s String containing hexadecimal characters to convert
50
+ * @return Byte array generated from input
51
+ * @throws java.lang.IllegalArgumentException if input length is incorrect
52
+ */
53
+ public static byte[] HexStringToByteArray(String s) throws IllegalArgumentException {
54
+ int len = s.length();
55
+ if (len % 2 == 1) {
56
+ throw new IllegalArgumentException("Hex string must have even number of characters");
57
+ }
58
+ byte[] data = new byte[len / 2]; // Allocate 1 byte per 2 hex characters
59
+ for (int i = 0; i < len; i += 2) {
60
+ // Convert each character into a integer (base-16), then bit-shift into place
61
+ data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
62
+ + Character.digit(s.charAt(i+1), 16));
63
+ }
64
+ return data;
65
+ }
66
+ }
@@ -0,0 +1,92 @@
1
+ package com.itsecrnd.rtnhceandroid;
2
+
3
+ import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_RECEIVE_C_APDU;
4
+ import static com.itsecrnd.rtnhceandroid.IntentKeys.INTENT_SEND_R_APDU;
5
+
6
+ import android.content.BroadcastReceiver;
7
+ import android.content.Context;
8
+ import android.content.Intent;
9
+ import android.content.IntentFilter;
10
+ import android.nfc.cardemulation.HostApduService;
11
+ import android.nfc.cardemulation.PollingFrame;
12
+ import android.os.Build;
13
+ import android.os.Bundle;
14
+ import android.util.Log;
15
+
16
+ import androidx.annotation.NonNull;
17
+ import androidx.annotation.RequiresApi;
18
+
19
+ import java.util.List;
20
+ import java.util.Locale;
21
+
22
+ import javax.crypto.SecretKey;
23
+ import javax.crypto.spec.SecretKeySpec;
24
+
25
+ @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
26
+ public class HCEService extends HostApduService {
27
+ private static final String TAG = "HCEService";
28
+
29
+ private SecretKey encSecretKey;
30
+
31
+ private final BroadcastReceiver receiver = new BroadcastReceiver() {
32
+ @Override
33
+ 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);
40
+ }
41
+ }
42
+ };
43
+
44
+ @Override
45
+ public byte[] processCommandApdu(byte[] command, Bundle extras) {
46
+ String capdu = AESGCMUtil.encryptData(encSecretKey, BinaryUtils.ByteArrayToHexString(command));
47
+
48
+ Intent intent = new Intent(INTENT_RECEIVE_C_APDU);
49
+ intent.setPackage(getApplicationContext().getPackageName());
50
+ intent.putExtra("capdu", capdu);
51
+ getApplicationContext().sendBroadcast(intent);
52
+
53
+ return null;
54
+ }
55
+
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
+ @Override
63
+ public void onCreate() {
64
+ Log.d(TAG, "Starting service");
65
+
66
+ IntentFilter filter = new IntentFilter();
67
+ filter.addAction(INTENT_SEND_R_APDU);
68
+
69
+ String encKey = getApplicationContext()
70
+ .getSharedPreferences("RTNHCEAndroidModuleBroadcastEnc", 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
+ getApplicationContext().registerReceiver(receiver, filter, RECEIVER_EXPORTED);
84
+ }
85
+
86
+ @Override
87
+ public void onDeactivated(int reason) {
88
+ Log.d(TAG, "Finishing service: " + reason);
89
+
90
+ getApplicationContext().unregisterReceiver(receiver);
91
+ }
92
+ }
@@ -0,0 +1,6 @@
1
+ package com.itsecrnd.rtnhceandroid;
2
+
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
+ }
@@ -0,0 +1,148 @@
1
+ package com.itsecrnd.rtnhceandroid;
2
+
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;
7
+
8
+ import android.content.BroadcastReceiver;
9
+ import android.content.Context;
10
+ import android.content.Intent;
11
+ import android.content.IntentFilter;
12
+ import android.os.Build;
13
+ import android.util.Log;
14
+
15
+ import androidx.annotation.NonNull;
16
+ import androidx.annotation.RequiresApi;
17
+
18
+ import com.facebook.react.bridge.Arguments;
19
+ import com.facebook.react.bridge.Promise;
20
+ import com.facebook.react.bridge.ReactApplicationContext;
21
+ import com.facebook.react.bridge.WritableMap;
22
+
23
+ import javax.crypto.SecretKey;
24
+
25
+ @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
26
+ public class RTNHCEAndroidModule extends NativeHCEModuleSpec {
27
+ public static final String TAG = "RTNHCEAndroidModule";
28
+ public static final String NAME = "NativeHCEModule";
29
+
30
+ private final SecretKey encSecretKey;
31
+
32
+ private final BroadcastReceiver receiver = new BroadcastReceiver() {
33
+ @Override
34
+ 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);
44
+ }
45
+ }
46
+ };
47
+
48
+ RTNHCEAndroidModule(ReactApplicationContext context) {
49
+ super(context);
50
+ String encKey;
51
+
52
+ try {
53
+ encSecretKey = AESGCMUtil.generateKey();
54
+ encKey = BinaryUtils.ByteArrayToHexString(encSecretKey.getEncoded());
55
+ } catch (Exception e) {
56
+ throw new RuntimeException("Failed to generate encryption key.", e);
57
+ }
58
+
59
+ getReactApplicationContext()
60
+ .getSharedPreferences("RTNHCEAndroidModuleBroadcastEnc", Context.MODE_PRIVATE)
61
+ .edit()
62
+ .putString("encKey", encKey)
63
+ .commit();
64
+
65
+ IntentFilter filter = new IntentFilter();
66
+ filter.addAction(INTENT_RECEIVE_C_APDU);
67
+
68
+ /*
69
+ * FIXME It seems like those intents are not going through across this module and HostApduService.
70
+ * This happens even though those things are within the same application and package.
71
+ * It also seems like there is no way to bind HostApduService (or it's a skill issue).
72
+ *
73
+ * For now we will just encrypt&authenticate all messages using AES/GCM and use exported
74
+ * receivers. This is not a perfect solution but it is going to be secure and sufficient for now.
75
+ *
76
+ * If somebody knows a better way then any pull requests would be greatly appreciated.
77
+ */
78
+ getReactApplicationContext().getApplicationContext().registerReceiver(receiver, filter, RECEIVER_EXPORTED);
79
+ }
80
+
81
+ @Override
82
+ @NonNull
83
+ public String getName() {
84
+ return RTNHCEAndroidModule.NAME;
85
+ }
86
+
87
+ @Override
88
+ public boolean isPlatformSupported() {
89
+ return true;
90
+ }
91
+
92
+ @Override
93
+ public void acquireExclusiveNFC(Promise promise) {
94
+ promise.resolve(null);
95
+ }
96
+
97
+ @Override
98
+ public boolean isExclusiveNFC() {
99
+ return false;
100
+ }
101
+
102
+ @Override
103
+ public void beginSession(Promise promise) {
104
+ promise.resolve(null);
105
+ }
106
+
107
+ @Override
108
+ public void setSessionAlertMessage(String message) {
109
+
110
+ }
111
+
112
+ @Override
113
+ public void invalidateSession() {
114
+
115
+ }
116
+
117
+ @Override
118
+ public boolean isSessionRunning() {
119
+ return true;
120
+ }
121
+
122
+ @Override
123
+ public void startHCE(Promise promise) {
124
+ promise.resolve(null);
125
+ }
126
+
127
+ @Override
128
+ public void stopHCE(String status, Promise promise) {
129
+ promise.resolve(null);
130
+ }
131
+
132
+ @Override
133
+ public void respondAPDU(String rapdu, Promise promise) {
134
+ String encRapdu = AESGCMUtil.encryptData(encSecretKey, rapdu);
135
+
136
+ Intent intent = new Intent(INTENT_SEND_R_APDU);
137
+ intent.setPackage(getReactApplicationContext().getPackageName());
138
+ intent.putExtra("rapdu", encRapdu);
139
+ getReactApplicationContext().getApplicationContext().sendBroadcast(intent);
140
+
141
+ promise.resolve(null);
142
+ }
143
+
144
+ @Override
145
+ public void isHCERunning(Promise promise) {
146
+ promise.resolve(true);
147
+ }
148
+ }
@@ -0,0 +1,47 @@
1
+ package com.itsecrnd.rtnhceandroid;
2
+
3
+ import android.os.Build;
4
+
5
+ import androidx.annotation.Nullable;
6
+ import androidx.annotation.RequiresApi;
7
+
8
+ import com.facebook.react.TurboReactPackage;
9
+ import com.facebook.react.bridge.NativeModule;
10
+ import com.facebook.react.bridge.ReactApplicationContext;
11
+ import com.facebook.react.module.model.ReactModuleInfo;
12
+ import com.facebook.react.module.model.ReactModuleInfoProvider;
13
+
14
+ import java.util.HashMap;
15
+ import java.util.Map;
16
+
17
+ @RequiresApi(api = Build.VERSION_CODES.TIRAMISU)
18
+ public class RTNHCEAndroidPackage extends TurboReactPackage {
19
+ @Nullable
20
+ @Override
21
+ public NativeModule getModule(String name, ReactApplicationContext reactContext) {
22
+ if (name.equals(RTNHCEAndroidModule.NAME)) {
23
+ return new RTNHCEAndroidModule(reactContext);
24
+ } else {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ @Override
30
+ public ReactModuleInfoProvider getReactModuleInfoProvider() {
31
+ return () -> {
32
+ final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();
33
+ moduleInfos.put(
34
+ RTNHCEAndroidModule.NAME,
35
+ new ReactModuleInfo(
36
+ RTNHCEAndroidModule.NAME,
37
+ RTNHCEAndroidModule.NAME,
38
+ false, // canOverrideExistingModule
39
+ false, // needsEagerInit
40
+ true, // hasConstants
41
+ false, // isCxxModule
42
+ true // isTurboModule
43
+ ));
44
+ return moduleInfos;
45
+ };
46
+ }
47
+ }
@@ -0,0 +1 @@
1
+ #import <React-RCTAppDelegate/RCTDefaultReactNativeFactoryDelegate.h>
@@ -0,0 +1,12 @@
1
+ #ifdef RCT_NEW_ARCH_ENABLED
2
+ #import <Foundation/Foundation.h>
3
+ #import <RTNHCEModuleSpec/RTNHCEModuleSpec.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ @interface RTNHCEModuleIOS : NativeHCEModuleSpecBase <NativeHCEModuleSpec>
8
+
9
+ @end
10
+
11
+ NS_ASSUME_NONNULL_END
12
+ #endif
@@ -0,0 +1,125 @@
1
+ #import "RTNHCEModuleIOS.h"
2
+ #import "RCTDefaultReactNativeFactoryDelegate.h"
3
+ #import "RTNHCEModuleIOS-Swift.h"
4
+
5
+ @implementation RTNHCEModuleIOS {
6
+ API_AVAILABLE(ios(17.4))
7
+ RTNHCEModuleIOSImpl *hcemoduleios;
8
+ }
9
+
10
+ -(id) init {
11
+ self = [super init];
12
+
13
+ if (@available(iOS 17.4, *)) {
14
+ __weak RTNHCEModuleIOS *weakSelf = self;
15
+
16
+ hcemoduleios = [RTNHCEModuleIOSImpl new];
17
+ [hcemoduleios setEmitOnEventWithEmitOnEvent:^(NSString *type, NSString *arg){
18
+ [weakSelf emitOnEvent:@{@"type": type, @"arg": arg}];
19
+ }];
20
+ } else {
21
+ // ignore
22
+ }
23
+
24
+ return(self);
25
+ }
26
+
27
+ - (void)dealloc {
28
+ [hcemoduleios setEmitOnEventWithEmitOnEvent:^(NSString *type, NSString *arg){
29
+ // no-op
30
+ }];
31
+ }
32
+
33
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
34
+ return std::make_shared<facebook::react::NativeHCEModuleSpecJSI>(params);
35
+ }
36
+
37
+ - (nonnull NSNumber *)isPlatformSupported {
38
+ if (@available(iOS 17.4, *)) {
39
+ return @1;
40
+ }
41
+
42
+ return @0;
43
+ }
44
+
45
+ - (void)acquireExclusiveNFC:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
46
+ if (@available(iOS 17.4, *)) {
47
+ return [hcemoduleios acquireExclusiveNFCWithResolve:resolve reject:reject];
48
+ } else {
49
+ reject(@"err_platform_unsupported", @"This iOS version does not support HCE.", nil);
50
+ }
51
+ }
52
+
53
+ - (nonnull NSNumber *)isExclusiveNFC {
54
+ if (@available(iOS 17.4, *)) {
55
+ return [hcemoduleios isExclusiveNFC] ? @1 : @0;
56
+ }
57
+
58
+ return @0;
59
+ }
60
+
61
+ - (void)beginSession:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
62
+ if (@available(iOS 17.4, *)) {
63
+ return [hcemoduleios beginSessionWithResolve:resolve reject:reject];
64
+ } else {
65
+ reject(@"err_platform_unsupported", @"This iOS version does not support HCE.", nil);
66
+ }
67
+ }
68
+
69
+ - (void)setSessionAlertMessage:(nonnull NSString *)message {
70
+ if (@available(iOS 17.4, *)) {
71
+ return [hcemoduleios setSessionAlertMessageWithMessage:message];
72
+ }
73
+ }
74
+
75
+ - (void)invalidateSession {
76
+ if (@available(iOS 17.4, *)) {
77
+ [hcemoduleios invalidateSession];
78
+ }
79
+ }
80
+
81
+ - (nonnull NSNumber *)isSessionRunning {
82
+ if (@available(iOS 17.4, *)) {
83
+ return [hcemoduleios isSessionRunning] ? @1 : @0;
84
+ } else {
85
+ return @0;
86
+ }
87
+ }
88
+
89
+ - (void)startHCE:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
90
+ if (@available(iOS 17.4, *)) {
91
+ return [hcemoduleios startHCEWithResolve:resolve reject:reject];
92
+ } else {
93
+ reject(@"err_platform_unsupported", @"This iOS version does not support HCE.", nil);
94
+ }
95
+ }
96
+
97
+ - (void)stopHCE:(nonnull NSString *)status resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
98
+ if (@available(iOS 17.4, *)) {
99
+ return [hcemoduleios stopHCEWithStatus:status resolve:resolve reject:reject];
100
+ } else {
101
+ reject(@"err_platform_unsupported", @"This iOS version does not support HCE.", nil);
102
+ }
103
+ }
104
+
105
+ - (void)respondAPDU:(nonnull NSString *)rapdu resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
106
+ if (@available(iOS 17.4, *)) {
107
+ return [hcemoduleios respondAPDUWithRapdu:rapdu resolve:resolve reject:reject];
108
+ } else {
109
+ reject(@"err_platform_unsupported", @"This iOS version does not support HCE.", nil);
110
+ }
111
+ }
112
+
113
+ - (void)isHCERunning:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
114
+ if (@available(iOS 17.4, *)) {
115
+ [hcemoduleios isHCERunningWithResolve:resolve reject:reject];
116
+ } else {
117
+ reject(@"err_platform_unsupported", @"This iOS version does not support HCE.", nil);
118
+ }
119
+ }
120
+
121
+ + (NSString *)moduleName {
122
+ return @"NativeHCEModule";
123
+ }
124
+
125
+ @end
@@ -0,0 +1,190 @@
1
+ import Foundation
2
+ import CoreNFC
3
+
4
+ extension StringProtocol {
5
+ func asHexArray() -> [UInt8] {
6
+ var startIndex = self.startIndex
7
+ return stride(from: 0, to: count, by: 2).compactMap { _ in
8
+ let endIndex = index(startIndex, offsetBy: 2, limitedBy: self.endIndex) ?? self.endIndex
9
+ defer { startIndex = endIndex }
10
+ return UInt8(self[startIndex..<endIndex], radix: 16)
11
+ }
12
+ }
13
+ }
14
+
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"
23
+ return map { String(format: format, $0) }.joined()
24
+ }
25
+ }
26
+
27
+ @available(iOS 17.4, *)
28
+ @objc public class RTNHCEModuleIOSImpl: NSObject {
29
+ var cardSession: CardSession? = nil
30
+ var cardSessionInvalidated: Bool = true
31
+ var presentmentIntent: NFCPresentmentIntentAssertion? = nil
32
+ var receivedCardAPDU: CardSession.APDU? = nil
33
+
34
+ var emitOnEvent: ((NSString, NSString?) -> ())? = nil
35
+
36
+ @objc public func setEmitOnEvent(emitOnEvent: @escaping (NSString, NSString?) -> ()) {
37
+ self.emitOnEvent = emitOnEvent
38
+ }
39
+
40
+ @objc public func acquireExclusiveNFC(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
41
+ Task.init {
42
+ if self.presentmentIntent != nil && self.presentmentIntent!.isValid {
43
+ reject("err_already_exists", "NFCPresentmentIntentAssertion already exists and is valid.", nil)
44
+ return
45
+ }
46
+
47
+ self.presentmentIntent = nil
48
+
49
+ do {
50
+ self.presentmentIntent = try await NFCPresentmentIntentAssertion.acquire()
51
+ } catch (let error) {
52
+ // TODO exact error details here and in other places as well
53
+ reject("err_create_presentment", "Failed to create NFCPresentmentIntentAssertion: \(error)", nil)
54
+ return
55
+ }
56
+
57
+ resolve(nil)
58
+ }
59
+ }
60
+
61
+ @objc public func isExclusiveNFC() -> Bool {
62
+ return self.presentmentIntent != nil && self.presentmentIntent!.isValid
63
+ }
64
+
65
+ @objc public func beginSession(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
66
+ Task.init {
67
+ guard NFCReaderSession.readingAvailable else {
68
+ reject("err_nfc_reader_not_available", "Preflight check failed: NFCReaderSession.readingAvailable is false", nil)
69
+ return
70
+ }
71
+
72
+ guard CardSession.isSupported else {
73
+ reject("err_card_session_unsupported", "Preflight check failed: CardSession.isSupported is false", nil)
74
+ return
75
+ }
76
+
77
+ guard await CardSession.isEligible else {
78
+ reject("err_card_session_not_eligible", "Preflight check failed: CardSession.isEligible is false", nil)
79
+ return
80
+ }
81
+
82
+ guard self.cardSessionInvalidated else {
83
+ reject("err_card_session_exists", "CardSession already exists.", nil)
84
+ return
85
+ }
86
+
87
+ do {
88
+ self.cardSession = try await CardSession()
89
+ } catch (let error) {
90
+ reject("err_create_card_session", "Failed to create CardSession(): \(error)", nil)
91
+ return
92
+ }
93
+
94
+ resolve(nil)
95
+
96
+ do {
97
+ for try await event in cardSession!.eventStream {
98
+ switch event {
99
+ case .sessionStarted:
100
+ cardSession!.alertMessage = String(localized: "Communicating with card reader.")
101
+ self.emitOnEvent!("sessionStarted", "")
102
+
103
+ case .readerDetected:
104
+ self.emitOnEvent!("readerDetected", "")
105
+
106
+ case .readerDeselected:
107
+ self.emitOnEvent!("readerDeselected", "")
108
+
109
+ case .received(let cardAPDU):
110
+ self.receivedCardAPDU = cardAPDU
111
+ self.emitOnEvent!("received", NSString(string:cardAPDU.payload.hexEncodedString()))
112
+
113
+ case .sessionInvalidated(let reason):
114
+ self.cardSessionInvalidated = true
115
+ self.emitOnEvent!("sessionInvalidated", "\(reason)" as NSString)
116
+ self.cardSession = nil
117
+
118
+ @unknown default:
119
+ fatalError("Received unknown CardSession event.")
120
+ }
121
+ }
122
+ } catch (let err) {
123
+ self.emitOnEvent!("internalError", "\(err)" as NSString)
124
+ }
125
+ }
126
+ }
127
+
128
+ @objc public func setSessionAlertMessage(message: NSString) {
129
+ self.cardSession?.alertMessage = String(message)
130
+ }
131
+
132
+ @objc public func invalidateSession() {
133
+ self.cardSession?.invalidate()
134
+ self.cardSession = nil
135
+ self.cardSessionInvalidated = true
136
+ }
137
+
138
+ @objc public func isSessionRunning() -> Bool {
139
+ return self.cardSession != nil && !self.cardSessionInvalidated
140
+ }
141
+
142
+ @objc public func isHCERunning(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
143
+ Task.init {
144
+ guard self.cardSession != nil else {
145
+ return resolve(false)
146
+ }
147
+
148
+ let isRunning = await self.cardSession!.isEmulationInProgress
149
+ resolve(isRunning)
150
+ }
151
+ }
152
+
153
+ @objc public func respondAPDU(rapdu: NSString, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
154
+ Task.init {
155
+ guard let capdu = self.receivedCardAPDU else {
156
+ reject("err_no_apdu_received", "There is no received APDU to respond to.", nil)
157
+ return
158
+ }
159
+
160
+ do {
161
+ try await capdu.respond(response: Data.init(String(rapdu).asHexArray()))
162
+ } catch (let error) {
163
+ reject("err_apdu_respond", "Error trying to respond to APDU. \(error)", nil)
164
+ return
165
+ }
166
+
167
+ resolve(nil)
168
+ }
169
+ }
170
+
171
+ @objc public func startHCE(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
172
+ Task.init {
173
+ try await self.cardSession?.startEmulation()
174
+ }
175
+ }
176
+
177
+ @objc public func stopHCE(status: NSString, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
178
+ Task.init {
179
+ let sStatus = String(status)
180
+
181
+ if (sStatus == "success") {
182
+ await self.cardSession?.stopEmulation(status: .success)
183
+ } else {
184
+ await self.cardSession?.stopEmulation(status: .failure)
185
+ }
186
+
187
+ resolve(nil)
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,33 @@
1
+ import type {TurboModule, CodegenTypes} from 'react-native';
2
+ import {TurboModuleRegistry} from 'react-native';
3
+
4
+ export type HCEModuleIOSEventType = 'sessionStarted' | 'readerDetected' | 'readerDeselected' | 'received' | 'sessionInvalidated';
5
+ export type HCEModuleIOSStopReason = 'success' | 'failure';
6
+
7
+ export type HCEModuleIOSEvent = {
8
+ type: HCEModuleIOSEventType
9
+ arg: string | null,
10
+ }
11
+
12
+ export interface Spec extends TurboModule {
13
+ isPlatformSupported(): boolean;
14
+
15
+ acquireExclusiveNFC(): Promise<boolean>;
16
+ isExclusiveNFC(): boolean;
17
+
18
+ beginSession(): Promise<void>;
19
+ setSessionAlertMessage(message: string): void;
20
+ invalidateSession(): void;
21
+ isSessionRunning(): boolean;
22
+
23
+ startHCE(): Promise<void>;
24
+ stopHCE(status: HCEModuleIOSStopReason): Promise<void>;
25
+ respondAPDU(rapdu: string): Promise<void>;
26
+ isHCERunning(): Promise<boolean>;
27
+
28
+ readonly onEvent: CodegenTypes.EventEmitter<HCEModuleIOSEvent>;
29
+ }
30
+
31
+ export default TurboModuleRegistry.getEnforcing<Spec>(
32
+ 'NativeHCEModule',
33
+ );
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@icedevml/react-native-host-card-emulation",
3
+ "version": "0.2.0",
4
+ "description": "Host Card Emulation (HCE) React Native Turbo Module for iOS and Android platforms.",
5
+ "react-native": "js/index",
6
+ "source": "js/index",
7
+ "files": [
8
+ "js",
9
+ "android",
10
+ "ios",
11
+ "RTNHCEModuleIOS.podspec",
12
+ "!android/build",
13
+ "!ios/build",
14
+ "!**/__tests__",
15
+ "!**/__fixtures__",
16
+ "!**/__mocks__"
17
+ ],
18
+ "keywords": [
19
+ "react-native",
20
+ "ios",
21
+ "nfc",
22
+ "hce"
23
+ ],
24
+ "repository": "https://github.com/icedevml/react-native-host-card-emulation",
25
+ "author": "Michal Leszczynski ml@icedev.pl (https://github.com/icedevml)",
26
+ "license": "MIT",
27
+ "bugs": {
28
+ "url": "https://github.com/icedevml/react-native-host-card-emulation/issues"
29
+ },
30
+ "homepage": "https://github.com/icedevml/react-native-host-card-emulation#readme",
31
+ "peerDependencies": {
32
+ "react": "*",
33
+ "react-native": "*"
34
+ },
35
+ "codegenConfig": {
36
+ "name": "RTNHCEModuleSpec",
37
+ "type": "modules",
38
+ "jsSrcsDir": "js",
39
+ "android": {
40
+ "javaPackageName": "com.itsecrnd.rtnhceandroid"
41
+ },
42
+ "ios": {
43
+ "modulesProvider": {
44
+ "NativeHCEModule": "RTNHCEModuleIOS"
45
+ }
46
+ }
47
+ }
48
+ }