You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
701 lines
31 KiB
701 lines
31 KiB
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
import '../../core/utils/logger.dart';
|
|
import '../../core/utils/permission_util.dart';
|
|
import '../../data/models/user_Info.dart';
|
|
import '../device_bind_registry.dart';
|
|
import '../../data/services/background_launch_consent.dart';
|
|
|
|
/// EaiRec 录音支架(AI手机支架)的接入层。
|
|
///
|
|
/// 底层协议在 `recorder_holder` 插件里,本类只是把 [EchoMeetDeviceSdk] 的流
|
|
/// 封装成 GetX 可观测状态,不重写任何蓝牙/Opus 逻辑。设备的两条通道:
|
|
/// - 通道 A:控制命令 + 实时 Opus 音频流(录音走这条)
|
|
/// - 通道 B:TF 卡文件(列表/下载/删除),见本类下半部分
|
|
/// 协议规范见 local_plugins/recorder_holder/EaiRec_protocol.md。
|
|
///
|
|
/// 与项目原有的 [BleManager](杰理耳机链路)是两套独立的设备通路,互不影响:
|
|
/// - 杰理耳机 → BleManager
|
|
/// - EaiRec 支架 → 本服务
|
|
/// 我们规定的支架广播名(准入白名单)。
|
|
///
|
|
/// ⚠️ 必须与 `EchoMeetDeviceSdk.defaultNameFilters` 保持一致:那边管「扫描时筛谁」,
|
|
/// 这边管「连接时放不放行」。SDK 是独立 package,import 不过来,只能两处各维护一份。
|
|
const List<String> kHolderAllowedNames = <String>['EaiRec', 'EAIMAR', 'Pad Note'];
|
|
|
|
/// 固件广播名是不是我们规定的名字。
|
|
///
|
|
/// ⚠️ **名字对不上就不连**,这是准入校验不是容错:那样的固件不是正式产品,
|
|
/// 服务端的 MAC 校验同样过不了(device_mac 里没有它),连上也没有意义,
|
|
/// 只会让用户以为设备可用、然后在设备管理页找不到它。
|
|
///
|
|
/// 早先这里做的是「`Echomeet` → `EaiRec` 归一」——方向错了:归一等于**接纳**
|
|
/// 不合规的固件,把问题往后推到服务端再失败一次。2026-09-15 改为在连接入口拒绝。
|
|
bool isAllowedHolderName(String raw) {
|
|
final n = raw.trim().toUpperCase();
|
|
if (n.isEmpty) return false;
|
|
for (final allowed in kHolderAllowedNames) {
|
|
if (n.startsWith(allowed.toUpperCase())) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
class HolderDeviceService extends GetxService {
|
|
static const String _tag = 'HolderDeviceService';
|
|
|
|
static HolderDeviceService get to => Get.find();
|
|
|
|
final EchoMeetDeviceSdk _sdk = EchoMeetDeviceSdk.instance;
|
|
|
|
/// 扫描到的设备
|
|
final RxList<EchoMeetScannedDevice> scanned = <EchoMeetScannedDevice>[].obs;
|
|
|
|
/// 按 MAC 去重累积扫描结果。
|
|
///
|
|
/// 原生侧是「每扫到一个设备就单独上报一次」(notifyScanResult 传单元素列表),
|
|
/// SDK 收到后会把整个结果流替换成这一条。如果直接 assignAll,目标设备刚进列表
|
|
/// 就会被下一个不匹配设备的空结果清掉,界面永远是空的。
|
|
/// echomeet 那边用的是 _byMac 累积(见 ble_scanner.dart),这里保持一致。
|
|
final Map<String, EchoMeetScannedDevice> _byAddress = {};
|
|
|
|
/// 连接状态
|
|
final Rx<EchoMeetConnectionState> connection =
|
|
EchoMeetConnectionState.disconnected.obs;
|
|
|
|
/// 设备录音状态(SDK 上报)
|
|
final Rx<EchoMeetRecordingState> recording =
|
|
const EchoMeetRecordingState().obs;
|
|
|
|
/// 设备信息(电量 / 固件版本)
|
|
final Rxn<EchoMeetDeviceInfo> deviceInfo = Rxn<EchoMeetDeviceInfo>();
|
|
|
|
final RxBool isScanning = false.obs;
|
|
|
|
/// 当前连接设备的 MAC(连接时记下,SDK 未提供反查接口)
|
|
final RxString connectedAddressRx = ''.obs;
|
|
String get connectedAddress => connectedAddressRx.value;
|
|
|
|
/// 当前连接设备的广播名(设备页据此选主图)
|
|
final RxString connectedNameRx = ''.obs;
|
|
String get connectedName => connectedNameRx.value;
|
|
|
|
// ==================== 自动回连 ====================
|
|
|
|
static const String _lastDeviceKey = 'holder_last_device';
|
|
final GetStorage _storage = GetStorage();
|
|
Timer? _autoConnectTimer;
|
|
int _autoAttempt = 0;
|
|
|
|
/// 本地已绑定设备列表的存储键(服务端绑定失败时也保证设备管理页有记录)
|
|
static const String _pairedListKey = 'holder_paired_devices';
|
|
|
|
/// 一次性清理:广播名白名单收紧(移除 `Echomeet`)之前记下的配对记录不可信。
|
|
///
|
|
/// 两个原因都要清:
|
|
/// 1. 那时连的可能就是不合规固件,记录留着会被自动回连直接拉起来,绕过准入;
|
|
/// 2. 短暂存在过的"名字归一"会把存储里的广播名改写成合规名,
|
|
/// 而自动回连拿不到真实广播名、只能信存储 —— 校验因此形同虚设。
|
|
///
|
|
/// 代价是用户要重新扫一次配对(扫描页点一下)。这一步换来的是「记住的设备」
|
|
/// 与「扫描白名单」口径一致,值得,而且只发生一次。
|
|
void _purgeUntrustedPairsOnce() {
|
|
const key = 'holder_pairs_purged_namegate_v1';
|
|
if (_storage.read<bool>(key) == true) return;
|
|
_storage.write(key, true);
|
|
final had = _storage.hasData(_lastDeviceKey) || pairedDevices.isNotEmpty;
|
|
_storage.remove(_lastDeviceKey);
|
|
_storage.remove(_pairedListKey);
|
|
if (had) {
|
|
Logger.w(_tag, '广播名白名单收紧,已清除旧的支架配对记录,请重新扫描配对');
|
|
}
|
|
}
|
|
|
|
/// 记住最近一次成功连接的设备,供开机/断连后自动回连,
|
|
/// 同时写入本地已绑定列表(设备管理页会合并展示)。
|
|
void _rememberDevice(String name, String address) {
|
|
if (address.isEmpty) return;
|
|
_storage.write(_lastDeviceKey, {'name': name, 'address': address});
|
|
_savePaired(name, address);
|
|
}
|
|
|
|
/// 本地已绑定的支架列表
|
|
List<Map> get pairedDevices {
|
|
final raw = _storage.read<List>(_pairedListKey);
|
|
if (raw == null) return const [];
|
|
return raw.whereType<Map>().toList(growable: false);
|
|
}
|
|
|
|
void _savePaired(String name, String address) {
|
|
final list = pairedDevices.toList();
|
|
final key = address.toUpperCase();
|
|
list.removeWhere(
|
|
(e) => (e['address'] ?? '').toString().toUpperCase() == key);
|
|
// 最近连接的排最前,供后续多设备回连按 MRU 顺序尝试
|
|
list.insert(0, {'name': name, 'address': address});
|
|
_storage.write(_pairedListKey, list);
|
|
Logger.i(_tag, '本地已绑定列表更新,共 ${list.length} 台');
|
|
}
|
|
|
|
/// 从本地已绑定列表移除(解绑用)
|
|
void removePaired(String address) {
|
|
final list = pairedDevices.toList();
|
|
final key = address.toUpperCase();
|
|
list.removeWhere(
|
|
(e) => (e['address'] ?? '').toString().toUpperCase() == key);
|
|
_storage.write(_pairedListKey, list);
|
|
}
|
|
|
|
Map? get _lastDevice => _storage.read<Map>(_lastDeviceKey);
|
|
|
|
/// 是否有已绑定(记住)的支架
|
|
bool get hasPairedDevice => (_lastDevice?['address'] ?? '') != '';
|
|
|
|
/// 最近一次连接过的设备名。断连后设备页仍按它显示主图,
|
|
/// 而不是退回默认图——用户看到的应该是"我的那台设备"。
|
|
String get lastDeviceName {
|
|
final n = connectedNameRx.value;
|
|
if (n.isNotEmpty) return n;
|
|
return (_lastDevice?['name'] ?? '') as String;
|
|
}
|
|
|
|
/// 尝试自动回连。
|
|
///
|
|
/// Android 上 SDK 的 connect() 走的是 registerBackgroundScan(mac),
|
|
/// 那本身就是系统级「后台扫到该 MAC 就连」的注册——设备开机进入广播范围时
|
|
/// 由原生层自动接上。所以自动回连的关键是:App 启动后 / 断连后重新注册一次。
|
|
/// 注册失败按退避重试,最多 [_maxAutoAttempts] 次。
|
|
static const int _maxAutoAttempts = 5;
|
|
|
|
/// 本次运行里用户拒绝过系统蓝牙权限。拒绝后不再自动重试、不再反复弹框,
|
|
/// 直到用户主动去连接页重试([resetPermissionDenied])。
|
|
bool _permissionDenied = false;
|
|
|
|
void resetPermissionDenied() => _permissionDenied = false;
|
|
|
|
Future<void> tryAutoConnect({bool reset = true}) async {
|
|
if (reset) _autoAttempt = 0;
|
|
_autoConnectTimer?.cancel();
|
|
if (isConnected) return;
|
|
// 自动回连 = 向系统注册蓝牙后台扫描 = 设备靠近时自启动本应用,
|
|
// 未取得用户明示同意前不能做(应用商店合规检测项)。
|
|
// 这里只做静默检查,不弹窗——弹窗只在用户主动点连接时弹。
|
|
if (!BackgroundLaunchConsent.granted) {
|
|
Logger.i(_tag, '用户未开启设备自动连接,跳过后台扫连注册');
|
|
return;
|
|
}
|
|
final last = _lastDevice;
|
|
final address = (last?['address'] ?? '') as String;
|
|
if (address.isEmpty) return;
|
|
if (_permissionDenied) {
|
|
Logger.i(_tag, '本次运行已被拒绝蓝牙权限,不再重复申请');
|
|
return;
|
|
}
|
|
// 合规(场景 1/2):后台自动回连是没有用户操作的流程,这里**只能查状态、
|
|
// 不能申请权限**——原来调 _sdk.requestPermission() 会让原生直接弹系统框,
|
|
// 而且拒绝标记只存在内存里,重启 App 又会再弹一次,正好踩中场景 2。
|
|
// 没权限就安静地放弃自动回连,等用户自己去连接页点连接时再申请。
|
|
if (!await _hasBluetoothPermission()) {
|
|
_permissionDenied = true;
|
|
_autoConnectTimer?.cancel();
|
|
_autoAttempt = _maxAutoAttempts;
|
|
Logger.w(_tag, '无蓝牙权限,自动回连中止(不弹框,等用户主动连接时再申请)');
|
|
return;
|
|
}
|
|
|
|
// ⚠️ 两个名字要分开:
|
|
// rawName —— 固件实际广播的名字(`Echomeet`)。iOS 上它会被当成
|
|
// `deviceNamePattern` 传给 startDeviceAssociation 去**匹配广播**,
|
|
// 传归一后的 `EaiRec` 就匹配不上,直接连不上且不报错。
|
|
// name —— 对外的产品名(`EaiRec`)。binddevice 上报、设备产品图、
|
|
// 固件升级的产品匹配都用它。
|
|
final name = (last?['name'] ?? '') as String;
|
|
// ⚠️ 记住过的设备也要校验:名字白名单收紧之后,本地可能还留着旧固件那台
|
|
// (刷名字之前连过)。不挡的话它会继续被自动回连上来,准入校验等于没做。
|
|
if (!isAllowedHolderName(name)) {
|
|
Logger.w(_tag, '记住的设备名「$name」不在允许列表 $kHolderAllowedNames,不再自动回连,已清除记录');
|
|
_autoAttempt = _maxAutoAttempts;
|
|
removePaired(address);
|
|
return;
|
|
}
|
|
connectedNameRx.value = name;
|
|
Logger.i(_tag, '自动回连注册: $name $address (第 ${_autoAttempt + 1} 次)');
|
|
connectedAddressRx.value = address;
|
|
bool ok = false;
|
|
try {
|
|
ok = await _sdk.connect(name: name, address: address);
|
|
} catch (e) {
|
|
Logger.e(_tag, '自动回连注册异常: $e');
|
|
}
|
|
|
|
// 注册成功后由原生后台扫连接管;这里只在注册失败时退避重试
|
|
if (!ok && ++_autoAttempt < _maxAutoAttempts) {
|
|
final delay = Duration(seconds: 5 * _autoAttempt);
|
|
_autoConnectTimer = Timer(delay, () => tryAutoConnect(reset: false));
|
|
}
|
|
}
|
|
|
|
StreamSubscription? _scanSub;
|
|
StreamSubscription? _connSub;
|
|
StreamSubscription? _recSub;
|
|
StreamSubscription? _infoSub;
|
|
|
|
/// 设备是否已连接
|
|
bool get isConnected => connection.value == EchoMeetConnectionState.connected;
|
|
|
|
/// 设备当前是否正在录音(以 SDK 缓存的设备上报状态为准)。
|
|
///
|
|
/// 录音控制是 toggle 语义,下发前必须先看设备实际状态——
|
|
/// 设备已经在录时再发一次 toggle 会把它关掉。
|
|
bool get deviceIsRecording =>
|
|
recording.value.isRecording || _sdk.isRecording;
|
|
|
|
/// 录音的 PCM 帧流(16kHz / 16bit / 单声道,Opus 已在原生侧解码)
|
|
Stream<EchoMeetAudioFrame> get audioFrames => _sdk.audioFrames;
|
|
|
|
// ==================== 通道 B:TF 卡文件 ====================
|
|
//
|
|
// 协议见 local_plugins/recorder_holder/EaiRec_protocol.md §5。
|
|
// 这里只做「连上之后对时 + 拉一次状态」和几个供上层调用的入口;
|
|
// 帧格式与状态机都在插件的 RecClient 里,本类不碰字节。
|
|
|
|
/// 设备 TF 卡与录音状态。设备主动上报(录音起停)也会刷新它。
|
|
final Rxn<RecInfo> recInfo = Rxn<RecInfo>();
|
|
|
|
/// 通道 B 是否可用。设备没有 TF 卡能力时为 false,此时通道 A 照常工作。
|
|
final RxBool recChannelReady = false.obs;
|
|
|
|
StreamSubscription<RecInfo>? _recInfoSub;
|
|
StreamSubscription<List<RecFileItem>>? _recListSub;
|
|
|
|
/// 设备端录音停止后会主动推一次完整文件列表,落在这里。
|
|
final RxList<RecFileItem> recFiles = <RecFileItem>[].obs;
|
|
|
|
/// 同一份推送的事件流版本。RxList 只表达「当前是什么」,
|
|
/// 而导入服务要的是「又推了一次」这个**事件**(内容可能一模一样)。
|
|
final StreamController<List<RecFileItem>> _recFilesPushes =
|
|
StreamController<List<RecFileItem>>.broadcast();
|
|
Stream<List<RecFileItem>> get recFilesPushes => _recFilesPushes.stream;
|
|
|
|
/// 连上之后的通道 B 握手。
|
|
///
|
|
/// 顺序按协议文档 §8.1:先对时再查状态 —— 设备是拿 RTC 生成录音文件的 FAT
|
|
/// 时间的,对时晚了这一次录的文件时间就是错的。
|
|
/// 通道 B 握手最多试几次。原生的 `connected` 回调在**服务发现之前**就抛出来了
|
|
/// (真机实测差约 700ms),第一次探必然探到「没有 0xFDA5」。
|
|
/// ⚠️ 这里失败就直接 return 的老写法后果比看上去严重:不只是这次没握上手,
|
|
/// 而是**对时没做(录音文件时间全错)、文件列表推送没订阅(录完不会自动同步)、
|
|
/// 设备状态没拉**,而 `ensureRecChannel()` 本身是每次重新探的,
|
|
/// 于是「列表能拉、但录完不会自动出现」——一个非常难查的半死状态。
|
|
static const int _maxRecChannelTries = 6;
|
|
|
|
Future<void> _initRecChannel() async {
|
|
for (var i = 1; i <= _maxRecChannelTries; i++) {
|
|
if (!isConnected) return; // 中途断了就别再试
|
|
try {
|
|
if (await _sdk.ensureRecChannel()) {
|
|
// 订阅要在对时之前挂上:设备可能在握手过程中就推东西上来。
|
|
// `??=` 保证重试时不会重复订阅。
|
|
_recInfoSub ??= _sdk.recorder.onRecInfo.listen((info) {
|
|
recInfo.value = info;
|
|
Logger.i(_tag, '设备状态: $info');
|
|
});
|
|
_recListSub ??= _sdk.recorder.onFileListPushed.listen((list) {
|
|
Logger.i(_tag, '设备主动推送文件列表,共 ${list.length} 个');
|
|
recFiles.assignAll(list);
|
|
if (!_recFilesPushes.isClosed) _recFilesPushes.add(list);
|
|
});
|
|
|
|
await _sdk.recorder.syncRtc();
|
|
recInfo.value = await _sdk.recorder.queryInfo();
|
|
recChannelReady.value = true;
|
|
Logger.i(_tag, '通道 B 就绪(第 $i 次): ${recInfo.value}');
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
// 通道 B 失败不能影响连接本身,只记不抛
|
|
Logger.i(_tag, '通道 B 握手第 $i 次失败,重试: $e');
|
|
}
|
|
await Future.delayed(const Duration(milliseconds: 800));
|
|
}
|
|
recChannelReady.value = false;
|
|
Logger.w(_tag, '通道 B 握手 $_maxRecChannelTries 次都没成,TF 卡录音无法同步');
|
|
}
|
|
|
|
/// 拉取 TF 卡上的完整录音列表。
|
|
Future<List<RecFileItem>> listRecordings() async {
|
|
if (!await _sdk.ensureRecChannel()) {
|
|
throw RecException('通道 B 不可用');
|
|
}
|
|
final list = await _sdk.recorder.listFiles();
|
|
recFiles.assignAll(list);
|
|
return list;
|
|
}
|
|
|
|
/// 把 TF 卡上的一个录音下载到本地。
|
|
///
|
|
/// 落在 `<documents>/EaiRec/<文件名>`,**同名文件会被当作断点续传的半成品接着写**
|
|
/// —— 这正是要的行为(下载中断后重进接着下)。要重下得先删掉本地文件。
|
|
Future<RecDownloadResult> downloadRecording(
|
|
String name, {
|
|
void Function(int received, int total)? onProgress,
|
|
RecCancelToken? cancelToken,
|
|
}) async {
|
|
if (!await _sdk.ensureRecChannel()) {
|
|
throw RecException('通道 B 不可用');
|
|
}
|
|
final docs = await getApplicationDocumentsDirectory();
|
|
final sink = FileRecSink(File('${docs.path}/EaiRec/$name'));
|
|
await sink.open();
|
|
return _sdk.recorder.download(
|
|
name: name,
|
|
sink: sink,
|
|
onProgress: onProgress,
|
|
cancelToken: cancelToken,
|
|
);
|
|
}
|
|
|
|
/// 把 TF 卡上的一个录音拉到调用方给的 sink 里。
|
|
///
|
|
/// 与 [downloadRecording] 的区别是**落点由调用方决定**——通用的
|
|
/// [DeviceFileStore] 端口要把文件放进自己的暂存目录,不能被这里写死。
|
|
Future<RecDownloadResult> downloadTo({
|
|
required String name,
|
|
required RecFileSink sink,
|
|
void Function(int received, int total)? onProgress,
|
|
RecCancelToken? cancelToken,
|
|
}) async {
|
|
if (!await _sdk.ensureRecChannel()) {
|
|
throw RecException('通道 B 不可用');
|
|
}
|
|
return _sdk.recorder.download(
|
|
name: name,
|
|
sink: sink,
|
|
onProgress: onProgress,
|
|
cancelToken: cancelToken,
|
|
);
|
|
}
|
|
|
|
/// 删除设备上的一个录音。**调用前 UI 必须已二次确认。**
|
|
Future<void> deleteRecording(String name) async {
|
|
if (!await _sdk.ensureRecChannel()) {
|
|
throw RecException('通道 B 不可用');
|
|
}
|
|
await _sdk.recorder.deleteFile(name);
|
|
recFiles.removeWhere((e) => e.name == name);
|
|
}
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
// 必须排在 ensureBound / 任何自动回连之前:清的就是会被回连拉起来的那份记录
|
|
_purgeUntrustedPairsOnce();
|
|
_sdk.ensureBound();
|
|
_scanSub = _sdk.scanResults.listen((list) {
|
|
if (list.isEmpty) return; // 空批次不清空已发现列表
|
|
for (final d in list) {
|
|
final key = d.address.isNotEmpty ? d.address : d.id;
|
|
if (key.isEmpty) continue;
|
|
_byAddress[key] = d;
|
|
}
|
|
scanned.assignAll(_byAddress.values.toList(growable: false));
|
|
});
|
|
_connSub = _sdk.connectionState.listen((s) {
|
|
Logger.i(_tag, '连接状态: $s');
|
|
connection.value = s;
|
|
if (s == EchoMeetConnectionState.connected) {
|
|
_autoConnectTimer?.cancel();
|
|
_autoAttempt = 0;
|
|
// ⚠️ 这里存的必须是**固件实际广播的名字**(手动连接时来自扫描结果,
|
|
// 自动回连时是把存储值原样写回,幂等)。
|
|
// 千万别在写入前对名字做任何"归一/美化"——存储是自动回连路径下**唯一**
|
|
// 的名字来源,一旦被改写成合规名,isAllowedHolderName 校验的就是我们
|
|
// 自己编的名字,准入等于没做。2026-09-15 真机上就这么被绕过一次。
|
|
_rememberDevice(
|
|
connectedNameRx.value,
|
|
connectedAddressRx.value,
|
|
);
|
|
// 协议 §8.1 第 7 步:连上后主动查一次电量。
|
|
// ⚠️ 不能只靠 waitConnected() 里那次——**自动回连不走 waitConnected**
|
|
// (`tryAutoConnect` 直接调 SDK.connect),于是设备页电量一直空着。
|
|
// 设备侧电量是「请求/响应 + 主动上报」双模式,不问就可能永远等不到。
|
|
_kickBatteryQuery();
|
|
// ⚠️ 绑定登记同样不能只挂在 connect():**自动回连不走 connect()**。
|
|
// 设备一旦记住了,之后每次都是原生回连上线,绑定接口一次都不会打,
|
|
// 设备管理页就永远空着(换账号/后台补录 MAC 之后尤其明显)。
|
|
unawaited(bindConnectedToServer());
|
|
// 通道 B(TF 卡文件)握手,失败不影响录音等通道 A 的功能
|
|
unawaited(_initRecChannel());
|
|
} else if (s == EchoMeetConnectionState.disconnected &&
|
|
hasPairedDevice) {
|
|
// 设备关机/走远导致的断连:重新挂上后台扫连,等它再开机
|
|
_batteryRetry?.cancel();
|
|
Logger.i(_tag, '连接断开,重新注册后台扫连等待设备上线');
|
|
Future.delayed(const Duration(seconds: 3), () => tryAutoConnect());
|
|
}
|
|
});
|
|
_recSub = _sdk.recordingState.listen((s) => recording.value = s);
|
|
_infoSub = _sdk.deviceInfo.listen((i) => deviceInfo.value = i);
|
|
// App 启动后延迟一点再挂,等蓝牙栈和权限就绪
|
|
Future.delayed(const Duration(seconds: 3), () => tryAutoConnect());
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
_scanSub?.cancel();
|
|
_connSub?.cancel();
|
|
_recSub?.cancel();
|
|
_infoSub?.cancel();
|
|
_recInfoSub?.cancel();
|
|
_recListSub?.cancel();
|
|
_recFilesPushes.close();
|
|
_autoConnectTimer?.cancel();
|
|
_batteryRetry?.cancel();
|
|
super.onClose();
|
|
}
|
|
|
|
/// 支架 SDK 扫描/连接需要的蓝牙权限
|
|
static const List<Permission> _btPermissions = [
|
|
Permission.bluetoothScan,
|
|
Permission.bluetoothConnect,
|
|
];
|
|
|
|
/// 只查状态,不申请
|
|
Future<bool> _hasBluetoothPermission() async {
|
|
if (!Platform.isAndroid) return true;
|
|
for (final p in _btPermissions) {
|
|
if (!await p.status.isGranted) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// 用户拒绝过且仍在 48h 冷却期 —— UI 据此把扫描/连接入口置灰
|
|
bool get isPermissionCoolingDown =>
|
|
Platform.isAndroid &&
|
|
PermissionUtil.instance.isAnyDenialCoolingDown(_btPermissions);
|
|
|
|
/// 申请蓝牙权限(只允许在用户主动触发扫描/连接时调用)
|
|
///
|
|
/// 合规(场景 1/2):SDK 的 requestPermission() 是原生直接弹系统框的,
|
|
/// permission_handler 管不到,所以在这里手工加冷却闸:拒绝过就直接返回 false,
|
|
/// 不再调用 SDK,重启 App 也一样——把拒绝状态落到 GetStorage 而不是内存变量。
|
|
Future<bool> requestPermission() async {
|
|
if (await _hasBluetoothPermission()) return true;
|
|
if (isPermissionCoolingDown) {
|
|
Logger.i(_tag, '蓝牙权限已被拒绝,48h 冷却中,本次不再申请,请置灰入口');
|
|
return false;
|
|
}
|
|
final granted = await _sdk.requestPermission();
|
|
if (!granted) {
|
|
await PermissionUtil.instance.markDenied(_btPermissions);
|
|
}
|
|
return granted;
|
|
}
|
|
|
|
Future<void> startScan() async {
|
|
if (!await requestPermission()) {
|
|
Logger.w(_tag, '蓝牙权限被拒绝,无法扫描');
|
|
return;
|
|
}
|
|
_byAddress.clear();
|
|
scanned.clear();
|
|
isScanning.value = true;
|
|
// ⚠️ 原生 `startScan` 会返回 false(蓝牙未就绪 / 权限 / 已在扫描)。
|
|
// 以前这里不看返回值,UI 就一直转「正在搜索」而其实根本没在扫。
|
|
// 现在失败了等蓝牙栈就绪再补一次;两次都不行才如实把 isScanning 放下来。
|
|
var ok = await _sdk.startScan();
|
|
if (!ok) {
|
|
await Future.delayed(const Duration(milliseconds: 800));
|
|
ok = await _sdk.startScan();
|
|
}
|
|
if (!ok) {
|
|
Logger.w(_tag, '原生扫描未能启动(蓝牙未就绪/权限/已在扫描),UI 退回未扫描态');
|
|
isScanning.value = false;
|
|
}
|
|
}
|
|
|
|
Future<void> stopScan() async {
|
|
await _sdk.stopScan();
|
|
isScanning.value = false;
|
|
}
|
|
|
|
Future<bool> connect(EchoMeetScannedDevice device) async {
|
|
// 扫描侧已按名字前缀筛过一道,这里是连接入口的兜底:手动传入、
|
|
// 或将来 filters 被放宽时,仍然不放不合规的固件进来。
|
|
if (!isAllowedHolderName(device.name)) {
|
|
Logger.w(_tag, '设备名「${device.name}」不在允许列表 $kHolderAllowedNames,拒绝连接');
|
|
return false;
|
|
}
|
|
await stopScan();
|
|
Logger.i(_tag, '连接设备: ${device.name} ${device.address}');
|
|
connectedAddressRx.value = device.address;
|
|
connectedNameRx.value = device.name;
|
|
_rememberDevice(device.name, device.address);
|
|
// 先向服务端登记绑定,设备管理页才有记录
|
|
await bindToServer(device);
|
|
return _sdk.connectDevice(device);
|
|
}
|
|
|
|
/// 把支架登记到服务端的已绑定设备列表。
|
|
///
|
|
/// 走 `user_binddevice`,与恒玄耳机同一个接口、同一套规则:**只按 MAC 校验**
|
|
/// (协议 §2.3;服务端 `api_binddevice.go` 拿 devicemac 在全局 `device_mac`
|
|
/// 表里查,产品是从命中行的 productid 列读出来的)。
|
|
///
|
|
/// ⚠️ **不要传 pid**:服务端 `UserBindDeviceReq.pid` 是 `int32`,而这里唯一能填的
|
|
/// `advertisementHex` 是十六进制**字符串**(常常还是空串)。传上去必然
|
|
/// `readUint32: unexpected character` → `code:22`,整个请求被拒,
|
|
/// 表现为「连上了但设备管理页永远空着」。恒玄那条链路就是不传 pid 的。
|
|
/// code 也留空:支架没有杰理那套广播授权码。
|
|
///
|
|
/// 接口失败不阻断连接和录音,只是设备管理页没有记录。
|
|
Future<void> bindToServer(EchoMeetScannedDevice device) =>
|
|
_bindMac(device.name, device.address);
|
|
|
|
/// 用**当前连着的**这台去登记。自动回连路径专用:那条路不经过 [connect],
|
|
/// 所以拿不到 `EchoMeetScannedDevice`,只有连接时记下的名字和 MAC。
|
|
Future<void> bindConnectedToServer() =>
|
|
_bindMac(connectedNameRx.value, connectedAddressRx.value);
|
|
|
|
/// 登记「这台支架现在属于当前账号」。
|
|
///
|
|
/// ⚠️ 去重键按 **uid|MAC** 走(在 [DeviceBindRegistry] 里),不能只按 MAC:
|
|
/// 只按 MAC 记会把「换了个账号」这件事一起吞掉 —— 服务端还认为设备属于上一个
|
|
/// 账号,新账号的设备管理页永远空着。服务端 user_binddevice 本来就支持接管。
|
|
Future<void> _bindMac(String name, String mac) async {
|
|
if (mac.isEmpty) return;
|
|
if (!User.isLoggedIn()) {
|
|
// 先连设备后登录是常见顺序(设备被动回连常快过登录态恢复)。
|
|
// 登录完成后由 DeviceAuth.bindLoginRetry 补报,这里不算失败。
|
|
Logger.w(_tag, '未登录,跳过设备绑定登记(登录后会自动补报)');
|
|
return;
|
|
}
|
|
await DeviceBindRegistry.ensureBound(mac: mac, name: name);
|
|
}
|
|
|
|
/// 等待连接真正建立。
|
|
///
|
|
/// Android 上 [connect] 调的是 registerBackgroundScan,返回值只代表「后台扫连
|
|
/// 注册成功」,真正连上是异步的,要等 connectionState 流推 connected。
|
|
/// SDK 的 INTEGRATION.md 快速开始里也是这个流程。
|
|
Future<bool> waitConnected({
|
|
Duration timeout = const Duration(seconds: 25),
|
|
}) async {
|
|
if (isConnected) return true;
|
|
final completer = Completer<bool>();
|
|
late final Worker worker;
|
|
Timer? timer;
|
|
void finish(bool ok) {
|
|
if (completer.isCompleted) return;
|
|
worker.dispose();
|
|
timer?.cancel();
|
|
completer.complete(ok);
|
|
}
|
|
|
|
worker = ever<EchoMeetConnectionState>(connection, (s) {
|
|
if (s == EchoMeetConnectionState.connected) {
|
|
// 电量查询不在这里发:onInit 的 connected 分支已经用 _kickBatteryQuery()
|
|
// 带重试地问了。在这儿再补一发只会在服务发现完成前多失败一次。
|
|
finish(true);
|
|
}
|
|
if (s == EchoMeetConnectionState.error) finish(false);
|
|
});
|
|
timer = Timer(timeout, () => finish(isConnected));
|
|
return completer.future;
|
|
}
|
|
|
|
/// 主动断开:同时清掉记忆,避免用户手动断开后又被自动连回
|
|
Future<void> disconnect() async {
|
|
_autoConnectTimer?.cancel();
|
|
final addr = connectedAddressRx.value;
|
|
await _storage.remove(_lastDeviceKey);
|
|
if (addr.isNotEmpty) removePaired(addr);
|
|
await _sdk.disconnect();
|
|
}
|
|
|
|
/// 打开设备编码器,让它开始往手机推 Opus 流。
|
|
///
|
|
/// 协议层是 `0xAA 0x05 0x02 0xB1 0x02 [CRC]`。这是唯一真正下发到设备的
|
|
/// 录音相关命令——`toggleRecording` 只切换原生的本地录音状态,不发 BLE 包。
|
|
Future<bool> openEncoder() => _sdk.openEncoder();
|
|
|
|
/// 切换设备录音状态。
|
|
///
|
|
/// 注意:原生的 toggleRecording **不发任何 BLE 包**,只切换 BleRecord 的
|
|
/// 本地 timerStatus / 文件写入状态。真正让设备推流的是 [openEncoder]。
|
|
Future<bool> startRecording() => _sdk.toggleRecording();
|
|
|
|
/// 停止设备录音。[fileName] 会作为设备端保存的文件名。
|
|
///
|
|
/// SDK 的 stopRecording 内部是 setNewName(name) + saveRecording(),
|
|
/// 与 echomeet 的停止序列一致,直接复用。
|
|
Future<bool> stopRecording({String? fileName}) =>
|
|
_sdk.stopRecording(fileName: fileName);
|
|
|
|
/// 暂停 / 恢复(SDK 是 toggle 语义)
|
|
Future<bool> toggleRecording() => _sdk.toggleRecording();
|
|
|
|
Future<bool> refreshBattery() => _sdk.refreshBattery();
|
|
|
|
// ---------------- 电量:连上后必须重试着问 ----------------
|
|
|
|
Timer? _batteryRetry;
|
|
int _batteryTries = 0;
|
|
|
|
/// 连上后把电量拿到手。**这里必须重试,问一次是不够的。**
|
|
///
|
|
/// ⚠️ 原生的 `connected` 回调在**服务发现之前**就抛出来了(真机实测差约
|
|
/// 700ms),那一刻写特征还是 null,`sendCommand` 直接打
|
|
/// `发送命令失败: 设备未连接` 并返回 false —— 命令整条丢在地上,且没有任何
|
|
/// 上层能看见的错。设备侧电量虽然也有主动上报,但不保证会来,
|
|
/// 于是表现就是**设备页右下角永远没有电量**(2026-09-10 真机复现)。
|
|
///
|
|
/// 所以:发不出去就隔 800ms 再发,发出去了给 2s 等回包,直到 deviceInfo
|
|
/// 里真的有电量值、或者试满 [_maxBatteryTries] 次为止。
|
|
/// 4 次约 3 秒,够覆盖服务发现那 700ms 的窗口了。**不要调大**:
|
|
/// 通道 A 缺失时这些重试一次都不会成功,只是白刷日志(见 [_attemptBattery] 的说明)。
|
|
static const int _maxBatteryTries = 4;
|
|
|
|
void _kickBatteryQuery() {
|
|
_batteryRetry?.cancel();
|
|
_batteryTries = 0;
|
|
unawaited(_attemptBattery());
|
|
}
|
|
|
|
Future<void> _attemptBattery() async {
|
|
if (!isConnected) return; // 断了就别再问
|
|
if (_batteryTries >= _maxBatteryTries) {
|
|
// ⚠️ 走到这里最常见的**不是**时序问题,而是设备根本没有通道 A。
|
|
// 2026-09-10 真机(Echomeet 00:0D:01:00:00:41)服务发现只有一个服务:
|
|
// `0xFDA5`(FDA6/FDA7) —— 通道 B(TF 卡)。协议 §4.3 的电量在通道 A
|
|
// (`abc0`/`abc1`/`abc2`) 上,那个服务压根不存在,于是原生 checkConn()
|
|
// 因 writeChar==null 恒 false,每一发都打「发送命令失败: 设备未连接」。
|
|
// **那条错误信息是误导**:链路好好的,缺的是服务。要电量得固件补通道 A。
|
|
Logger.w(
|
|
_tag,
|
|
'连上后问了 $_batteryTries 次都没拿到电量。'
|
|
'若原生一直报「发送命令失败: 设备未连接」,看服务发现清单:'
|
|
'这台固件很可能只暴露了 0xFDA5(通道B),没有 abc0(通道A),电量物理上问不到');
|
|
return;
|
|
}
|
|
_batteryTries++;
|
|
final sent = await refreshBattery();
|
|
_batteryRetry = Timer(
|
|
Duration(milliseconds: sent ? 2000 : 800),
|
|
() {
|
|
if (deviceInfo.value?.batteryLevel != null) {
|
|
Logger.i(_tag, '电量已拿到: ${deviceInfo.value?.batteryLevel}%'
|
|
'(第 $_batteryTries 次尝试)');
|
|
return;
|
|
}
|
|
unawaited(_attemptBattery());
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|