import 'dart:async'; import 'dart:io'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; import '../../core/utils/logger.dart'; import 'holder_device_service.dart'; /// EaiRec 支架的设备端文件仓库 —— 通道 B(TF 卡,`0xFDA5`)。 /// /// **这台硬件唯一可用的录音路径就是这条**:它没有通道 A' 实时音频上行 /// (真机 GATT 里根本没有 `ae00`,见 CLAUDE.md 支架那节),录音是 /// 「录到 TF 卡、事后拉」。所以本类不是锦上添花,是支架录音能不能用的关键。 /// /// 协议与状态机都在 `recorder_holder` 的 `RecClient` 里,本类只做两件事: /// 把它接到通用的 [DeviceFileStore] 上,以及把 SDK 的异常翻译成 [DeviceException]。 class HolderFileStore implements DeviceFileStore { HolderFileStore(this._link); static const String _tag = 'HolderFileStore'; final HolderDeviceService _link; @override bool get isDeviceRecording => _link.deviceIsRecording || (_link.recInfo.value?.recording ?? false); @override Stream> get listPushes => _link.recFilesPushes.map((list) => list.map(_toEntry).toList()); @override Future> list() async { try { final files = await _link.listRecordings(); return files.map(_toEntry).toList(growable: false); } on RecException catch (e) { throw DeviceException(DeviceErrorCode.fileTransferFailed, e.toString()); } } @override Future download( String name, { required String destPath, void Function(int received, int total)? onProgress, DeviceFileCancelToken? cancelToken, }) async { // ⚠️ 正在录的那个文件不能拉:设备还在往里写,拉下来是个残片, // 而且 size 一直在变,断点续传的长度判断也会错。 if (isDeviceRecording) { throw DeviceException(DeviceErrorCode.fileBusy, '设备正在录音'); } final dest = File(destPath); await dest.parent.create(recursive: true); // SDK 的取消令牌与通用令牌是两套类型,这里做个桥接 final tok = RecCancelToken(); if (cancelToken?.isCancelled ?? false) { tok.cancel(); } else { unawaited(cancelToken?.whenCancelled.then((_) => tok.cancel())); } final sink = FileRecSink(dest); await sink.open(); try { final result = await _link.downloadTo( name: name, sink: sink, onProgress: onProgress, cancelToken: tok, ); if (!result.complete) { // 没传完只有两种可能:被取消,或链路断了。前者不是故障。 if (cancelToken?.isCancelled ?? false) { throw DeviceException(DeviceErrorCode.cancelled, name); } throw DeviceException( DeviceErrorCode.fileTransferFailed, '$name 未传输完整'); } // ⚠️ 落盘字节数必须与设备报的文件长度逐字节对上。 // 这条是**删除设备原件的前置条件**:传输层虽然逐包校验了 checksum // (RecClient.verifyChecksum 默认开)且 complete 判的是 bytes==fileSize, // 但那看的是 sink 的计数,不是磁盘上真实的长度。写盘失败/被截断时 // 两者会不一致,而我们马上要拿这个结果去做不可逆的删除。 final onDisk = await dest.length(); if (onDisk != result.fileSize) { throw DeviceException(DeviceErrorCode.fileTransferFailed, '$name 落盘 $onDisk B 与设备报的 ${result.fileSize} B 不符'); } Logger.i(_tag, '已拉取 $name → $destPath ($onDisk B,' '断点续传 ${result.resumes} 次)'); return destPath; } on RecException catch (e) { throw DeviceException(DeviceErrorCode.fileTransferFailed, e.toString()); } } @override Future delete(String name) async { try { await _link.deleteRecording(name); } on RecException catch (e) { throw DeviceException(DeviceErrorCode.featureFailed, e.toString()); } } DeviceFileEntry _toEntry(RecFileItem f) => DeviceFileEntry( name: f.name, size: f.size, // RecFileItem.time 解不出合法 FAT 日期时就是 null,原样透传: // 上层会退回「按导入时刻命名」,好过显示 1980 年 modifiedAt: f.time, metadata: {'ftime': f.ftime}, ); }