feat: phone-to-phone NFC via HCE with proprietary AID
This commit is contained in:
@@ -21,7 +21,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "me.elcaju"
|
||||
minSdk 21 // Required for FlutterSecureStorage (EncryptedSharedPreferences)
|
||||
minSdkVersion flutter.minSdkVersion // Required for FlutterSecureStorage (EncryptedSharedPreferences)
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutter.versionCode
|
||||
versionName flutter.versionName
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- HCE service: phone emulates NFC tag for phone-to-phone transfers -->
|
||||
<service
|
||||
android:name=".NfcHceService"
|
||||
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/hce_nfc" />
|
||||
</service>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
package me.elcaju
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.cardemulation.CardEmulation
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val CHANNEL = "me.elcaju/nfc_hce"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"setPayload" -> {
|
||||
val payload = call.argument<ByteArray>("payload")
|
||||
NfcHceService.ndefPayload = payload
|
||||
|
||||
// Force Android to route the NDEF AID to our service
|
||||
// instead of manufacturer services (Xiaomi Mi Share, etc.)
|
||||
try {
|
||||
val adapter = NfcAdapter.getDefaultAdapter(this)
|
||||
if (adapter != null) {
|
||||
val cardEmulation = CardEmulation.getInstance(adapter)
|
||||
cardEmulation.setPreferredService(
|
||||
this,
|
||||
ComponentName(this, NfcHceService::class.java)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
result.success(true)
|
||||
}
|
||||
"clearPayload" -> {
|
||||
NfcHceService.ndefPayload = null
|
||||
|
||||
// Release preferred service routing
|
||||
try {
|
||||
val adapter = NfcAdapter.getDefaultAdapter(this)
|
||||
if (adapter != null) {
|
||||
val cardEmulation = CardEmulation.getInstance(adapter)
|
||||
cardEmulation.unsetPreferredService(this)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
result.success(true)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package me.elcaju
|
||||
|
||||
import android.nfc.cardemulation.HostApduService
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Host Card Emulation service that makes the phone act as an NFC Forum Type 4 tag.
|
||||
* Another phone in reader mode can tap and read the NDEF message we serve.
|
||||
*
|
||||
* The payload is set from Flutter via MainActivity's MethodChannel.
|
||||
*/
|
||||
class NfcHceService : HostApduService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NfcHceService"
|
||||
|
||||
// Shared payload - set by Flutter via MethodChannel
|
||||
@Volatile
|
||||
var ndefPayload: ByteArray? = null
|
||||
|
||||
private val OK = byteArrayOf(0x90.toByte(), 0x00.toByte())
|
||||
private val NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte())
|
||||
|
||||
// Capability Container (CC) file - fixed structure
|
||||
// MLe=0xFF (255), MLc=0xFF (255), max NDEF=0x70FF (28671)
|
||||
// Compatible with Numo
|
||||
private val CC_FILE = byteArrayOf(
|
||||
0x00, 0x0F, // CC length (15)
|
||||
0x20, // Mapping version 2.0
|
||||
0x00, 0xFF.toByte(), // MLe: max read 255 bytes
|
||||
0x00, 0xFF.toByte(), // MLc: max write 255 bytes
|
||||
0x04, 0x06, // NDEF File Control TLV
|
||||
0xE1.toByte(), 0x04, // NDEF file ID
|
||||
0x70, 0xFF.toByte(), // Max NDEF size: 28671 bytes
|
||||
0x00, // Read access: open
|
||||
0xFF.toByte() // Write access: denied
|
||||
)
|
||||
}
|
||||
|
||||
private var selectedFile: String = "none"
|
||||
private var ndefFileCache: ByteArray? = null
|
||||
|
||||
override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray {
|
||||
if (commandApdu.size < 4) return NOT_FOUND
|
||||
|
||||
val ins = commandApdu[1]
|
||||
|
||||
// SELECT command
|
||||
if (ins == 0xA4.toByte()) {
|
||||
return handleSelect(commandApdu)
|
||||
}
|
||||
|
||||
// READ BINARY command
|
||||
if (ins == 0xB0.toByte()) {
|
||||
return handleRead(commandApdu)
|
||||
}
|
||||
|
||||
return NOT_FOUND
|
||||
}
|
||||
|
||||
private fun handleSelect(apdu: ByteArray): ByteArray {
|
||||
// Select ElCaju proprietary AID (F04543414A5500)
|
||||
// Avoids conflicts with Xiaomi Mi Share / Samsung Beam
|
||||
if (apdu.size >= 12 && apdu[5] == 0xF0.toByte() && apdu[6] == 0x45.toByte()) {
|
||||
selectedFile = "app"
|
||||
Log.d(TAG, "Selected ElCaju Application (proprietary AID)")
|
||||
return OK
|
||||
}
|
||||
|
||||
// Select NDEF Application (AID: D2760000850101)
|
||||
if (apdu.size >= 12 && apdu[5] == 0xD2.toByte() && apdu[6] == 0x76.toByte()) {
|
||||
selectedFile = "app"
|
||||
Log.d(TAG, "Selected NDEF Application")
|
||||
return OK
|
||||
}
|
||||
|
||||
// Select by file ID
|
||||
if (apdu.size >= 7) {
|
||||
val fileId = ((apdu[5].toInt() and 0xFF) shl 8) or (apdu[6].toInt() and 0xFF)
|
||||
when (fileId) {
|
||||
0xE103 -> {
|
||||
selectedFile = "cc"
|
||||
Log.d(TAG, "Selected CC file")
|
||||
return OK
|
||||
}
|
||||
0xE104 -> {
|
||||
selectedFile = "ndef"
|
||||
// Cache the NDEF file when selected
|
||||
val payload = ndefPayload
|
||||
if (payload != null) {
|
||||
val file = ByteArray(2 + payload.size)
|
||||
file[0] = (payload.size shr 8).toByte()
|
||||
file[1] = (payload.size and 0xFF).toByte()
|
||||
System.arraycopy(payload, 0, file, 2, payload.size)
|
||||
ndefFileCache = file
|
||||
Log.d(TAG, "Selected NDEF file (${payload.size} bytes payload)")
|
||||
} else {
|
||||
ndefFileCache = null
|
||||
Log.w(TAG, "Selected NDEF file but no payload set")
|
||||
}
|
||||
return OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NOT_FOUND
|
||||
}
|
||||
|
||||
private fun handleRead(apdu: ByteArray): ByteArray {
|
||||
if (apdu.size < 5) return NOT_FOUND
|
||||
|
||||
val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF)
|
||||
// Le=0x00 means 256 bytes in short APDU encoding
|
||||
var length = apdu[4].toInt() and 0xFF
|
||||
if (length == 0) length = 256
|
||||
|
||||
val data = when (selectedFile) {
|
||||
"cc" -> CC_FILE
|
||||
"ndef" -> ndefFileCache ?: return NOT_FOUND
|
||||
else -> return NOT_FOUND
|
||||
}
|
||||
|
||||
if (offset >= data.size) {
|
||||
Log.w(TAG, "Read offset $offset beyond data size ${data.size}")
|
||||
return NOT_FOUND
|
||||
}
|
||||
|
||||
val end = minOf(offset + length, data.size)
|
||||
val response = data.copyOfRange(offset, end)
|
||||
Log.d(TAG, "Read $selectedFile: offset=$offset len=$length returned=${response.size} bytes")
|
||||
return response + OK
|
||||
}
|
||||
|
||||
override fun onDeactivated(reason: Int) {
|
||||
Log.d(TAG, "Deactivated (reason=$reason)")
|
||||
selectedFile = "none"
|
||||
ndefFileCache = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="hce_description">ElCaju NFC</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/hce_description"
|
||||
android:requireDeviceUnlock="false">
|
||||
<aid-group
|
||||
android:category="other"
|
||||
android:description="@string/hce_description">
|
||||
<!-- ElCaju proprietary AID (avoids Xiaomi/Samsung NFC service conflicts) -->
|
||||
<aid-filter android:name="F04543414A5500" />
|
||||
<!-- NDEF Tag Application AID (NFC Forum Type 4) -->
|
||||
<aid-filter android:name="D2760000850101" />
|
||||
</aid-group>
|
||||
</host-apdu-service>
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:nfc_manager/nfc_manager.dart';
|
||||
import 'package:nfc_manager/nfc_manager_android.dart';
|
||||
import 'package:ndef_record/ndef_record.dart';
|
||||
@@ -20,6 +21,64 @@ enum NfcState {
|
||||
/// Writes tokens as NDEF Text Records for maximum compatibility
|
||||
/// with cashu.me and Numo. Reads both Text and URI records.
|
||||
class NfcService {
|
||||
static const _hceChannel = MethodChannel('me.elcaju/nfc_hce');
|
||||
|
||||
// ─── HCE (phone-to-phone) ───
|
||||
|
||||
/// Start emulating an NFC tag with the given text payload.
|
||||
/// The phone will appear as an NFC tag to other devices.
|
||||
static Future<void> startEmulating(String text) async {
|
||||
final ndefMessage = _buildNdefTextMessage(text);
|
||||
await _hceChannel.invokeMethod('setPayload', {'payload': ndefMessage});
|
||||
}
|
||||
|
||||
/// Stop emulating an NFC tag.
|
||||
static Future<void> stopEmulating() async {
|
||||
await _hceChannel.invokeMethod('clearPayload');
|
||||
}
|
||||
|
||||
/// Build an NDEF message with a Text Record.
|
||||
/// Uses Short Record (SR) for payloads ≤ 255 bytes,
|
||||
/// Long Record for > 255 bytes (compatible with Numo).
|
||||
static Uint8List _buildNdefTextMessage(String text) {
|
||||
final textBytes = Uint8List.fromList(text.codeUnits);
|
||||
final languageCode = Uint8List.fromList('en'.codeUnits);
|
||||
|
||||
// NDEF Text Record payload: [status byte][lang code][text]
|
||||
final recordPayload = Uint8List(1 + languageCode.length + textBytes.length);
|
||||
recordPayload[0] = languageCode.length;
|
||||
recordPayload.setRange(1, 1 + languageCode.length, languageCode);
|
||||
recordPayload.setRange(1 + languageCode.length, recordPayload.length, textBytes);
|
||||
|
||||
final payloadLength = recordPayload.length;
|
||||
final isShortRecord = payloadLength <= 255;
|
||||
|
||||
if (isShortRecord) {
|
||||
// Short Record: flags(1) + typeLen(1) + payloadLen(1) + type(1) + payload
|
||||
final record = Uint8List(4 + payloadLength);
|
||||
record[0] = 0xD1; // MB|ME|SR|TNF=well-known
|
||||
record[1] = 1; // type length
|
||||
record[2] = payloadLength;
|
||||
record[3] = 0x54; // 'T'
|
||||
record.setRange(4, 4 + payloadLength, recordPayload);
|
||||
return record;
|
||||
} else {
|
||||
// Long Record: flags(1) + typeLen(1) + payloadLen(4) + type(1) + payload
|
||||
final record = Uint8List(7 + payloadLength);
|
||||
record[0] = 0xC1; // MB|ME|TNF=well-known (no SR flag)
|
||||
record[1] = 1; // type length
|
||||
record[2] = (payloadLength >> 24) & 0xFF;
|
||||
record[3] = (payloadLength >> 16) & 0xFF;
|
||||
record[4] = (payloadLength >> 8) & 0xFF;
|
||||
record[5] = payloadLength & 0xFF;
|
||||
record[6] = 0x54; // 'T'
|
||||
record.setRange(7, 7 + payloadLength, recordPayload);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tag read/write ───
|
||||
|
||||
/// Check NFC state on the device.
|
||||
static Future<NfcState> checkState() async {
|
||||
try {
|
||||
@@ -92,6 +151,8 @@ class NfcService {
|
||||
}
|
||||
|
||||
/// Start reading NFC tags for Cashu tokens.
|
||||
/// Tries IsoDep APDU first (phone-to-phone HCE, bypasses manufacturer
|
||||
/// NFC services), falls back to NDEF (physical tags).
|
||||
static Future<void> startRead({
|
||||
required void Function(String token) onTokenRead,
|
||||
required void Function(String error) onError,
|
||||
@@ -99,29 +160,44 @@ class NfcService {
|
||||
NfcManager.instance.startSession(
|
||||
pollingOptions: {NfcPollingOption.iso14443},
|
||||
onDiscovered: (NfcTag tag) async {
|
||||
final ndef = NdefAndroid.from(tag);
|
||||
if (ndef == null) {
|
||||
onError('Tag does not contain NDEF data');
|
||||
NfcManager.instance.stopSession();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final message = ndef.cachedNdefMessage ?? await ndef.getNdefMessage();
|
||||
if (message == null) {
|
||||
onError('No NDEF message on tag');
|
||||
NfcManager.instance.stopSession();
|
||||
return;
|
||||
final diagnostics = <String>[];
|
||||
|
||||
// 1. Try IsoDep first (HCE phone-to-phone)
|
||||
final isoDep = IsoDepAndroid.from(tag);
|
||||
if (isoDep != null) {
|
||||
final (token, isoInfo) = await _readViaIsoDep(isoDep);
|
||||
if (token != null) {
|
||||
onTokenRead(token);
|
||||
NfcManager.instance.stopSession();
|
||||
return;
|
||||
}
|
||||
diagnostics.add('IsoDep: $isoInfo');
|
||||
} else {
|
||||
diagnostics.add('IsoDep: not available');
|
||||
}
|
||||
|
||||
final token = _extractToken(message);
|
||||
if (token != null) {
|
||||
onTokenRead(token);
|
||||
NfcManager.instance.stopSession();
|
||||
// 2. Fallback: NDEF (physical tags)
|
||||
final ndef = NdefAndroid.from(tag);
|
||||
if (ndef != null) {
|
||||
final message = ndef.cachedNdefMessage ?? await ndef.getNdefMessage();
|
||||
if (message != null) {
|
||||
final token = _extractToken(message);
|
||||
if (token != null) {
|
||||
onTokenRead(token);
|
||||
NfcManager.instance.stopSession();
|
||||
return;
|
||||
}
|
||||
diagnostics.add('NDEF: ${message.records.length} records, no Cashu token');
|
||||
} else {
|
||||
diagnostics.add('NDEF: no message');
|
||||
}
|
||||
} else {
|
||||
onError('No Cashu token found on tag');
|
||||
NfcManager.instance.stopSession();
|
||||
diagnostics.add('NDEF: not available');
|
||||
}
|
||||
|
||||
onError('No Cashu token found [${diagnostics.join('; ')}]');
|
||||
NfcManager.instance.stopSession();
|
||||
} catch (e) {
|
||||
onError(e.toString());
|
||||
NfcManager.instance.stopSession();
|
||||
@@ -130,6 +206,186 @@ class NfcService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Read NDEF from HCE via IsoDep APDU commands (like Numo).
|
||||
/// Bypasses manufacturer NFC services (Xiaomi, Samsung, etc).
|
||||
/// Returns (token, diagnosticInfo) tuple.
|
||||
static Future<(String?, String)> _readViaIsoDep(IsoDepAndroid isoDep) async {
|
||||
String _hex(Uint8List bytes) =>
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
|
||||
try {
|
||||
// Set longer timeout (5s) to avoid TagLostException
|
||||
await isoDep.setTimeout(5000);
|
||||
|
||||
// Try ElCaju proprietary AID first (avoids Xiaomi/Samsung conflicts)
|
||||
final selectElCaju = await isoDep.transceive(Uint8List.fromList([
|
||||
0x00, 0xA4, 0x04, 0x00, 0x07,
|
||||
0xF0, 0x45, 0x43, 0x41, 0x4A, 0x55, 0x00,
|
||||
0x00,
|
||||
]));
|
||||
final bool elCajuSelected = _isOk(selectElCaju);
|
||||
|
||||
// Fallback: standard NDEF Application AID
|
||||
if (!elCajuSelected) {
|
||||
final selectApp = await isoDep.transceive(Uint8List.fromList([
|
||||
0x00, 0xA4, 0x04, 0x00, 0x07,
|
||||
0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01,
|
||||
0x00,
|
||||
]));
|
||||
if (!_isOk(selectApp)) {
|
||||
return (null, 'SELECT APP failed (both AIDs): elcaju=${_hex(selectElCaju)}, ndef=${_hex(selectApp)}');
|
||||
}
|
||||
}
|
||||
|
||||
// SELECT NDEF file (E104)
|
||||
final selectNdef = await isoDep.transceive(Uint8List.fromList([
|
||||
0x00, 0xA4, 0x00, 0x0C, 0x02,
|
||||
0xE1, 0x04,
|
||||
]));
|
||||
if (!_isOk(selectNdef)) {
|
||||
return (null, 'SELECT NDEF failed: ${_hex(selectNdef)}');
|
||||
}
|
||||
|
||||
// READ NLEN (first 2 bytes)
|
||||
final nlenResponse = await isoDep.transceive(Uint8List.fromList([
|
||||
0x00, 0xB0, 0x00, 0x00, 0x02,
|
||||
]));
|
||||
if (nlenResponse.length < 4 || !_isOk(nlenResponse)) {
|
||||
return (null, 'READ NLEN failed: ${_hex(nlenResponse)}');
|
||||
}
|
||||
|
||||
final ndefLen = (nlenResponse[0] << 8) | nlenResponse[1];
|
||||
if (ndefLen == 0) {
|
||||
return (null, 'NLEN=0 (no payload set on emitter)');
|
||||
}
|
||||
|
||||
// READ NDEF message in chunks (max 255 bytes per read)
|
||||
final ndefBytes = BytesBuilder();
|
||||
var offset = 2; // skip NLEN
|
||||
var remaining = ndefLen;
|
||||
|
||||
while (remaining > 0) {
|
||||
final chunkSize = remaining > 255 ? 255 : remaining;
|
||||
final readCmd = Uint8List.fromList([
|
||||
0x00, 0xB0,
|
||||
(offset >> 8) & 0xFF,
|
||||
offset & 0xFF,
|
||||
chunkSize,
|
||||
]);
|
||||
final chunk = await isoDep.transceive(readCmd);
|
||||
if (chunk.length < 2 || !_isOk(chunk)) {
|
||||
return (null, 'READ chunk at offset=$offset failed: ${_hex(chunk)}');
|
||||
}
|
||||
|
||||
// Response = data + SW1 SW2 (last 2 bytes are status)
|
||||
ndefBytes.add(chunk.sublist(0, chunk.length - 2));
|
||||
final bytesRead = chunk.length - 2;
|
||||
offset += bytesRead;
|
||||
remaining -= bytesRead;
|
||||
}
|
||||
|
||||
// Parse NDEF message from raw bytes
|
||||
final raw = ndefBytes.toBytes();
|
||||
if (raw.isEmpty) {
|
||||
return (null, 'read ${ndefLen}B but empty after parsing');
|
||||
}
|
||||
|
||||
final token = _parseNdefAndExtractToken(Uint8List.fromList(raw));
|
||||
if (token != null) {
|
||||
return (token, 'OK');
|
||||
}
|
||||
return (null, 'NDEF parsed (${raw.length}B) but no Cashu token, hex: ${_hex(Uint8List.fromList(raw.length > 40 ? raw.sublist(0, 40) : raw))}...');
|
||||
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('TagLostException')) {
|
||||
return (null, 'connection lost (hold phones together longer)');
|
||||
}
|
||||
return (null, 'error: $msg');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if APDU response ends with 90 00 (OK).
|
||||
static bool _isOk(Uint8List response) {
|
||||
if (response.length < 2) return false;
|
||||
return response[response.length - 2] == 0x90 &&
|
||||
response[response.length - 1] == 0x00;
|
||||
}
|
||||
|
||||
/// Parse raw NDEF bytes and extract Cashu token.
|
||||
static String? _parseNdefAndExtractToken(Uint8List raw) {
|
||||
if (raw.isEmpty) return null;
|
||||
|
||||
var pos = 0;
|
||||
while (pos < raw.length) {
|
||||
if (pos + 3 > raw.length) break;
|
||||
|
||||
final flags = raw[pos];
|
||||
final tnf = flags & 0x07;
|
||||
final isShortRecord = (flags & 0x10) != 0;
|
||||
final hasIdLength = (flags & 0x08) != 0;
|
||||
|
||||
pos++;
|
||||
final typeLength = raw[pos]; pos++;
|
||||
|
||||
int payloadLength;
|
||||
if (isShortRecord) {
|
||||
payloadLength = raw[pos]; pos++;
|
||||
} else {
|
||||
if (pos + 4 > raw.length) break;
|
||||
payloadLength = (raw[pos] << 24) | (raw[pos+1] << 16) |
|
||||
(raw[pos+2] << 8) | raw[pos+3];
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
int idLength = 0;
|
||||
if (hasIdLength) {
|
||||
idLength = raw[pos]; pos++;
|
||||
}
|
||||
|
||||
// Type
|
||||
if (pos + typeLength > raw.length) break;
|
||||
final type = raw.sublist(pos, pos + typeLength); pos += typeLength;
|
||||
|
||||
// ID (skip)
|
||||
pos += idLength;
|
||||
|
||||
// Payload
|
||||
if (pos + payloadLength > raw.length) break;
|
||||
final payload = raw.sublist(pos, pos + payloadLength);
|
||||
pos += payloadLength;
|
||||
|
||||
// Check: Text Record (TNF=0x01, type='T')
|
||||
if (tnf == 0x01 && typeLength == 1 && type[0] == 0x54) {
|
||||
if (payload.isNotEmpty) {
|
||||
final langLen = payload[0] & 0x3F;
|
||||
if (payload.length > 1 + langLen) {
|
||||
final text = String.fromCharCodes(payload.sublist(1 + langLen));
|
||||
if (_isCashuToken(text)) return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check: URI Record (TNF=0x01, type='U')
|
||||
if (tnf == 0x01 && typeLength == 1 && type[0] == 0x55) {
|
||||
if (payload.isNotEmpty) {
|
||||
const prefixes = ['', 'http://www.', 'https://www.', 'http://', 'https://'];
|
||||
final prefixIdx = payload[0];
|
||||
final prefix = prefixIdx < prefixes.length ? prefixes[prefixIdx] : '';
|
||||
final rest = String.fromCharCodes(payload.sublist(1));
|
||||
final uri = '$prefix$rest';
|
||||
final token = _extractTokenFromUri(uri);
|
||||
if (token != null) return token;
|
||||
}
|
||||
}
|
||||
|
||||
// If ME (Message End) flag set, stop
|
||||
if ((flags & 0x40) != 0) break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stop any active NFC read session.
|
||||
static void stopRead() {
|
||||
NfcManager.instance.stopSession();
|
||||
|
||||
@@ -353,6 +353,14 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
}
|
||||
|
||||
void _toggleNfcRead() async {
|
||||
// Cancel active session first (before state checks, in case NFC was
|
||||
// disabled while a session was running)
|
||||
if (_nfcReading) {
|
||||
NfcService.stopRead();
|
||||
setState(() => _nfcReading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check state in case user toggled NFC in settings
|
||||
final currentState = await NfcService.checkState();
|
||||
if (mounted) setState(() => _nfcState = currentState);
|
||||
@@ -381,12 +389,6 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nfcReading) {
|
||||
NfcService.stopRead();
|
||||
setState(() => _nfcReading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _nfcReading = true);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -398,27 +400,41 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
NfcService.startRead(
|
||||
onTokenRead: (token) {
|
||||
if (!mounted) return;
|
||||
setState(() => _nfcReading = false);
|
||||
_tokenController.text = token;
|
||||
_onTokenChanged(token);
|
||||
},
|
||||
onError: (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _nfcReading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.nfcReadError(error)),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
try {
|
||||
await NfcService.startRead(
|
||||
onTokenRead: (token) {
|
||||
if (!mounted) return;
|
||||
setState(() => _nfcReading = false);
|
||||
_tokenController.text = token;
|
||||
_onTokenChanged(token);
|
||||
},
|
||||
onError: (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _nfcReading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.nfcReadError(error)),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _nfcReading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.nfcReadError(e.toString())),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScanButton() {
|
||||
|
||||
@@ -60,8 +60,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_animationTimer?.cancel();
|
||||
if (_nfcWriting) NfcService.stopWrite();
|
||||
|
||||
if (_nfcWriting) NfcService.stopEmulating();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -612,6 +611,13 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
}
|
||||
|
||||
void _writeNfc(BuildContext context) async {
|
||||
// Toggle off: stop emulating
|
||||
if (_nfcWriting) {
|
||||
await NfcService.stopEmulating();
|
||||
setState(() => _nfcWriting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check state in case user toggled NFC in settings
|
||||
final currentState = await NfcService.checkState();
|
||||
if (mounted) setState(() => _nfcState = currentState);
|
||||
@@ -640,53 +646,30 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nfcWriting) {
|
||||
// Cancel active session
|
||||
NfcService.stopWrite();
|
||||
try {
|
||||
await NfcService.startEmulating(widget.token);
|
||||
setState(() => _nfcWriting = true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.nfcHoldNear),
|
||||
backgroundColor: AppColors.primaryAction,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _nfcWriting = false);
|
||||
return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.nfcWriteError(e.toString())),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() => _nfcWriting = true);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.nfcHoldNear),
|
||||
backgroundColor: AppColors.primaryAction,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
|
||||
NfcService.startWrite(
|
||||
token: widget.token,
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _nfcWriting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.nfcWriteSuccess),
|
||||
backgroundColor: AppColors.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _nfcWriting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(L10n.of(context)!.nfcWriteError(error)),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _goToHome(BuildContext context) {
|
||||
|
||||
Reference in New Issue
Block a user