458 lines
14 KiB
Dart
458 lines
14 KiB
Dart
// lib/services/nfc_service.dart
|
||
//
|
||
// NFC 封装服务(包含读取和写入方法,均提供回调与 Future 两种使用方式)
|
||
// 请将此文件放到你的项目中(例如 lib/services/nfc_service.dart)并直接使用。
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:nfc_manager/nfc_manager.dart';
|
||
import 'package:nfc_manager/ndef_record.dart';
|
||
import 'package:nfc_manager/nfc_manager_android.dart';
|
||
import 'package:nfc_manager/nfc_manager_ios.dart';
|
||
import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
|
||
|
||
/// NfcService 单例:提供 NFC 的读取与写入基础能力
|
||
///
|
||
/// - startScanOnceWithCallback(...):回调式的一次性读取(发现标签即回调)
|
||
/// - readOnceText(...):Future 式读取,返回解析出的文本(若失败会抛异常)
|
||
/// - writeText(...):写入文本记录到标签(回调/返回写入结果)
|
||
/// - stopSession():停止当前会话
|
||
///
|
||
/// 注:iOS 必须在真机测试,并在 Xcode 中打开 NFC 权限;Android 需设备支持 NFC。
|
||
class NfcService {
|
||
NfcService._internal();
|
||
|
||
static final NfcService instance = NfcService._internal();
|
||
|
||
/// 是否正在进行 NFC 会话(扫描或写入)
|
||
final ValueNotifier<bool> scanning = ValueNotifier(false);
|
||
|
||
/// 日志广播流(方便 UI 订阅)
|
||
final StreamController<String> _logController = StreamController.broadcast();
|
||
|
||
Stream<String> get logs => _logController.stream;
|
||
|
||
/// 检查设备是否支持 NFC
|
||
Future<bool> isAvailable() => NfcManager.instance.isAvailable();
|
||
|
||
/// 停止会话(若正在运行)
|
||
Future<void> stopSession() async {
|
||
if (scanning.value) {
|
||
try {
|
||
await NfcManager.instance.stopSession();
|
||
} catch (_) {}
|
||
scanning.value = false;
|
||
}
|
||
}
|
||
|
||
// ---------------- 辅助方法 ----------------
|
||
|
||
/// bytes -> "AA:BB:CC" 形式的大写十六进制字符串
|
||
String _bytesToHex(Uint8List bytes) {
|
||
return bytes
|
||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||
.join(':')
|
||
.toUpperCase();
|
||
}
|
||
|
||
/// 递归在 Map/List 中查找第一个可用的字节数组 (List<int> / Uint8List)
|
||
List<int>? _findFirstByteArray(dynamic value) {
|
||
if (value == null) return null;
|
||
if (value is Uint8List) return value.toList();
|
||
if (value is List<int>) return value;
|
||
if (value is List) {
|
||
for (var item in value) {
|
||
final found = _findFirstByteArray(item);
|
||
if (found != null) return found;
|
||
}
|
||
return null;
|
||
}
|
||
if (value is Map) {
|
||
for (final entry in value.entries) {
|
||
final found = _findFirstByteArray(entry.value);
|
||
if (found != null) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// 从 tag(NfcTag / Map)中提取 UID,兼容多种实现
|
||
String extractUidFromTag(dynamic tag) {
|
||
try {
|
||
dynamic raw = tag;
|
||
// 有些实现把数据放 data 字段
|
||
if (tag is Map && tag.containsKey('data')) raw = tag['data'];
|
||
|
||
final bytes = _findFirstByteArray(raw);
|
||
if (bytes != null && bytes.isNotEmpty) {
|
||
return _bytesToHex(Uint8List.fromList(bytes));
|
||
}
|
||
|
||
// 再尝试一些常见的键名
|
||
if (tag is Map) {
|
||
final possible = ['id', 'identifier', 'Id', 'ID'];
|
||
for (final k in possible) {
|
||
if (tag[k] != null) {
|
||
final b = _findFirstByteArray(tag[k]);
|
||
if (b != null) return _bytesToHex(Uint8List.fromList(b));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 最后一招:平台特定 API (如果存在)
|
||
if (Platform.isAndroid) {
|
||
try {
|
||
final nfcA = NfcAAndroid.from(tag);
|
||
if (nfcA != null && nfcA.tag.id != null)
|
||
return _bytesToHex(Uint8List.fromList(nfcA.tag.id));
|
||
} catch (_) {}
|
||
} else if (Platform.isIOS) {
|
||
try {
|
||
final mifare = MiFareIos.from(tag);
|
||
if (mifare != null && mifare.identifier != null)
|
||
return _bytesToHex(Uint8List.fromList(mifare.identifier));
|
||
} catch (_) {}
|
||
}
|
||
} catch (e) {
|
||
debugPrint('extractUidFromTag error: $e');
|
||
}
|
||
return 'UNKNOWN';
|
||
}
|
||
|
||
/// 从 NDEF record 的 payload 解析文本(Text Record)并返回字符串
|
||
String parseTextFromPayload(Uint8List payload) {
|
||
if (payload.isEmpty) return '';
|
||
final status = payload[0];
|
||
final langLen = status & 0x3F;
|
||
final textBytes = payload.sublist(1 + langLen);
|
||
try {
|
||
return utf8.decode(textBytes);
|
||
} catch (e) {
|
||
return String.fromCharCodes(textBytes);
|
||
}
|
||
}
|
||
|
||
String _formatMessageToString(dynamic msg) {
|
||
if (msg == null) return '<empty>';
|
||
try {
|
||
final records =
|
||
(msg is Map && msg['records'] != null)
|
||
? List<dynamic>.from(msg['records'])
|
||
: (msg is dynamic ? (msg.records as List<dynamic>?) : null);
|
||
if (records == null || records.isEmpty) return '<empty>';
|
||
final sb = StringBuffer();
|
||
for (var i = 0; i < records.length; i++) {
|
||
final r = records[i];
|
||
sb.writeln('Record $i: ${r.toString()}');
|
||
try {
|
||
// 解析 payload
|
||
Uint8List? p;
|
||
if (r is Map && r['payload'] != null) {
|
||
final ptmp = r['payload'];
|
||
if (ptmp is Uint8List)
|
||
p = ptmp;
|
||
else if (ptmp is List<int>)
|
||
p = Uint8List.fromList(ptmp);
|
||
} else {
|
||
final pr = (r as dynamic).payload;
|
||
if (pr is Uint8List)
|
||
p = pr;
|
||
else if (pr is List<int>)
|
||
p = Uint8List.fromList(pr);
|
||
}
|
||
if (p != null) {
|
||
final txt = parseTextFromPayload(p);
|
||
sb.writeln(' text: $txt');
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
return sb.toString();
|
||
} catch (e) {
|
||
return msg.toString();
|
||
}
|
||
}
|
||
|
||
// ---------------- 读取 API ----------------
|
||
|
||
/// 回调式的一次性扫描(发现标签后立即回调)
|
||
///
|
||
/// onResult(uid, parsedText, rawMessage) - 当发现标签时回调
|
||
/// onError(error) - 出错时回调
|
||
/// timeout - 超时时间(可选)
|
||
Future<void> startScanOnceWithCallback({
|
||
required void Function(String uid, String parsedText, dynamic rawMessage)
|
||
onResult,
|
||
void Function(Object error)? onError,
|
||
Duration? timeout,
|
||
}) async {
|
||
final available = await isAvailable();
|
||
if (!available) {
|
||
onError?.call('NFC not available');
|
||
return;
|
||
}
|
||
if (scanning.value) {
|
||
onError?.call('Another session running');
|
||
return;
|
||
}
|
||
|
||
scanning.value = true;
|
||
_logController.add('NFC scanning started');
|
||
|
||
Timer? timer;
|
||
if (timeout != null) {
|
||
timer = Timer(timeout, () async {
|
||
await stopSession();
|
||
_logController.add('NFC scanning timeout');
|
||
onError?.call('timeout');
|
||
});
|
||
}
|
||
|
||
try {
|
||
await NfcManager.instance.startSession(
|
||
onDiscovered: (dynamic tag) async {
|
||
try {
|
||
final uid = extractUidFromTag(tag);
|
||
dynamic rawMsg;
|
||
String parsedText = '';
|
||
|
||
// 尝试用 Ndef.from 获取消息
|
||
try {
|
||
final ndef = Ndef.from(tag);
|
||
if (ndef != null) {
|
||
rawMsg = ndef.cachedMessage;
|
||
if (rawMsg != null) {
|
||
// 解析第一条文本记录(若存在)
|
||
if ((rawMsg.records as List).isNotEmpty) {
|
||
final first = rawMsg.records.first;
|
||
final payload = first.payload;
|
||
if (payload is Uint8List)
|
||
parsedText = parseTextFromPayload(payload);
|
||
else if (payload is List<int>)
|
||
parsedText = parseTextFromPayload(
|
||
Uint8List.fromList(payload),
|
||
);
|
||
}
|
||
}
|
||
} else {
|
||
rawMsg = null;
|
||
}
|
||
} catch (_) {
|
||
// 回退:尝试从 tag map 中嗅探 cachedMessage / records
|
||
try {
|
||
if (tag is Map) {
|
||
rawMsg =
|
||
tag['cachedMessage'] ??
|
||
tag['ndef']?['cachedMessage'] ??
|
||
tag['message'];
|
||
if (rawMsg != null) {
|
||
final recs =
|
||
(rawMsg['records'] ?? (rawMsg as dynamic).records)
|
||
as dynamic;
|
||
if (recs != null && recs is List && recs.isNotEmpty) {
|
||
final r = recs.first;
|
||
final p =
|
||
(r is Map) ? r['payload'] : (r as dynamic).payload;
|
||
if (p is Uint8List)
|
||
parsedText = parseTextFromPayload(p);
|
||
else if (p is List<int>)
|
||
parsedText = parseTextFromPayload(
|
||
Uint8List.fromList(p),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
// 回调结果
|
||
onResult(uid, parsedText, rawMsg);
|
||
_logController.add(
|
||
'UID: $uid\nmessage: ${_formatMessageToString(rawMsg)}',
|
||
);
|
||
|
||
await stopSession();
|
||
} catch (e) {
|
||
onError?.call(e);
|
||
await stopSession();
|
||
} finally {
|
||
timer?.cancel();
|
||
scanning.value = false;
|
||
}
|
||
},
|
||
// 尽量多协议尝试以提升兼容性
|
||
pollingOptions: {NfcPollingOption.iso14443},
|
||
);
|
||
} catch (e) {
|
||
scanning.value = false;
|
||
timer?.cancel();
|
||
onError?.call(e);
|
||
}
|
||
}
|
||
|
||
/// Future 式读取:解析第一条文本记录并返回字符串(格式:'UID\n文本')
|
||
Future<String> readOnceText({
|
||
Duration timeout = const Duration(seconds: 10),
|
||
}) async {
|
||
final completer = Completer<String>();
|
||
await startScanOnceWithCallback(
|
||
onResult: (uid, parsedText, rawMsg) {
|
||
if (parsedText.isEmpty) {
|
||
completer.completeError('No text record found (UID: $uid)');
|
||
} else {
|
||
completer.complete('UID: $uid\n$parsedText');
|
||
}
|
||
},
|
||
onError: (err) {
|
||
if (!completer.isCompleted) completer.completeError(err);
|
||
},
|
||
timeout: timeout,
|
||
);
|
||
return completer.future;
|
||
}
|
||
|
||
// ---------------- 写入 API ----------------
|
||
|
||
/// 构造 NDEF Text payload(status byte + lang + utf8 bytes)
|
||
Uint8List _buildTextPayload(String text, {String lang = 'en'}) {
|
||
final textBytes = utf8.encode(text);
|
||
final langBytes = utf8.encode(lang);
|
||
final status = langBytes.length & 0x3F;
|
||
final payload = <int>[status, ...langBytes, ...textBytes];
|
||
return Uint8List.fromList(payload);
|
||
}
|
||
|
||
/// 写入文本到标签(单条 Text record)
|
||
///
|
||
/// - text: 要写入的文本
|
||
/// - timeout: 超时时间(可选)
|
||
/// - onComplete: 回调 (ok, err)
|
||
///
|
||
/// 返回 true 表示写入成功(同时 onComplete 也会被触发)
|
||
Future<bool> writeText(
|
||
String text, {
|
||
Duration? timeout,
|
||
void Function(bool ok, Object? err)? onComplete,
|
||
}) async {
|
||
debugPrint('writeText called - scanning=${scanning.value}');
|
||
final available = await isAvailable();
|
||
debugPrint('writeText: isAvailable=$available');
|
||
if (!available) {
|
||
onComplete?.call(false, 'NFC not available');
|
||
return false;
|
||
}
|
||
if (scanning.value) {
|
||
onComplete?.call(false, 'Another session running');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await NfcManager.instance.stopSession();
|
||
} catch (e) {
|
||
debugPrint('stopSession ignore: $e');
|
||
}
|
||
|
||
scanning.value = true;
|
||
Timer? timer;
|
||
if (timeout != null) {
|
||
timer = Timer(timeout, () async {
|
||
await stopSession();
|
||
scanning.value = false;
|
||
onComplete?.call(false, 'timeout');
|
||
});
|
||
}
|
||
|
||
bool success = false;
|
||
try {
|
||
// 修改了 pollingOptions 配置
|
||
final polling =
|
||
Platform.isIOS
|
||
? {NfcPollingOption.iso14443} // iOS 使用更具体的配置
|
||
: {
|
||
NfcPollingOption.iso14443,
|
||
NfcPollingOption.iso15693,
|
||
NfcPollingOption.iso18092,
|
||
};
|
||
|
||
await NfcManager.instance.startSession(
|
||
pollingOptions: polling,
|
||
onDiscovered: (dynamic tag) async {
|
||
timer?.cancel();
|
||
try {
|
||
final ndef = Ndef.from(tag);
|
||
if (ndef == null) {
|
||
onComplete?.call(false, 'Tag not NDEF');
|
||
await stopSession();
|
||
scanning.value = false;
|
||
return;
|
||
}
|
||
|
||
// 检查是否可写
|
||
if (Platform.isIOS) {
|
||
final miFare = MiFareIos.from(tag);
|
||
if (miFare == null) {
|
||
onComplete?.call(false, 'Unsupported tag type on iOS');
|
||
await stopSession();
|
||
scanning.value = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
final payload = _buildTextPayload(text, lang: 'en');
|
||
final record = NdefRecord(
|
||
typeNameFormat: TypeNameFormat.wellKnown,
|
||
type: Uint8List.fromList('T'.codeUnits),
|
||
identifier: Uint8List(0),
|
||
payload: payload,
|
||
);
|
||
final message = NdefMessage(records: [record]);
|
||
|
||
await ndef.write(message: message);
|
||
// 取出 UID (优先取 nfca.identifier,如果没有就取顶层 id)
|
||
String? uid;
|
||
if (Platform.isAndroid) {
|
||
try {
|
||
final nfcA = NfcAAndroid.from(tag);
|
||
if (nfcA != null && nfcA.tag.id != null) {
|
||
uid = _bytesToHex(Uint8List.fromList(nfcA.tag.id));
|
||
}
|
||
} catch (_) {}
|
||
} else if (Platform.isIOS) {
|
||
try {
|
||
final mifare = MiFareIos.from(tag);
|
||
if (mifare != null && mifare.identifier != null) {
|
||
uid = _bytesToHex(Uint8List.fromList(mifare.identifier));
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
success = true;
|
||
onComplete?.call(true, uid); // ✅ 把 UID 返回出去
|
||
} catch (e, st) {
|
||
debugPrint('iOS NFC Write Error: $e');
|
||
debugPrint('Stack trace: $st');
|
||
onComplete?.call(false, e);
|
||
} finally {
|
||
await stopSession();
|
||
scanning.value = false;
|
||
}
|
||
},
|
||
);
|
||
} catch (e, st) {
|
||
debugPrint('startSession exception: $e\n$st');
|
||
timer?.cancel();
|
||
scanning.value = false;
|
||
onComplete?.call(false, e);
|
||
}
|
||
return success;
|
||
}
|
||
|
||
/// 释放资源
|
||
void dispose() {
|
||
try {
|
||
_logController.close();
|
||
} catch (_) {}
|
||
}
|
||
}
|