diff options
| author | Adam Soltys <[email protected]> | 2026-03-05 15:05:37 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-05 15:51:36 -0800 |
| commit | 4c519faaf748cea25f9d0be87dd0f3e09d7bae4c (patch) | |
| tree | 1fbda1930d8a8f8d6d03871274105dbfb7e1caa8 /android | |
| parent | 4a74d51e92365a731a2e5322783b88a39e14f046 (diff) | |
| download | coinos-ui-4c519faaf748cea25f9d0be87dd0f3e09d7bae4c.tar.gz coinos-ui-4c519faaf748cea25f9d0be87dd0f3e09d7bae4c.zip | |
android hce nfc
Diffstat (limited to 'android')
| -rw-r--r-- | android/app/build.gradle | 5 | ||||
| -rw-r--r-- | android/app/src/main/AndroidManifest.xml | 14 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/MainActivity.java | 2 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/hce/HceApduService.java | 201 | ||||
| -rw-r--r-- | android/app/src/main/java/io/coinos/hce/HcePlugin.java | 29 | ||||
| -rw-r--r-- | android/app/src/main/res/values/strings.xml | 2 | ||||
| -rw-r--r-- | android/app/src/main/res/xml/aid_list.xml | 10 | ||||
| -rw-r--r-- | android/build.gradle | 2 | ||||
| -rw-r--r-- | android/gradle/wrapper/gradle-wrapper.properties | 2 |
9 files changed, 265 insertions, 2 deletions
diff --git a/android/app/build.gradle b/android/app/build.gradle index 0a658d76..ae299f49 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -54,6 +54,11 @@ repositories { } } +configurations.all { + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk7' + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8' +} + dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 72d7ccc0..0d4bbc41 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -82,6 +82,18 @@ </intent-filter> </activity> + <service + android:name=".hce.HceApduService" + android:exported="true" + android:permission="android.permission.BIND_NFC_SERVICE"> + <intent-filter> + <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" /> + </intent-filter> + <meta-data + android:name="android.nfc.cardemulation.host_apdu_service" + android:resource="@xml/aid_list" /> + </service> + <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" @@ -94,5 +106,7 @@ <!-- Permissions --> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.CAMERA" /> + <uses-permission android:name="android.permission.NFC" /> <uses-feature android:name="android.hardware.camera" android:required="true"/> + <uses-feature android:name="android.hardware.nfc.hce" android:required="false" /> </manifest> diff --git a/android/app/src/main/java/io/coinos/MainActivity.java b/android/app/src/main/java/io/coinos/MainActivity.java index 271ee363..3a5654a3 100644 --- a/android/app/src/main/java/io/coinos/MainActivity.java +++ b/android/app/src/main/java/io/coinos/MainActivity.java @@ -24,6 +24,7 @@ import java.nio.charset.Charset; import java.util.Arrays; import io.coinos.nfcwriter.NfcWriterPlugin; +import io.coinos.hce.HcePlugin; public class MainActivity extends BridgeActivity { private NfcAdapter nfcAdapter; @@ -33,6 +34,7 @@ public class MainActivity extends BridgeActivity { // ✅ Let Capacitor inflate its layout and wire everything super.onCreate(savedInstanceState); registerPlugin(NfcWriterPlugin.class); + registerPlugin(HcePlugin.class); WebView webView = bridge.getWebView(); // This works *after* super.onCreate() diff --git a/android/app/src/main/java/io/coinos/hce/HceApduService.java b/android/app/src/main/java/io/coinos/hce/HceApduService.java new file mode 100644 index 00000000..a46e3855 --- /dev/null +++ b/android/app/src/main/java/io/coinos/hce/HceApduService.java @@ -0,0 +1,201 @@ +package io.coinos.hce; + +import android.nfc.cardemulation.HostApduService; +import android.os.Bundle; +import android.util.Log; + +import java.util.Arrays; + +/** + * Emulates an NFC Forum Type 4 Tag containing an NDEF URL record. + * When a powered NFC reader (or another phone) taps this device, + * it reads the NDEF message and can open the URL. + */ +public class HceApduService extends HostApduService { + private static final String TAG = "HceApduService"; + + // NDEF Tag Application AID + private static final byte[] NDEF_AID = hexToBytes("D2760000850101"); + + // File IDs + private static final byte[] CC_FILE_ID = new byte[]{(byte) 0xE1, 0x03}; + private static final byte[] NDEF_FILE_ID = new byte[]{(byte) 0xE1, 0x04}; + + // APDU command classes + private static final byte SELECT_INS = (byte) 0xA4; + private static final byte READ_BINARY_INS = (byte) 0xB0; + + // Status words + private static final byte[] SW_OK = new byte[]{(byte) 0x90, 0x00}; + private static final byte[] SW_NOT_FOUND = new byte[]{(byte) 0x6A, (byte) 0x82}; + private static final byte[] SW_FUNC_NOT_SUPPORTED = new byte[]{(byte) 0x6A, (byte) 0x81}; + + // Capability Container (15 bytes, read-only tag with 2048-byte NDEF file) + private static final byte[] CC_FILE = new byte[]{ + 0x00, 0x0F, // CC length + 0x20, // Mapping version 2.0 + 0x00, (byte) 0xF6, // Max R-APDU size (246) + 0x00, 0x00, // Max C-APDU size (0 = no write) + 0x04, 0x06, // NDEF File Control TLV (type=4, length=6) + (byte) 0xE1, 0x04, // NDEF file ID + 0x08, 0x00, // Max NDEF file size (2048) + 0x00, // Read access: open + (byte) 0xFF // Write access: denied + }; + + private static String currentUrl = "https://coinos.io"; + + private enum SelectedFile { NONE, CC, NDEF } + private SelectedFile selectedFile = SelectedFile.NONE; + + public static void setUrl(String url) { + currentUrl = url; + Log.d(TAG, "HCE URL set to: " + url); + } + + public static String getUrl() { + return currentUrl; + } + + @Override + public byte[] processCommandApdu(byte[] apdu, Bundle extras) { + if (apdu == null || apdu.length < 4) { + return SW_FUNC_NOT_SUPPORTED; + } + + byte ins = apdu[1]; + + if (ins == SELECT_INS) { + return handleSelect(apdu); + } else if (ins == READ_BINARY_INS) { + return handleReadBinary(apdu); + } + + return SW_FUNC_NOT_SUPPORTED; + } + + private byte[] handleSelect(byte[] apdu) { + byte p1 = apdu[2]; + + // SELECT by AID (P1=0x04) + if (p1 == 0x04) { + if (apdu.length >= 12) { + byte[] aid = Arrays.copyOfRange(apdu, 5, 5 + (apdu[4] & 0xFF)); + if (Arrays.equals(aid, NDEF_AID)) { + selectedFile = SelectedFile.NONE; + Log.d(TAG, "NDEF application selected"); + return SW_OK; + } + } + return SW_NOT_FOUND; + } + + // SELECT by File ID (P1=0x00) + if (p1 == 0x00 && apdu.length >= 7) { + byte[] fileId = Arrays.copyOfRange(apdu, 5, 7); + if (Arrays.equals(fileId, CC_FILE_ID)) { + selectedFile = SelectedFile.CC; + Log.d(TAG, "CC file selected"); + return SW_OK; + } else if (Arrays.equals(fileId, NDEF_FILE_ID)) { + selectedFile = SelectedFile.NDEF; + Log.d(TAG, "NDEF file selected"); + return SW_OK; + } + return SW_NOT_FOUND; + } + + return SW_FUNC_NOT_SUPPORTED; + } + + private byte[] handleReadBinary(byte[] apdu) { + int offset = ((apdu[2] & 0xFF) << 8) | (apdu[3] & 0xFF); + int length = apdu[4] & 0xFF; + + byte[] fileData; + switch (selectedFile) { + case CC: + fileData = CC_FILE; + break; + case NDEF: + fileData = buildNdefFile(); + break; + default: + return SW_NOT_FOUND; + } + + if (offset >= fileData.length) { + return SW_NOT_FOUND; + } + + int available = fileData.length - offset; + int readLen = Math.min(length, available); + byte[] response = new byte[readLen + 2]; + System.arraycopy(fileData, offset, response, 0, readLen); + response[readLen] = (byte) 0x90; + response[readLen + 1] = 0x00; + return response; + } + + private byte[] buildNdefFile() { + byte[] ndefMessage = buildNdefUrlRecord(currentUrl); + // NDEF file = 2-byte length prefix + NDEF message + byte[] file = new byte[2 + ndefMessage.length]; + file[0] = (byte) ((ndefMessage.length >> 8) & 0xFF); + file[1] = (byte) (ndefMessage.length & 0xFF); + System.arraycopy(ndefMessage, 0, file, 2, ndefMessage.length); + return file; + } + + private byte[] buildNdefUrlRecord(String url) { + // Determine URI prefix code + byte prefixCode = 0x00; + String payload = url; + + String[][] prefixes = { + {"https://www.", "\u0002"}, + {"http://www.", "\u0001"}, + {"https://", "\u0004"}, + {"http://", "\u0003"}, + }; + + for (String[] p : prefixes) { + if (url.startsWith(p[0])) { + prefixCode = (byte) p[1].charAt(0); + payload = url.substring(p[0].length()); + break; + } + } + + byte[] payloadBytes = payload.getBytes(); + int payloadLen = 1 + payloadBytes.length; // prefix code + URI body + + // Short record format NDEF message + // Flags: MB=1, ME=1, SR=1, TNF=0x01 (Well-Known) = 0xD1 + byte[] record = new byte[4 + payloadLen]; + record[0] = (byte) 0xD1; // MB|ME|SR|TNF=Well-Known + record[1] = 0x01; // Type length (1 byte for "U") + record[2] = (byte) payloadLen; // Payload length + record[3] = 0x55; // Type: "U" (URI) + record[4] = prefixCode; // URI prefix code + System.arraycopy(payloadBytes, 0, record, 5, payloadBytes.length); + + return record; + } + + @Override + public void onDeactivated(int reason) { + Log.d(TAG, "HCE deactivated: " + reason); + selectedFile = SelectedFile.NONE; + } + + private static byte[] hexToBytes(String hex) { + int len = hex.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i + 1), 16)); + } + return data; + } +} diff --git a/android/app/src/main/java/io/coinos/hce/HcePlugin.java b/android/app/src/main/java/io/coinos/hce/HcePlugin.java new file mode 100644 index 00000000..fb562c8d --- /dev/null +++ b/android/app/src/main/java/io/coinos/hce/HcePlugin.java @@ -0,0 +1,29 @@ +package io.coinos.hce; + +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.JSObject; +import com.getcapacitor.annotation.CapacitorPlugin; + +@CapacitorPlugin(name = "Hce") +public class HcePlugin extends Plugin { + + @PluginMethod + public void setUrl(PluginCall call) { + String url = call.getString("url"); + if (url == null || url.isEmpty()) { + call.reject("URL is required"); + return; + } + HceApduService.setUrl(url); + call.resolve(); + } + + @PluginMethod + public void getUrl(PluginCall call) { + JSObject result = new JSObject(); + result.put("url", HceApduService.getUrl()); + call.resolve(result); + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2eefd89b..3f8524a6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -4,4 +4,6 @@ <string name="title_activity_main">coinos</string> <string name="package_name">io.coinos</string> <string name="custom_url_scheme">io.coinos</string> + <string name="hce_service_description">Coinos NFC Payment</string> + <string name="hce_aid_group_description">Coinos NDEF Tag Emulation</string> </resources> diff --git a/android/app/src/main/res/xml/aid_list.xml b/android/app/src/main/res/xml/aid_list.xml new file mode 100644 index 00000000..2f2a6b39 --- /dev/null +++ b/android/app/src/main/res/xml/aid_list.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android" + android:description="@string/hce_service_description" + android:requireDeviceUnlock="false"> + <aid-group + android:category="other" + android:description="@string/hce_aid_group_description"> + <aid-filter android:name="D2760000850101" /> + </aid-group> +</host-apdu-service> diff --git a/android/build.gradle b/android/build.gradle index 85a5dda2..9a45d8fd 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'com.android.tools.build:gradle:8.7.3' classpath 'com.google.gms:google-services:4.4.0' // NOTE: Do not place your application dependencies here; they belong diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index c747538f..c1d5e018 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME |
