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.
 
 
 
 
 
 

405 lines
16 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:permission_handler/permission_handler.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/permission_util.dart';
import 'background_launch_consent.dart';
import 'agent_module_defaults.dart';
import '../models/user_Info.dart';
import 'network/api.dart';
/// 蓝讯方案设备(AI手机支架 / AI Pencil)的接入层。
///
/// 底层协议实现全部来自 echomeet 项目的 `recorder_holder` 插件,本类只是把
/// [EchoMeetDeviceSdk] 的流封装成 GetX 可观测状态,不重写任何蓝牙/Opus 逻辑。
///
/// 与项目原有的 [BleManager](杰理耳机链路)是两套独立的设备通路,互不影响:
/// - 杰理耳机 → BleManager
/// - 蓝讯支架 → 本服务
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';
/// 记住最近一次成功连接的设备,供开机/断连后自动回连,
/// 同时写入本地已绑定列表(设备管理页会合并展示)。
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;
}
final name = (last?['name'] ?? 'Echomeet') as String;
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;
@override
void onInit() {
super.onInit();
_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;
_rememberDevice('Echomeet', connectedAddressRx.value);
// 首次连上支架 → AI 页铺开支架的默认功能编排
AgentDefaults.onHolderConnected();
} else if (s == EchoMeetConnectionState.disconnected &&
hasPairedDevice) {
// 设备关机/走远导致的断连:重新挂上后台扫连,等它再开机
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();
_autoConnectTimer?.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;
await _sdk.startScan();
}
Future<void> stopScan() async {
await _sdk.stopScan();
isScanning.value = false;
}
Future<bool> connect(EchoMeetScannedDevice device) async {
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 接口。蓝讯广播里没有杰理那套
/// pid/code 授权字段,所以 pid 用广播里的 hex(服务端可据此识别),
/// code 留空——接口失败不阻断连接,只记日志。
Future<void> bindToServer(EchoMeetScannedDevice device) async {
if (!User.isLoggedIn()) {
Logger.w(_tag, '未登录,跳过设备绑定登记');
return;
}
try {
final resp = await Api.binddevice({
'pid': device.advertisementHex,
'code': '',
'devicename': device.name,
'devicemac': device.address,
});
Logger.i(_tag, '绑定接口返回: $resp');
final deviceJson = resp?['device'];
if (deviceJson is Map<String, dynamic>) {
User.instance.addUserDevice(UserDevice.fromJson(deviceJson));
Logger.i(_tag, '支架已登记到已绑定设备列表');
} else {
Logger.w(_tag, '绑定接口未返回 device 字段,设备管理页可能看不到这台');
}
} catch (e) {
// 绑定失败不影响本次连接和录音,只是设备管理页没有记录
Logger.e(_tag, '支架绑定登记失败: $e');
}
}
/// 等待连接真正建立。
///
/// 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) {
// 连上后主动拉一次电量,设备页右下角才有数可显示
refreshBattery();
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();
}