131 changed files with 9259 additions and 5689 deletions
File diff suppressed because it is too large
@ -1,110 +1,211 @@ |
|||||
// import 'dart:ffi'; // Not supported on web; not needed here |
import 'dart:async'; |
||||
|
|
||||
import '/data/models/appconfig_model.dart'; |
import 'package:device_jieli/device_jieli.dart'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:permission_handler/permission_handler.dart'; |
||||
|
|
||||
import '/core/utils/logger.dart'; |
import '/core/utils/logger.dart'; |
||||
import '/data/models/user_Info.dart'; |
|
||||
import '/data/services/ble_manager.dart'; |
import '/data/services/ble_manager.dart'; |
||||
import '/data/services/network/api.dart'; |
|
||||
import '/routes/app_routes.dart'; |
import '/routes/app_routes.dart'; |
||||
import 'package:get/get.dart'; |
|
||||
|
|
||||
/* |
/// 设备页控制器:扫描杰理设备、点击连接、断开。 |
||||
设备管理 |
/// 旧的绑定 / 解绑流程已下线。 |
||||
*/ |
|
||||
class DevicesController extends GetxController { |
class DevicesController extends GetxController { |
||||
late final BleManager _bleManager; |
late final BleManager _bleManager; |
||||
|
|
||||
// 修改为响应式变量 |
// 扫描结果(直接绑定到 BleManager 内部 Rx 列表) |
||||
final RxMap<int, List<UserDevice>> devices = <int, List<UserDevice>>{}.obs; |
RxList<JieliDevice> get scannedDevices => _bleManager.scannedDevices; |
||||
|
RxBool get isScanning => _bleManager.isScanning; |
||||
|
|
||||
|
// 当前连接地址(响应式) |
||||
|
RxInt get connectionStateRx => _bleManager.connectionStateRx; |
||||
|
|
||||
// 或者使用这种方式 |
String? get currentDeviceAddress => _bleManager.currentDeviceAddress; |
||||
// final Rx<Map<int, List<UserDevice>>> devices = Rx<Map<int, List<UserDevice>>>({}); |
|
||||
|
// 正在连接的目标地址(用于 tile UI 反馈) |
||||
|
final RxnString connectingAddress = RxnString(); |
||||
|
|
||||
@override |
@override |
||||
void onInit() { |
void onInit() { |
||||
super.onInit(); |
super.onInit(); |
||||
print("初始化设备管理1"); |
|
||||
_bleManager = Get.find<BleManager>(); |
_bleManager = Get.find<BleManager>(); |
||||
print("初始化设备管理2"); |
Future.microtask(startScan); |
||||
// 在初始化时加载设备数据 |
|
||||
_loadDevices(); |
|
||||
} |
} |
||||
|
|
||||
// 加载设备数据 |
@override |
||||
void _loadDevices() async { |
void onClose() { |
||||
Logger.info("加载设备"); |
_bleManager.stopScan(); |
||||
final response = await Api.getdevices(); |
super.onClose(); |
||||
if (response != null) { |
} |
||||
if (response['devices'] != null) { |
|
||||
var devices = (response['devices'] as List<dynamic>) |
Future<void> startScan() async { |
||||
.map((e) => UserDevice.fromJson(e as Map<String, dynamic>)) |
print('[BLE] [SCAN] DevicesController.startScan 被调用'); |
||||
.toList(); |
final granted = await _ensureBluetoothPermission(); |
||||
User.instance.devices = devices; |
print('[BLE] [SCAN] 权限申请结果: granted=$granted'); |
||||
} else { |
if (!granted) { |
||||
User.instance.devices = []; |
Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr); |
||||
} |
return; |
||||
Logger.info("加载设备 ${User.instance.devices.length}"); |
|
||||
} |
} |
||||
devices.clear(); |
final ok = await _bleManager.startScan(); |
||||
for (var device in User.instance.devices) { |
if (!ok) { |
||||
if (!devices.containsKey(device.devicetype)) { |
final err = _bleManager.lastScanError ?? 'unknown'; |
||||
devices[device.devicetype] = []; |
print('[BLE] [SCAN] 扫描启动失败: $err'); |
||||
} |
Get.snackbar('error'.tr, '${'startScanFailed'.tr}: $err'); |
||||
devices[device.devicetype]!.add(device); |
|
||||
} |
} |
||||
devices.refresh(); |
|
||||
} |
} |
||||
|
|
||||
@override |
Future<void> stopScan() async { |
||||
void onClose() { |
await _bleManager.stopScan(); |
||||
super.onClose(); |
|
||||
// onClose 中不应该修改数据,这里应该是清理资源 |
|
||||
} |
} |
||||
|
|
||||
void unbindearphone(UserDevice device) async { |
Future<void> connect(JieliDevice device) async { |
||||
final response = await Api.unbinddevice({ |
if (connectingAddress.value != null) { |
||||
"id": device.id, |
Logger.info('连接进行中,忽略重复点击: ${device.address}'); |
||||
}); |
return; |
||||
Logger.info("解绑设备 $response"); |
} |
||||
if (response != null) { |
// 已连上同一台设备 → 直接进首页,避免卡在「连接中」对话框 |
||||
// 响应式更新 |
if (_bleManager.isConnected && |
||||
User.instance.devices.remove(device); |
_bleManager.currentDeviceAddress == device.address) { |
||||
devices.clear(); |
Logger.info('设备已处于连接状态,直接进入首页: ${device.address}'); |
||||
for (var device in User.instance.devices) { |
Get.offAllNamed(Routes.home); |
||||
if (!devices.containsKey(device.devicetype)) { |
return; |
||||
devices[device.devicetype] = []; |
} |
||||
|
|
||||
|
Logger.info('连接设备: ${device.name} ${device.address}'); |
||||
|
connectingAddress.value = device.address; |
||||
|
await _bleManager.stopScan(); |
||||
|
|
||||
|
// 必须 BLE 连接 OK + RCSP init 成功 才算「真正连上」。 |
||||
|
// 任一环节失败立即判失败,避免 dialog 关掉之后又被断开。 |
||||
|
final completer = Completer<bool>(); |
||||
|
bool gotConnectionOk = false; |
||||
|
|
||||
|
final evtSub = Jielihome.instance.events.listen((evt) { |
||||
|
if (evt is ConnectionStateEvent) { |
||||
|
if (evt.address != device.address) return; |
||||
|
if (evt.state == ConnectionStateEvent.connectionOk) { |
||||
|
gotConnectionOk = true; |
||||
|
} else if (evt.state == ConnectionStateEvent.connectionDisconnect) { |
||||
|
if (!completer.isCompleted) completer.complete(false); |
||||
|
} |
||||
|
} else if (evt is RcspInitEvent) { |
||||
|
if (evt.address != device.address) return; |
||||
|
if (evt.success && gotConnectionOk) { |
||||
|
if (!completer.isCompleted) completer.complete(true); |
||||
|
} else if (!evt.success) { |
||||
|
if (!completer.isCompleted) completer.complete(false); |
||||
} |
} |
||||
devices[device.devicetype]!.add(device); |
|
||||
} |
} |
||||
devices.refresh(); |
}); |
||||
Logger.info( |
|
||||
"解绑设备 ${device.devicemac} 当前使用设备:${_bleManager.currentDeviceAddress}"); |
_showConnectingDialog(device); |
||||
if (device.devicemac == _bleManager.currentDeviceAddress) { |
|
||||
Logger.info("解绑设备 $device 重新匹配!"); |
try { |
||||
User.instance.removeUserDevice(device.id); |
await _bleManager.connectDevice(device); |
||||
_bleManager.clearPairedDevices(); |
final ok = await completer.future |
||||
_bleManager.disconnect(); |
.timeout(const Duration(seconds: 20), onTimeout: () => false); |
||||
await Future.delayed(const Duration(seconds: 1)); |
|
||||
Get.toNamed(Routes.pairing); |
if (Get.isDialogOpen ?? false) Get.back(); |
||||
|
|
||||
|
if (ok) { |
||||
|
Get.offAllNamed(Routes.home); |
||||
|
} else { |
||||
|
Get.snackbar('error'.tr, 'connectionFailure'.tr); |
||||
} |
} |
||||
return; |
} catch (e) { |
||||
} else { |
Logger.error('连接异常: $e'); |
||||
return; |
if (Get.isDialogOpen ?? false) Get.back(); |
||||
|
Get.snackbar('error'.tr, 'connectionFailure'.tr); |
||||
|
} finally { |
||||
|
await evtSub.cancel(); |
||||
|
connectingAddress.value = null; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// 添加设备 |
void _showConnectingDialog(JieliDevice device) { |
||||
void addDevice(UserDevice device) { |
Get.dialog( |
||||
if (!devices.containsKey(device.devicetype)) { |
PopScope( |
||||
devices[device.devicetype] = []; |
canPop: false, |
||||
} |
child: Dialog( |
||||
devices[device.devicetype]!.add(device); |
backgroundColor: Colors.transparent, |
||||
devices.refresh(); // 触发UI更新 |
elevation: 0, |
||||
|
insetPadding: EdgeInsets.symmetric(horizontal: 40.w), |
||||
|
child: Container( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 28.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Get.isDarkMode |
||||
|
? const Color(0xFF1C1C1E) |
||||
|
: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(16.r), |
||||
|
), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 44.w, |
||||
|
height: 44.w, |
||||
|
child: const CircularProgressIndicator( |
||||
|
strokeWidth: 3, |
||||
|
valueColor: |
||||
|
AlwaysStoppedAnimation<Color>(Color(0xFF0066CC)), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 18.h), |
||||
|
Text( |
||||
|
'connectingDevice'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 15.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Get.isDarkMode ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 6.h), |
||||
|
Text( |
||||
|
device.name.isEmpty ? device.address : device.name, |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12.sp, |
||||
|
color: Get.isDarkMode ? Colors.white54 : Colors.black54, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
barrierDismissible: false, |
||||
|
); |
||||
} |
} |
||||
|
|
||||
// 获取指定类型的设备列表 |
Future<void> disconnect() async { |
||||
List<UserDevice> getDevicesByType(int deviceType) { |
await _bleManager.disconnect(); |
||||
return devices[deviceType] ?? []; |
} |
||||
|
|
||||
|
bool isConnected(JieliDevice device) { |
||||
|
return _bleManager.isConnected && |
||||
|
_bleManager.currentDeviceAddress == device.address; |
||||
|
} |
||||
|
|
||||
|
Future<bool> _ensureBluetoothPermission() async { |
||||
|
try { |
||||
|
// Android 12+:BLUETOOTH_SCAN 已声明 neverForLocation,所以不强制要求定位。 |
||||
|
// 但实际不少 ROM 仍要求定位才能扫到 BLE 设备 —— 同时申请,谁拒绝就报错。 |
||||
|
final results = await [ |
||||
|
Permission.bluetoothScan, |
||||
|
Permission.bluetoothConnect, |
||||
|
Permission.locationWhenInUse, |
||||
|
].request(); |
||||
|
print('[BLE] [SCAN] 权限申请详情: $results'); |
||||
|
// 至少 scan + connect 必须授权;location 缺失警告但不一定阻塞 |
||||
|
final scanOk = results[Permission.bluetoothScan]?.isGranted ?? false; |
||||
|
final connectOk = results[Permission.bluetoothConnect]?.isGranted ?? false; |
||||
|
return scanOk && connectOk; |
||||
|
} catch (e) { |
||||
|
Logger.error('请求蓝牙权限失败: $e'); |
||||
|
return false; |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
|||||
File diff suppressed because it is too large
@ -1,9 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/pairing_controller.dart'; |
|
||||
|
|
||||
class PairingBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
Get.put<PairingController>(PairingController()); |
|
||||
} |
|
||||
} |
|
||||
File diff suppressed because it is too large
@ -1,879 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
import '../controllers/pairing_controller.dart'; |
|
||||
|
|
||||
class PairingView extends StatefulWidget { |
|
||||
const PairingView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
State<PairingView> createState() => _PairingViewState(); |
|
||||
} |
|
||||
|
|
||||
class _PairingViewState extends State<PairingView> |
|
||||
with TickerProviderStateMixin { |
|
||||
late AnimationController _pulseController; |
|
||||
late AnimationController _rotationController; |
|
||||
late Animation<double> _pulseAnimation; |
|
||||
late Animation<double> _rotationAnimation; |
|
||||
|
|
||||
PairingController get controller => Get.find<PairingController>(); |
|
||||
|
|
||||
@override |
|
||||
void initState() { |
|
||||
super.initState(); |
|
||||
|
|
||||
// 脉搏动画控制器 |
|
||||
_pulseController = AnimationController( |
|
||||
duration: const Duration(milliseconds: 1500), |
|
||||
vsync: this, |
|
||||
); |
|
||||
|
|
||||
// 旋转动画控制器 |
|
||||
_rotationController = AnimationController( |
|
||||
duration: const Duration(milliseconds: 2000), |
|
||||
vsync: this, |
|
||||
); |
|
||||
|
|
||||
// 脉搏动画 |
|
||||
_pulseAnimation = Tween<double>( |
|
||||
begin: 0.8, |
|
||||
end: 1.2, |
|
||||
).animate(CurvedAnimation( |
|
||||
parent: _pulseController, |
|
||||
curve: Curves.easeInOut, |
|
||||
)); |
|
||||
|
|
||||
// 旋转动画 |
|
||||
_rotationAnimation = Tween<double>( |
|
||||
begin: 0.0, |
|
||||
end: 1.0, |
|
||||
).animate(CurvedAnimation( |
|
||||
parent: _rotationController, |
|
||||
curve: Curves.linear, |
|
||||
)); |
|
||||
|
|
||||
// 开始动画 |
|
||||
_pulseController.repeat(reverse: true); |
|
||||
_rotationController.repeat(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void dispose() { |
|
||||
_pulseController.dispose(); |
|
||||
_rotationController.dispose(); |
|
||||
super.dispose(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|
||||
|
|
||||
return Scaffold( |
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|
||||
body: SafeArea( |
|
||||
child: Column( |
|
||||
children: [ |
|
||||
// 固定顶部区域 - 不受下方内容影响 |
|
||||
_buildFixedTopSection(isDarkMode), |
|
||||
|
|
||||
// 可滚动的动态内容区域 |
|
||||
Expanded( |
|
||||
child: _buildDynamicContentSection(isDarkMode), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 固定顶部区域 |
|
||||
Widget _buildFixedTopSection(bool isDarkMode) { |
|
||||
return Padding( |
|
||||
padding: EdgeInsets.only(top: 60.h, left: 24.w, right: 24.w), |
|
||||
child: Column( |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
children: [ |
|
||||
// 标题部分 |
|
||||
_buildHeader(isDarkMode), |
|
||||
SizedBox(height: 20.h), |
|
||||
|
|
||||
_buildDeviceAnimation(isDarkMode), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 动态内容区域 |
|
||||
Widget _buildDynamicContentSection(bool isDarkMode) { |
|
||||
return SingleChildScrollView( |
|
||||
physics: const BouncingScrollPhysics(), |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w), |
|
||||
child: Column( |
|
||||
mainAxisAlignment: MainAxisAlignment.start, |
|
||||
crossAxisAlignment: CrossAxisAlignment.center, |
|
||||
children: [ |
|
||||
SizedBox(height: 30.h), |
|
||||
|
|
||||
// 状态信息 |
|
||||
_buildStatusInfo(isDarkMode), |
|
||||
|
|
||||
SizedBox(height: 40.h), |
|
||||
|
|
||||
// 底部按钮 |
|
||||
_buildActionButtons(isDarkMode), |
|
||||
|
|
||||
SizedBox(height: 20.h), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 头部标题 |
|
||||
Widget _buildHeader(bool isDarkMode) { |
|
||||
return Column( |
|
||||
children: [ |
|
||||
Text( |
|
||||
'Voitrans', |
|
||||
style: TextStyle( |
|
||||
fontSize: 30.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 8.h), |
|
||||
Text( |
|
||||
'homeSlogan'.tr, // 连接世界,超越声音 |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
color: isDarkMode ? Colors.white60 : Colors.black54, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 设备动画 - 根据状态显示不同的动画效果 |
|
||||
Widget _buildDeviceAnimation(bool isDarkMode) { |
|
||||
return Obx(() { |
|
||||
final state = controller.currentState; |
|
||||
final isLoading = controller.isLoading.value; |
|
||||
// 根据状态决定动画颜色 |
|
||||
Color primaryColor = const Color(0xFF0066CC); |
|
||||
Color secondaryColor = const Color(0xFF5E5CE6); |
|
||||
|
|
||||
// 错误状态使用红色 |
|
||||
if (state == PairingState.error) { |
|
||||
primaryColor = Colors.red; |
|
||||
secondaryColor = Colors.redAccent; |
|
||||
} |
|
||||
// 成功状态使用绿色 |
|
||||
else if (state == PairingState.completed) { |
|
||||
primaryColor = Colors.green; |
|
||||
secondaryColor = Colors.greenAccent; |
|
||||
} |
|
||||
|
|
||||
// 待机状态的特殊处理 |
|
||||
if (state == PairingState.idle || state == PairingState.waitingClassic) { |
|
||||
return SizedBox( |
|
||||
width: 240.w, |
|
||||
height: 240.w, |
|
||||
child: Stack( |
|
||||
alignment: Alignment.center, |
|
||||
children: [ |
|
||||
// 静态背景圆圈 |
|
||||
Container( |
|
||||
width: 200.w, |
|
||||
height: 200.w, |
|
||||
decoration: BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
begin: Alignment.topLeft, |
|
||||
end: Alignment.bottomRight, |
|
||||
colors: [ |
|
||||
primaryColor.withValues(alpha: 0.1), |
|
||||
secondaryColor.withValues(alpha: 0.1), |
|
||||
], |
|
||||
), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
), |
|
||||
// 设备图标 |
|
||||
Container( |
|
||||
width: 120.w, |
|
||||
height: 120.w, |
|
||||
decoration: BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
begin: Alignment.topLeft, |
|
||||
end: Alignment.bottomRight, |
|
||||
colors: [primaryColor, secondaryColor], |
|
||||
), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
child: Icon( |
|
||||
Icons.headphones, |
|
||||
color: Colors.white, |
|
||||
size: 60.w, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return SizedBox( |
|
||||
width: 240.w, |
|
||||
height: 240.w, |
|
||||
child: Stack( |
|
||||
alignment: Alignment.center, |
|
||||
children: [ |
|
||||
// 连续脉搏动画 - 只在加载时显示 |
|
||||
if (isLoading) _buildPulseAnimation(primaryColor, secondaryColor), |
|
||||
|
|
||||
// 特殊的安全连接动画 - 只在建立安全连接时显示 |
|
||||
if (state == PairingState.waitingBle) |
|
||||
_buildSecureConnectionAnimation(primaryColor, secondaryColor), |
|
||||
|
|
||||
// 设备关联动画 - 只在关联设备时显示 |
|
||||
if (state == PairingState.associating) |
|
||||
_buildAssociatingAnimation(primaryColor, secondaryColor), |
|
||||
|
|
||||
// 旋转边框 - 只在加载时显示 |
|
||||
if (isLoading) _buildRotatingBorder(primaryColor), |
|
||||
|
|
||||
// 设备图标 |
|
||||
Container( |
|
||||
width: 120.w, |
|
||||
height: 120.w, |
|
||||
decoration: BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
begin: Alignment.topLeft, |
|
||||
end: Alignment.bottomRight, |
|
||||
colors: [primaryColor, secondaryColor], |
|
||||
), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
child: Icon( |
|
||||
_getIconForState(state), |
|
||||
color: Colors.white, |
|
||||
size: 60.w, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 构建连续脉搏动画 |
|
||||
Widget _buildPulseAnimation(Color primaryColor, Color secondaryColor) { |
|
||||
return AnimatedBuilder( |
|
||||
animation: _pulseAnimation, |
|
||||
builder: (context, child) { |
|
||||
final value = _pulseAnimation.value; |
|
||||
final opacity = |
|
||||
0.4 - (_pulseController.value * 0.3); // 0.4 到 0.1 的透明度变化 |
|
||||
|
|
||||
return Container( |
|
||||
width: 200.w * value, |
|
||||
height: 200.w * value, |
|
||||
decoration: BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
begin: Alignment.topLeft, |
|
||||
end: Alignment.bottomRight, |
|
||||
colors: [ |
|
||||
primaryColor.withValues(alpha: opacity), |
|
||||
secondaryColor.withValues(alpha: opacity), |
|
||||
], |
|
||||
), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建旋转边框动画 |
|
||||
Widget _buildRotatingBorder(Color primaryColor) { |
|
||||
return AnimatedBuilder( |
|
||||
animation: _rotationAnimation, |
|
||||
builder: (context, child) { |
|
||||
return Transform.rotate( |
|
||||
angle: _rotationAnimation.value * 2 * 3.14159, // 完整旋转 |
|
||||
child: SizedBox( |
|
||||
width: 140.w, |
|
||||
height: 140.w, |
|
||||
child: CircularProgressIndicator( |
|
||||
strokeWidth: 3.w, |
|
||||
valueColor: AlwaysStoppedAnimation<Color>(primaryColor), |
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.2), |
|
||||
value: 0.7, // 显示70%的圆弧 |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建安全连接特殊动画 - 多层波纹效果 |
|
||||
Widget _buildSecureConnectionAnimation( |
|
||||
Color primaryColor, Color secondaryColor) { |
|
||||
return Stack( |
|
||||
alignment: Alignment.center, |
|
||||
children: [ |
|
||||
// 第一层波纹 |
|
||||
AnimatedBuilder( |
|
||||
animation: _pulseAnimation, |
|
||||
builder: (context, child) { |
|
||||
final value = _pulseAnimation.value; |
|
||||
final opacity = 0.3 - (_pulseController.value * 0.2); |
|
||||
|
|
||||
return Container( |
|
||||
width: 180.w * value, |
|
||||
height: 180.w * value, |
|
||||
decoration: BoxDecoration( |
|
||||
border: Border.all( |
|
||||
color: primaryColor.withValues(alpha: opacity), |
|
||||
width: 2.w, |
|
||||
), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
|
|
||||
// 第二层波纹 - 延迟动画 |
|
||||
AnimatedBuilder( |
|
||||
animation: _pulseAnimation, |
|
||||
builder: (context, child) { |
|
||||
final delayedValue = (_pulseController.value + 0.3) % 1.0; |
|
||||
final value = 0.8 + (delayedValue * 0.4); |
|
||||
final opacity = 0.25 - (delayedValue * 0.15); |
|
||||
|
|
||||
return Container( |
|
||||
width: 160.w * value, |
|
||||
height: 160.w * value, |
|
||||
decoration: BoxDecoration( |
|
||||
border: Border.all( |
|
||||
color: secondaryColor.withValues(alpha: opacity), |
|
||||
width: 1.5.w, |
|
||||
), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
|
|
||||
// 第三层波纹 - 更大延迟 |
|
||||
AnimatedBuilder( |
|
||||
animation: _pulseAnimation, |
|
||||
builder: (context, child) { |
|
||||
final delayedValue = (_pulseController.value + 0.6) % 1.0; |
|
||||
final value = 0.8 + (delayedValue * 0.4); |
|
||||
final opacity = 0.2 - (delayedValue * 0.1); |
|
||||
|
|
||||
return Container( |
|
||||
width: 140.w * value, |
|
||||
height: 140.w * value, |
|
||||
decoration: BoxDecoration( |
|
||||
border: Border.all( |
|
||||
color: primaryColor.withValues(alpha: opacity), |
|
||||
width: 1.w, |
|
||||
), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 根据状态获取图标 |
|
||||
IconData _getIconForState(PairingState state) { |
|
||||
switch (state) { |
|
||||
case PairingState.completed: |
|
||||
return Icons.check_circle; |
|
||||
case PairingState.associating: |
|
||||
return Icons.link; |
|
||||
case PairingState.error: |
|
||||
final errorType = controller.errorType; |
|
||||
switch (errorType) { |
|
||||
case ErrorType.bluetoothOff: |
|
||||
case ErrorType.noPermission: |
|
||||
return Icons.bluetooth_disabled; |
|
||||
case ErrorType.noClassicDevice: |
|
||||
case ErrorType.deviceNotMatch: |
|
||||
return Icons.headphones_outlined; |
|
||||
case ErrorType.associationFailed: |
|
||||
return Icons.link_off; |
|
||||
default: |
|
||||
return Icons.error_outline; |
|
||||
} |
|
||||
default: |
|
||||
return Icons.headphones; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 状态信息 |
|
||||
Widget _buildStatusInfo(bool isDarkMode) { |
|
||||
return Obx(() { |
|
||||
final status = controller.statusText.value; |
|
||||
final state = controller.currentState; |
|
||||
final device = controller.currentDevice; |
|
||||
|
|
||||
return Column( |
|
||||
children: [ |
|
||||
// 状态文本 - 更大更醒目 |
|
||||
Text( |
|
||||
status, |
|
||||
style: TextStyle( |
|
||||
fontSize: 20.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
), |
|
||||
textAlign: TextAlign.center, |
|
||||
), |
|
||||
|
|
||||
SizedBox(height: 10.h), |
|
||||
|
|
||||
// 子状态提示文本 - 根据状态显示不同提示 |
|
||||
Text( |
|
||||
_getSubtitleForState(state), |
|
||||
style: TextStyle( |
|
||||
fontSize: 14.sp, |
|
||||
color: isDarkMode ? Colors.white60 : Colors.grey[600], |
|
||||
), |
|
||||
textAlign: TextAlign.center, |
|
||||
), |
|
||||
|
|
||||
// 当前连接设备(有设备时才显示) |
|
||||
if (device != null && |
|
||||
(state == PairingState.verifying || |
|
||||
state == PairingState.binding || |
|
||||
state == PairingState.waitingBle || |
|
||||
state == PairingState.completed)) ...[ |
|
||||
SizedBox(height: 24.h), |
|
||||
_buildConnectedDevice(device, isDarkMode), |
|
||||
], |
|
||||
], |
|
||||
); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 根据状态获取副标题 |
|
||||
String _getSubtitleForState(PairingState state) { |
|
||||
switch (state) { |
|
||||
case PairingState.idle: |
|
||||
return 'readyToPair'.tr; // 准备开始配对 |
|
||||
case PairingState.checking: |
|
||||
return 'preparingPairingEnvironment'.tr; // 正在准备配对环境... |
|
||||
case PairingState.waitingClassic: |
|
||||
return 'connectDeepSoundViaSystemBluetooth'.tr; // 请先通过系统蓝牙连接Voitrans设备 |
|
||||
case PairingState.verifying: |
|
||||
return 'verifyingDevice'.tr; // 正在验证设备... |
|
||||
case PairingState.binding: |
|
||||
return 'deviceBindingConfirm'.tr; // 设备绑定确认... |
|
||||
case PairingState.associating: |
|
||||
return 'establishingDeviceAssociation'.tr; // 正在建立设备关联... |
|
||||
case PairingState.waitingBle: |
|
||||
return 'waitingCompletePairing'.tr; // 请稍候,正在完成配对... |
|
||||
case PairingState.completed: |
|
||||
return 'pairingSuccessRedirecting'.tr; // 配对成功!即将跳转到主页... |
|
||||
case PairingState.error: |
|
||||
final errorType = controller.errorType; |
|
||||
switch (errorType) { |
|
||||
case ErrorType.bluetoothOff: |
|
||||
return 'connectDeepSoundViaSystemBluetooth'.tr; // 蓝牙已关闭,请打开蓝牙 |
|
||||
case ErrorType.noPermission: |
|
||||
return 'lackPermissions'.tr; // 缺少蓝牙权限和位置权限 |
|
||||
case ErrorType.noClassicDevice: |
|
||||
return 'connectDeepSoundViaSystemBluetooth' |
|
||||
.tr; // 请先通过系统蓝牙连接Voitrans设备 |
|
||||
case ErrorType.deviceNotMatch: |
|
||||
return 'ensureConnectedDeepSoundDevice'.tr.trArgs( |
|
||||
[controller.currentDevice?.name ?? '']); // 请确保连接的是Voitrans设备 |
|
||||
case ErrorType.deviceBound: |
|
||||
return 'deviceAlreadyBoundToOtherAccount'.tr; // 该设备已被其他账号绑定 |
|
||||
case ErrorType.associationFailed: |
|
||||
return 'deviceAssociationFailed'.tr; // 设备关联失败,请重试 |
|
||||
case ErrorType.bleTimeout: |
|
||||
return 'scanTimeoutEnsureDeviceNearby'.tr; // 扫描超时,请确保设备在附近 |
|
||||
case ErrorType.networkError: |
|
||||
return 'networkError'.tr; // 网络错误 |
|
||||
case ErrorType.authnFailed: |
|
||||
return 'authorizationFailedCheckCode'.tr; // 授权失败,请检查授权码是否正确 |
|
||||
default: |
|
||||
return 'pairingFailedRetry'.tr; // 配对失败,请重试 |
|
||||
} |
|
||||
default: |
|
||||
return ''; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 已连接设备信息 |
|
||||
Widget _buildConnectedDevice(device, bool isDarkMode) { |
|
||||
return SizedBox( |
|
||||
width: double.infinity, |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
'currentlyConnected'.tr, // 当前已连接: |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: isDarkMode ? Colors.white70 : Colors.black87, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 8.h), |
|
||||
_buildDeviceItem(device, isDarkMode), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 设备项 |
|
||||
Widget _buildDeviceItem(device, bool isDarkMode) { |
|
||||
return Container( |
|
||||
margin: EdgeInsets.only(bottom: 8.h), |
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100], |
|
||||
borderRadius: BorderRadius.circular(8.r), |
|
||||
), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Icon( |
|
||||
Icons.headphones, |
|
||||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|
||||
size: 24.w, |
|
||||
), |
|
||||
SizedBox(width: 12.w), |
|
||||
Expanded( |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
device.name ?? 'unknown'.tr, |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w500, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 4.h), |
|
||||
Text( |
|
||||
device.address ?? '', |
|
||||
style: TextStyle( |
|
||||
fontSize: 12.sp, |
|
||||
color: isDarkMode ? Colors.white54 : Colors.black45, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
Icon( |
|
||||
Icons.bluetooth_connected, |
|
||||
color: Colors.green, |
|
||||
size: 20.w, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 获取连接按钮图标 |
|
||||
IconData _getConnectButtonIcon() { |
|
||||
final buttonText = controller.connectButtonText.value; |
|
||||
if (buttonText.contains('bluetooth'.tr)) { |
|
||||
return Icons.bluetooth; |
|
||||
} else if (buttonText.contains('settings'.tr)) { |
|
||||
return Icons.settings; |
|
||||
} |
|
||||
return Icons.bluetooth_searching; |
|
||||
} |
|
||||
|
|
||||
// 操作按钮 |
|
||||
Widget _buildActionButtons(bool isDarkMode) { |
|
||||
return Obx(() { |
|
||||
final showConnectButton = controller.showConnectButton.value; |
|
||||
final showRetryButton = controller.showRetryButton.value; |
|
||||
final showBindButton = controller.showBindButton.value; |
|
||||
final showRetryChectButton = controller.showRetryChectButton.value; |
|
||||
final showSkipButton = controller.showSkipButton.value; |
|
||||
final showAutoCodeInput = controller.showAutoCodeInput.value; |
|
||||
final showSkipBindingButton = controller.showSkipBindingButton.value; |
|
||||
return SizedBox( |
|
||||
width: double.infinity, |
|
||||
child: Column( |
|
||||
children: [ |
|
||||
// 显示连接按钮 |
|
||||
if (showConnectButton) |
|
||||
ElevatedButton( |
|
||||
onPressed: () => controller.onConnectTap(), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: const Color(0xFF0066CC), |
|
||||
foregroundColor: Colors.white, |
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h), |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(16.r), |
|
||||
), |
|
||||
elevation: 0, |
|
||||
minimumSize: Size(double.infinity, 56.h), |
|
||||
), |
|
||||
child: Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: [ |
|
||||
Icon(_getConnectButtonIcon(), size: 20.w), |
|
||||
SizedBox(width: 8.w), |
|
||||
Text( |
|
||||
controller.connectButtonText.value, |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
if (showBindButton) |
|
||||
ElevatedButton( |
|
||||
onPressed: () => controller.confirmBindDevice(), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.blue, |
|
||||
foregroundColor: Colors.white, |
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h), |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(16.r), |
|
||||
), |
|
||||
elevation: 0, |
|
||||
minimumSize: Size(double.infinity, 56.h), |
|
||||
), |
|
||||
child: Text( |
|
||||
"bind".tr, // 绑定 |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 12.h), |
|
||||
// 显示重试按钮 |
|
||||
if (showRetryButton) |
|
||||
ElevatedButton( |
|
||||
onPressed: () => controller.onRetryTap(), |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Colors.red, |
|
||||
foregroundColor: Colors.white, |
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h), |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(16.r), |
|
||||
), |
|
||||
elevation: 0, |
|
||||
minimumSize: Size(double.infinity, 56.h), |
|
||||
), |
|
||||
child: Text( |
|
||||
controller.retryButtonText.value, |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
if (showAutoCodeInput) |
|
||||
Column( |
|
||||
children: [ |
|
||||
// 输入框 |
|
||||
SizedBox( |
|
||||
width: double.infinity, |
|
||||
height: 48.h, |
|
||||
child: TextField( |
|
||||
controller: controller |
|
||||
.codeInputController, // 你可以在 Controller 中声明这个 |
|
||||
decoration: InputDecoration( |
|
||||
hintText: 'enterCode'.tr, // 请输入代码 |
|
||||
border: OutlineInputBorder( |
|
||||
borderRadius: BorderRadius.circular(12.r), |
|
||||
), |
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16.w), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 12.h), |
|
||||
SizedBox( |
|
||||
width: double.infinity, |
|
||||
height: 48.h, |
|
||||
child: ElevatedButton( |
|
||||
onPressed: controller.onAutoCodeInputTap, |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Theme.of(Get.context!).primaryColor, |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(12.r), |
|
||||
), |
|
||||
elevation: 0, |
|
||||
), |
|
||||
child: Text( |
|
||||
'authorizeBind'.tr, // 授权绑定 |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: Colors.white, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
// 自动输入代码按钮 |
|
||||
|
|
||||
// 跳过按钮 |
|
||||
if (showSkipButton) ...[ |
|
||||
SizedBox(height: 12.h), |
|
||||
TextButton( |
|
||||
onPressed: () => controller.onSkipTap(), |
|
||||
style: TextButton.styleFrom( |
|
||||
foregroundColor: const Color(0xFF0066CC), |
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|
||||
minimumSize: Size(double.infinity, 48.h), |
|
||||
), |
|
||||
child: Text( |
|
||||
'talkLater'.tr, // 稍后再说 |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w500, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
|
|
||||
// 跳过绑定按钮 |
|
||||
if (showSkipBindingButton) ...[ |
|
||||
SizedBox(height: 12.h), |
|
||||
TextButton( |
|
||||
onPressed: () => controller.onSkipBindingTap(), |
|
||||
style: TextButton.styleFrom( |
|
||||
foregroundColor: const Color(0xFF0066CC), |
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|
||||
minimumSize: Size(double.infinity, 48.h), |
|
||||
), |
|
||||
child: Text( |
|
||||
'skipBinding'.tr, // 跳过绑定 |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w500, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
//重新检测 |
|
||||
if (showRetryChectButton) ...[ |
|
||||
SizedBox(height: 12.h), |
|
||||
TextButton( |
|
||||
onPressed: () => controller.onRetryTap(), |
|
||||
style: TextButton.styleFrom( |
|
||||
foregroundColor: const Color(0xFF0066CC), |
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|
||||
minimumSize: Size(double.infinity, 48.h), |
|
||||
), |
|
||||
child: Text( |
|
||||
'rescan'.tr, // 重新扫描 |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w500, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 构建设备关联动画 - 连接线条效果 |
|
||||
Widget _buildAssociatingAnimation(Color primaryColor, Color secondaryColor) { |
|
||||
return AnimatedBuilder( |
|
||||
animation: _rotationAnimation, |
|
||||
builder: (context, child) { |
|
||||
return Stack( |
|
||||
alignment: Alignment.center, |
|
||||
children: [ |
|
||||
// 旋转的连接线条 |
|
||||
Transform.rotate( |
|
||||
angle: _rotationAnimation.value * 2 * 3.14159, |
|
||||
child: Container( |
|
||||
width: 160.w, |
|
||||
height: 160.w, |
|
||||
decoration: BoxDecoration( |
|
||||
shape: BoxShape.circle, |
|
||||
border: Border.all( |
|
||||
color: primaryColor.withValues(alpha: 0.6), |
|
||||
width: 2.w, |
|
||||
), |
|
||||
), |
|
||||
child: Stack( |
|
||||
children: [ |
|
||||
// 连接点1 |
|
||||
Positioned( |
|
||||
top: 10.w, |
|
||||
left: 80.w - 4.w, |
|
||||
child: Container( |
|
||||
width: 8.w, |
|
||||
height: 8.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: primaryColor, |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
// 连接点2 |
|
||||
Positioned( |
|
||||
bottom: 10.w, |
|
||||
left: 80.w - 4.w, |
|
||||
child: Container( |
|
||||
width: 8.w, |
|
||||
height: 8.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: secondaryColor, |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
// 连接点3 |
|
||||
Positioned( |
|
||||
top: 80.w - 4.w, |
|
||||
left: 10.w, |
|
||||
child: Container( |
|
||||
width: 8.w, |
|
||||
height: 8.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: primaryColor, |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
// 连接点4 |
|
||||
Positioned( |
|
||||
top: 80.w - 4.w, |
|
||||
right: 10.w, |
|
||||
child: Container( |
|
||||
width: 8.w, |
|
||||
height: 8.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: secondaryColor, |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,304 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
import '../controllers/pairing_controller.dart'; |
|
||||
|
|
||||
/// 配对视图 - 简化版本 |
|
||||
class PairingView extends GetView<PairingController> { |
|
||||
const PairingView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|
||||
|
|
||||
return Scaffold( |
|
||||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|
||||
body: SafeArea( |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.all(24.w), |
|
||||
child: Column( |
|
||||
children: [ |
|
||||
// 顶部标题 |
|
||||
_buildHeader(isDarkMode), |
|
||||
|
|
||||
// 中间内容 |
|
||||
Expanded( |
|
||||
child: Center( |
|
||||
child: Obx(() => _buildContent(isDarkMode)), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
// 底部按钮 |
|
||||
Obx(() => _buildButtons(isDarkMode)), |
|
||||
|
|
||||
SizedBox(height: 20.h), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/// 构建标题 |
|
||||
Widget _buildHeader(bool isDarkMode) { |
|
||||
return Column( |
|
||||
children: [ |
|
||||
Text( |
|
||||
'devicePairing'.tr, // 设备配对 |
|
||||
style: TextStyle( |
|
||||
fontSize: 24.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 8.h), |
|
||||
Text( |
|
||||
'connectYourDeepSoundHeadset'.tr, // 连接您的 Voitrans 耳机 |
|
||||
style: TextStyle( |
|
||||
fontSize: 14.sp, |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 40.h), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/// 构建主要内容 |
|
||||
Widget _buildContent(bool isDarkMode) { |
|
||||
final state = controller.currentState; |
|
||||
final isLoading = controller.isLoading.value; |
|
||||
|
|
||||
return Column( |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
children: [ |
|
||||
// 状态图标 |
|
||||
_buildStateIcon(state, isLoading, isDarkMode), |
|
||||
|
|
||||
SizedBox(height: 40.h), |
|
||||
|
|
||||
// 状态文本 |
|
||||
Text( |
|
||||
controller.statusText.value, |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
), |
|
||||
textAlign: TextAlign.center, |
|
||||
), |
|
||||
|
|
||||
// 错误详情 |
|
||||
if (state == PairingState.error) ...[ |
|
||||
SizedBox(height: 16.h), |
|
||||
Container( |
|
||||
padding: EdgeInsets.all(16.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.red.withValues(alpha: 0.1), |
|
||||
borderRadius: BorderRadius.circular(12.r), |
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)), |
|
||||
), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Icon( |
|
||||
Icons.error_outline, |
|
||||
color: Colors.red, |
|
||||
size: 20.sp, |
|
||||
), |
|
||||
SizedBox(width: 12.w), |
|
||||
Expanded( |
|
||||
child: Text( |
|
||||
controller.errorMessage, |
|
||||
style: TextStyle( |
|
||||
fontSize: 14.sp, |
|
||||
color: Colors.red, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/// 构建状态图标 |
|
||||
Widget _buildStateIcon(PairingState state, bool isLoading, bool isDarkMode) { |
|
||||
if (isLoading) { |
|
||||
return CircularProgressIndicator( |
|
||||
valueColor: AlwaysStoppedAnimation<Color>( |
|
||||
Theme.of(Get.context!).primaryColor, |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
IconData iconData; |
|
||||
Color iconColor; |
|
||||
|
|
||||
switch (state) { |
|
||||
case PairingState.idle: |
|
||||
case PairingState.checking: |
|
||||
iconData = Icons.bluetooth_searching; |
|
||||
iconColor = Colors.blue; |
|
||||
break; |
|
||||
|
|
||||
case PairingState.waitingClassic: |
|
||||
iconData = Icons.bluetooth; |
|
||||
iconColor = Colors.grey; |
|
||||
break; |
|
||||
|
|
||||
case PairingState.verifying: |
|
||||
iconData = Icons.verified_user; |
|
||||
iconColor = Colors.orange; |
|
||||
break; |
|
||||
case PairingState.binding: |
|
||||
iconData = Icons.blind; |
|
||||
iconColor = Colors.blue; |
|
||||
break; |
|
||||
case PairingState.associating: |
|
||||
iconData = Icons.link; |
|
||||
iconColor = Colors.blue; |
|
||||
break; |
|
||||
|
|
||||
case PairingState.waitingBle: |
|
||||
iconData = Icons.sync; |
|
||||
iconColor = Colors.blue; |
|
||||
break; |
|
||||
case PairingState.manualbinding: |
|
||||
iconData = Icons.blind; |
|
||||
iconColor = Colors.blue; |
|
||||
break; |
|
||||
case PairingState.completed: |
|
||||
iconData = Icons.check_circle; |
|
||||
iconColor = Colors.green; |
|
||||
break; |
|
||||
|
|
||||
case PairingState.error: |
|
||||
iconData = Icons.error; |
|
||||
iconColor = Colors.red; |
|
||||
break; |
|
||||
} |
|
||||
|
|
||||
return Container( |
|
||||
width: 100.w, |
|
||||
height: 100.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: iconColor.withValues(alpha: 0.1), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
child: Icon( |
|
||||
iconData, |
|
||||
size: 60.sp, |
|
||||
color: iconColor, |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/// 构建按钮 |
|
||||
Widget _buildButtons(bool isDarkMode) { |
|
||||
final showConnectButton = controller.showConnectButton.value; |
|
||||
final showBindButton = controller.showBindButton.value; |
|
||||
final showRetryButton = controller.showRetryButton.value; |
|
||||
final showAutoCodeInput = controller.showAutoCodeInput.value; |
|
||||
final showSkipButton = controller.showSkipButton.value; |
|
||||
|
|
||||
return Column( |
|
||||
children: [ |
|
||||
// 主要操作按钮 |
|
||||
if (showConnectButton) |
|
||||
_buildPrimaryButton( |
|
||||
'connectDevice'.tr, // 连接设备 |
|
||||
controller.onConnectTap, |
|
||||
isDarkMode, |
|
||||
), |
|
||||
|
|
||||
// 绑定按钮 |
|
||||
if (showBindButton) |
|
||||
TextButton( |
|
||||
onPressed: controller.confirmBindDevice, |
|
||||
child: Text( |
|
||||
'bind'.tr, // 绑定 |
|
||||
style: TextStyle( |
|
||||
fontSize: 14.sp, |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
if (showRetryButton) |
|
||||
_buildPrimaryButton( |
|
||||
'retry'.tr, // 重试 |
|
||||
controller.onRetryTap, |
|
||||
isDarkMode, |
|
||||
), |
|
||||
|
|
||||
SizedBox(height: 12.h), |
|
||||
|
|
||||
if (showAutoCodeInput) |
|
||||
// 自动输入代码按钮 |
|
||||
SizedBox( |
|
||||
width: double.infinity, |
|
||||
height: 48.h, |
|
||||
child: ElevatedButton( |
|
||||
onPressed: controller.onAutoCodeInputTap, |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Theme.of(Get.context!).primaryColor, |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(12.r), |
|
||||
), |
|
||||
elevation: 0, |
|
||||
), |
|
||||
child: Text( |
|
||||
'autoInputCode'.tr, // 自动输入代码 |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: Colors.white, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
// 跳过按钮 |
|
||||
if (showSkipButton) |
|
||||
TextButton( |
|
||||
onPressed: controller.onSkipTap, |
|
||||
child: Text( |
|
||||
'skip'.tr, // 跳过 |
|
||||
style: TextStyle( |
|
||||
fontSize: 14.sp, |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/// 构建主要按钮 |
|
||||
Widget _buildPrimaryButton( |
|
||||
String text, VoidCallback onPressed, bool isDarkMode) { |
|
||||
return SizedBox( |
|
||||
width: double.infinity, |
|
||||
height: 48.h, |
|
||||
child: ElevatedButton( |
|
||||
onPressed: onPressed, |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: Theme.of(Get.context!).primaryColor, |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(12.r), |
|
||||
), |
|
||||
elevation: 0, |
|
||||
), |
|
||||
child: Text( |
|
||||
text, |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: Colors.white, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,10 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/wakeup_voice_controller.dart'; |
|
||||
|
|
||||
class WakeupVoiceBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
Get.lazyPut<WakeupVoiceController>(() => WakeupVoiceController()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@ -1,543 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import 'package:dio/dio.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import '../../../data/services/ble_manager.dart'; |
|
||||
import '../../../data/services/network/api.dart'; |
|
||||
|
|
||||
class WakeupVoiceController extends GetxController { |
|
||||
// 是否启用唤醒词(原有本地词功能,可保留) |
|
||||
final RxBool enabled = true.obs; |
|
||||
|
|
||||
final BleManager bleManager = Get.find<BleManager>(); |
|
||||
// 本地自定义唤醒词(原有) |
|
||||
final RxList<String> wakeWords = <String>['你好,深声', 'Hey Voitrans'].obs; |
|
||||
final RxString inputText = ''.obs; |
|
||||
|
|
||||
// 服务端唤醒语音包列表 |
|
||||
final RxList<WakeupVoiceItem> voices = <WakeupVoiceItem>[].obs; |
|
||||
// 当前设备的唤醒语音 id 与版本(由外部设备信息设置) |
|
||||
final RxnInt currentDeviceVoiceId = RxnInt(53251); |
|
||||
final RxnString currentDeviceVoiceVersion = RxnString('1.0.0'); |
|
||||
// 当前页面选择的语音包 id |
|
||||
final RxnInt selectedVoiceId = RxnInt(); |
|
||||
|
|
||||
// 加载状态 |
|
||||
final RxBool isLoading = false.obs; |
|
||||
final RxString loadError = ''.obs; |
|
||||
|
|
||||
// 设备状态(用于页面头部展示) |
|
||||
final RxString deviceName = ''.obs; |
|
||||
final RxBool isConnected = false.obs; |
|
||||
final RxBool wakeupVersionMismatch = false.obs; |
|
||||
final RxnInt deviceWakeIdCode = RxnInt(); |
|
||||
final RxnInt deviceWakeVerCode = RxnInt(); |
|
||||
|
|
||||
// 预览/测试模式:跳过 onReady 自动拉取 |
|
||||
bool skipAutoFetch = false; |
|
||||
|
|
||||
@override |
|
||||
void onReady() { |
|
||||
super.onReady(); |
|
||||
_bindBleObservers(); |
|
||||
// 允许通过路由参数传入当前设备唤醒语音信息 |
|
||||
final args = Get.arguments; |
|
||||
if (args is Map) { |
|
||||
final id = args['wakeupVoiceId']; |
|
||||
final ver = args['wakeupVoiceVersion']; |
|
||||
if (id is int && ver is String) { |
|
||||
setCurrentDeviceWakeVoice(id: id, version: ver); |
|
||||
} |
|
||||
final name = args['deviceName']; |
|
||||
final connected = args['connected']; |
|
||||
if (name is String) deviceName.value = name; |
|
||||
if (connected is bool) isConnected.value = connected; |
|
||||
} |
|
||||
if (!skipAutoFetch) { |
|
||||
fetchVoices(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 绑定 BLE 相关的观察者 |
|
||||
/// 参数:无 |
|
||||
/// 返回:无 |
|
||||
/// 异常:发生监听绑定异常时捕获并记录日志,不抛出 |
|
||||
void _bindBleObservers() { |
|
||||
// 同步连接状态与设备名 |
|
||||
try { |
|
||||
isConnected.value = bleManager.isConnected; |
|
||||
final info = bleManager.deviceInfo.value; |
|
||||
if (info != null && info.name.isNotEmpty) { |
|
||||
deviceName.value = info.name; |
|
||||
_syncDeviceWakeVersions(info); |
|
||||
} |
|
||||
// 监听连接状态变化 |
|
||||
ever<int>(bleManager.connectionStateRx, (state) { |
|
||||
isConnected.value = bleManager.isConnected; |
|
||||
final info = bleManager.deviceInfo.value; |
|
||||
if (info != null && info.name.isNotEmpty) { |
|
||||
deviceName.value = info.name; |
|
||||
_syncDeviceWakeVersions(info); |
|
||||
} |
|
||||
}); |
|
||||
// 监听设备信息更新 |
|
||||
ever<DeviceInfo?>(bleManager.deviceInfo, (info) { |
|
||||
if (info != null && info.name.isNotEmpty) { |
|
||||
deviceName.value = info.name; |
|
||||
_syncDeviceWakeVersions(info); |
|
||||
} |
|
||||
}); |
|
||||
// 监听升级进度 |
|
||||
ever<int>(bleManager.wakeupVoiceUpgradeProgress, (p) { |
|
||||
upgradeProgress.value = p; |
|
||||
if (p >= 100 && upgradingVoiceId.value != null) { |
|
||||
final item = _findById(upgradingVoiceId.value); |
|
||||
if (item != null) { |
|
||||
currentDeviceVoiceId.value = item.id; |
|
||||
currentDeviceVoiceVersion.value = item.version; |
|
||||
selectedVoiceId.value = item.id; |
|
||||
} |
|
||||
upgradingVoiceId.value = null; |
|
||||
} |
|
||||
}); |
|
||||
// 监听升级状态:仅当状态为“成功(2)”或“失败(3)”时弹出提示弹窗;升级中(1)或其他状态不提示 |
|
||||
ever<int>(bleManager.wakeupVoiceUpgradeStatus, (s) { |
|
||||
// 升级状态字段未定义,暂存到日志 |
|
||||
Logger.info('BLE 升级状态码: $s'); |
|
||||
if (s == 2) { |
|
||||
// 升级成功 |
|
||||
Get.snackbar('成功', '唤醒语音升级成功'); |
|
||||
} else if (s == 3) { |
|
||||
// 升级失败 |
|
||||
Get.snackbar('错误', '唤醒语音升级失败'); |
|
||||
} |
|
||||
}); |
|
||||
} catch (e) { |
|
||||
Logger.warning('绑定BLE观察者失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 供外部设置当前设备语音包信息 |
|
||||
/// 设置当前设备的唤醒语音包信息 |
|
||||
/// 参数:`id` 唤醒词ID;`version` 版本字符串(如 `1.2.3.4`) |
|
||||
/// 返回:无 |
|
||||
/// 异常:无 |
|
||||
void setCurrentDeviceWakeVoice({required int id, required String version}) { |
|
||||
currentDeviceVoiceId.value = id; |
|
||||
currentDeviceVoiceVersion.value = version; |
|
||||
// 若已加载列表,默认选中当前设备项 |
|
||||
if (voices.isNotEmpty) { |
|
||||
selectedVoiceId.value = id; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 拉取服务端唤醒语音包列表 |
|
||||
/// 参数:无 |
|
||||
/// 返回:`Future<void>`,完成后 `voices`、`selectedVoiceId` 等被更新 |
|
||||
/// 异常:网络或数据解析异常会写入 `loadError` 并弹出提示,不抛出 |
|
||||
Future<void> fetchVoices() async { |
|
||||
try { |
|
||||
isLoading.value = true; |
|
||||
loadError.value = ''; |
|
||||
final res = await Api.getwakeupvoices(); |
|
||||
Logger.info('获取唤醒语音列表成功 res: $res'); |
|
||||
// 仅按约定结构解析:{ voices: [...] } |
|
||||
if (res is! Map || res['voices'] is! List) { |
|
||||
loadError.value = '接口返回异常:缺少 voices 列表'; |
|
||||
voices.clear(); |
|
||||
return; |
|
||||
} |
|
||||
final list = List<Map<String, dynamic>>.from(res['voices'] as List); |
|
||||
|
|
||||
voices.assignAll(list |
|
||||
.map((e) => WakeupVoiceItem.fromJson(e as Map<String, dynamic>)) |
|
||||
.toList()); |
|
||||
|
|
||||
if (voices.isNotEmpty) { |
|
||||
selectedVoiceId.value = voices.first.id; |
|
||||
} |
|
||||
final devIdCode = deviceWakeIdCode.value; |
|
||||
if (devIdCode != null) { |
|
||||
final sameIdItems = voices.where((v) { |
|
||||
final p = _parseVersion(v.version); |
|
||||
return p != null && |
|
||||
p.length >= 2 && |
|
||||
((p[0] << 8) | p[1]) == devIdCode; |
|
||||
}).toList(); |
|
||||
if (sameIdItems.isNotEmpty) { |
|
||||
selectedVoiceId.value = sameIdItems.first.id; |
|
||||
currentDeviceVoiceId.value = devIdCode; |
|
||||
} |
|
||||
} |
|
||||
if (wakeupVersionMismatch.value) { |
|
||||
loadError.value = '设备异常:左右唤醒词版本不一致'; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
loadError.value = '加载唤醒语音失败: $e'; |
|
||||
Get.snackbar('错误', loadError.value); |
|
||||
} finally { |
|
||||
isLoading.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 解析版本字符串为四段整型数组 |
|
||||
/// 参数:`v` 版本字符串,期望形如 `A.B.C.D`(可包含非数字分隔符) |
|
||||
/// 返回:`List<int>?` 长度为4的数组,若解析失败返回 `null` |
|
||||
/// 异常:无 |
|
||||
List<int>? _parseVersion(String v) { |
|
||||
final parts = |
|
||||
v.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList(); |
|
||||
if (parts.length < 4) return null; |
|
||||
final nums = parts.take(4).map((e) => int.tryParse(e) ?? 0).toList(); |
|
||||
return nums; |
|
||||
} |
|
||||
|
|
||||
/// 同步设备左右耳唤醒词版本信息,并计算设备唤醒词ID与版本编码 |
|
||||
/// 参数:`info` 设备信息对象,需包含 `wakeupLeftVersion` 与 `wakeupRightVersion` |
|
||||
/// 返回:无 |
|
||||
/// 异常:无;若版本缺失或解析失败则静默返回 |
|
||||
void _syncDeviceWakeVersions(DeviceInfo info) { |
|
||||
final left = info.wakeupLeftVersion; |
|
||||
final right = info.wakeupRightVersion; |
|
||||
wakeupVersionMismatch.value = |
|
||||
left.isNotEmpty && right.isNotEmpty && left != right; |
|
||||
final base = left.isNotEmpty ? left : right; |
|
||||
if (base.isEmpty) return; |
|
||||
final p = _parseVersion(base); |
|
||||
if (p == null || p.length < 4) return; |
|
||||
deviceWakeIdCode.value = (p[0] << 8) | p[1]; |
|
||||
deviceWakeVerCode.value = (p[2] << 8) | p[3]; |
|
||||
currentDeviceVoiceId.value = deviceWakeIdCode.value; |
|
||||
currentDeviceVoiceVersion.value = '${p[2]}.${p[3]}'; |
|
||||
} |
|
||||
|
|
||||
/// 判断候选项与设备的唤醒词ID是否一致 |
|
||||
/// 参数:`item` 唤醒词项,若为空返回 `false` |
|
||||
/// 返回:`bool`,一致返回 `true` |
|
||||
/// 异常:无 |
|
||||
bool _isSameWakeId(WakeupVoiceItem? item) { |
|
||||
if (item == null) return false; |
|
||||
final p = _parseVersion(item.version); |
|
||||
if (p == null || p.length < 2) return false; |
|
||||
final idCode = (p[0] << 8) | p[1]; |
|
||||
return deviceWakeIdCode.value != null && deviceWakeIdCode.value == idCode; |
|
||||
} |
|
||||
|
|
||||
/// 判断候选项与设备的唤醒词版本是否一致 |
|
||||
/// 参数:`item` 唤醒词项,若为空返回 `false` |
|
||||
/// 返回:`bool`,一致返回 `true` |
|
||||
/// 异常:无 |
|
||||
bool _isSameWakeVersion(WakeupVoiceItem? item) { |
|
||||
if (item == null) return false; |
|
||||
final p = _parseVersion(item.version); |
|
||||
if (p == null || p.length < 4) return false; |
|
||||
final verCode = (p[2] << 8) | p[3]; |
|
||||
return deviceWakeVerCode.value != null && |
|
||||
deviceWakeVerCode.value == verCode; |
|
||||
} |
|
||||
|
|
||||
// 选择语音包 |
|
||||
/// 选择语音包 |
|
||||
/// 参数:`id` 待选择的语音包ID |
|
||||
/// 返回:无 |
|
||||
/// 异常:无 |
|
||||
void selectVoice(int id) { |
|
||||
selectedVoiceId.value = id; |
|
||||
} |
|
||||
|
|
||||
// 动作按钮文案与状态 |
|
||||
/// 获取当前选择项对应的操作文案(基于设备与候选项版本关系) |
|
||||
/// 参数:无(使用 `selectedVoiceId`) |
|
||||
/// 返回:`String`,可能为 `设备异常`、`升级`、`切换`、`已是最新`、`处理` |
|
||||
/// 异常:无 |
|
||||
String get actionText { |
|
||||
final sel = selectedVoiceId.value; |
|
||||
final curId = currentDeviceVoiceId.value; |
|
||||
final curVer = currentDeviceVoiceVersion.value; |
|
||||
final selected = _findById(sel); |
|
||||
if (sel == null || selected == null) return '处理'; |
|
||||
if (wakeupVersionMismatch.value) return '设备异常'; |
|
||||
|
|
||||
if (_isSameWakeId(selected)) { |
|
||||
return _isSameWakeVersion(selected) ? '已是最新' : '升级'; |
|
||||
} |
|
||||
return '切换'; |
|
||||
} |
|
||||
|
|
||||
/// 获取当前选择项是否允许触发操作 |
|
||||
/// 参数:无(使用 `selectedVoiceId`) |
|
||||
/// 返回:`bool`,当 `设备异常` 或 `已是最新` 时为 `false` |
|
||||
/// 异常:无 |
|
||||
bool get actionEnabled { |
|
||||
final sel = selectedVoiceId.value; |
|
||||
final curId = currentDeviceVoiceId.value; |
|
||||
final curVer = currentDeviceVoiceVersion.value; |
|
||||
final selected = _findById(sel); |
|
||||
if (sel == null || selected == null) return false; |
|
||||
if (!isConnected.value) return false; |
|
||||
if (wakeupVersionMismatch.value) return false; |
|
||||
|
|
||||
if (_isSameWakeId(selected)) { |
|
||||
return !_isSameWakeVersion(selected); |
|
||||
} |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 计算指定语音项的操作文案(不影响当前选择项) |
|
||||
/// 参数:`item` 候选语音项 |
|
||||
/// 返回:`String`,可能为 `设备异常`、`升级`、`切换`、`已是最新` |
|
||||
/// 异常:无 |
|
||||
String actionTextForItem(WakeupVoiceItem item) { |
|
||||
if (wakeupVersionMismatch.value) return '设备异常'; |
|
||||
if (_isSameWakeId(item)) { |
|
||||
return _isSameWakeVersion(item) ? '已是最新' : '升级'; |
|
||||
} |
|
||||
return '切换'; |
|
||||
} |
|
||||
|
|
||||
/// 计算指定语音项是否允许操作(不影响当前选择项) |
|
||||
/// 参数:`item` 候选语音项 |
|
||||
/// 返回:`bool`,当 `设备异常` 或 `已是最新` 时为 `false` |
|
||||
/// 异常:无 |
|
||||
bool actionEnabledForItem(WakeupVoiceItem item) { |
|
||||
if (!isConnected.value) return false; |
|
||||
if (wakeupVersionMismatch.value) return false; |
|
||||
if (_isSameWakeId(item)) { |
|
||||
return !_isSameWakeVersion(item); |
|
||||
} |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
/// 根据当前选择项触发操作 |
|
||||
/// 参数:无 |
|
||||
/// 返回:`Future<void>` |
|
||||
/// 异常:无;当不允许操作时直接返回 |
|
||||
Future<void> onActionPressed() async { |
|
||||
final label = actionText; |
|
||||
if (!actionEnabled) return; |
|
||||
|
|
||||
switch (label) { |
|
||||
case '升级': |
|
||||
await upgradeWakeVoice(); |
|
||||
break; |
|
||||
case '切换': |
|
||||
await switchWakeVoice(); |
|
||||
break; |
|
||||
default: |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 将当前设备升级到已选择项 |
|
||||
/// 参数:无(使用 `selectedVoiceId`) |
|
||||
/// 返回:`Future<void>` |
|
||||
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出 |
|
||||
Future<void> upgradeWakeVoice() async { |
|
||||
final sel = _findById(selectedVoiceId.value); |
|
||||
if (sel == null) return; |
|
||||
await _startUpgradeByItem(sel); |
|
||||
} |
|
||||
|
|
||||
/// 将当前设备切换到已选择项 |
|
||||
/// 参数:无(使用 `selectedVoiceId`) |
|
||||
/// 返回:`Future<void>` |
|
||||
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出 |
|
||||
Future<void> switchWakeVoice() async { |
|
||||
final sel = _findById(selectedVoiceId.value); |
|
||||
if (sel == null) return; |
|
||||
await _startUpgradeByItem(sel); |
|
||||
} |
|
||||
|
|
||||
// 直接切换到指定语音包(页面卡片按钮使用) |
|
||||
/// 直接切换到指定语音项 |
|
||||
/// 参数:`item` 目标语音项 |
|
||||
/// 返回:`Future<void>` |
|
||||
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出 |
|
||||
Future<void> switchWakeVoiceTo(WakeupVoiceItem item) async { |
|
||||
selectedVoiceId.value = item.id; |
|
||||
await _startUpgradeByItem(item); |
|
||||
} |
|
||||
|
|
||||
// 直接升级到指定语音包(页面卡片按钮使用) |
|
||||
/// 直接升级到指定语音项 |
|
||||
/// 参数:`item` 目标语音项 |
|
||||
/// 返回:`Future<void>` |
|
||||
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出 |
|
||||
Future<void> upgradeWakeVoiceTo(WakeupVoiceItem item) async { |
|
||||
selectedVoiceId.value = item.id; |
|
||||
await _startUpgradeByItem(item); |
|
||||
} |
|
||||
|
|
||||
// 升级相关状态 |
|
||||
final RxnInt upgradingVoiceId = RxnInt(); |
|
||||
final RxInt upgradeProgress = 0.obs; |
|
||||
|
|
||||
/// 按指定语音项发起升级流程(下载并写入设备) |
|
||||
/// 参数:`item` 目标语音项 |
|
||||
/// 返回:`Future<void>` |
|
||||
/// 异常:下载或BLE交互失败会通过提示与日志反馈,不抛出 |
|
||||
Future<void> _startUpgradeByItem(WakeupVoiceItem item) async { |
|
||||
try { |
|
||||
if (item.packagaddress.isEmpty) { |
|
||||
Get.snackbar('升级失败', '语音包地址为空'); |
|
||||
return; |
|
||||
} |
|
||||
upgradingVoiceId.value = item.id; |
|
||||
upgradeProgress.value = 0; |
|
||||
final url = item.packagaddress; |
|
||||
Logger.info('开始下载唤醒语音包: $url'); |
|
||||
final bytes = await _downloadVoiceBytes(url); |
|
||||
if (bytes.isEmpty) { |
|
||||
Get.snackbar('升级失败', '语音包下载为空'); |
|
||||
upgradingVoiceId.value = null; |
|
||||
return; |
|
||||
} |
|
||||
Logger.info('下载完成,字节数: ${bytes.length},开始升级'); |
|
||||
// 注意:MethodChannel 在 Android 端对 Uint8List 会编码为 byte[], |
|
||||
// 而当前原生插件期望的是 List。为避免 ClassCastException,传递 List<int>。 |
|
||||
final ok = await bleManager.startWakeupVoicesUpgrade(bytes.toList()); |
|
||||
if (!ok) { |
|
||||
Get.snackbar('升级失败', 'BLE升级启动失败'); |
|
||||
upgradingVoiceId.value = null; |
|
||||
return; |
|
||||
} |
|
||||
Get.snackbar('升级已启动', '正在写入设备,请稍候'); |
|
||||
} catch (e) { |
|
||||
upgradingVoiceId.value = null; |
|
||||
Logger.info('升级失败: $e'); |
|
||||
Get.snackbar('升级失败', e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 下载语音包二进制数据 |
|
||||
/// 参数:`url` 语音包地址 |
|
||||
/// 返回:`Future<List<int>>` 字节列表;失败返回空列表 |
|
||||
/// 异常:网络异常会记录日志并返回空列表 |
|
||||
Future<List<int>> _downloadVoiceBytes(String url) async { |
|
||||
try { |
|
||||
final dio = Dio(); |
|
||||
final response = await dio.get<List<int>>( |
|
||||
url, |
|
||||
options: Options( |
|
||||
responseType: ResponseType.bytes, |
|
||||
receiveTimeout: const Duration(seconds: 60), |
|
||||
), |
|
||||
); |
|
||||
return response.data ?? []; |
|
||||
} catch (e) { |
|
||||
Logger.error('下载语音包失败: $url', e); |
|
||||
return []; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 根据ID查找语音项 |
|
||||
/// 参数:`id` 语音包ID |
|
||||
/// 返回:`WakeupVoiceItem?` 找到返回对象,否则 `null` |
|
||||
/// 异常:无 |
|
||||
WakeupVoiceItem? _findById(int? id) { |
|
||||
if (id == null) return null; |
|
||||
for (final v in voices) { |
|
||||
if (v.id == id) return v; |
|
||||
} |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
// 以下保留原有本地词功能(开关、增删) |
|
||||
void toggleEnabled(bool value) { |
|
||||
enabled.value = value; |
|
||||
} |
|
||||
|
|
||||
void setInput(String value) { |
|
||||
inputText.value = value; |
|
||||
} |
|
||||
|
|
||||
String? validateWakeWord(String text) { |
|
||||
final t = text.trim(); |
|
||||
if (t.isEmpty) return '唤醒词不能为空'; |
|
||||
if (t.length > 20) return '唤醒词过长(≤20个字符)'; |
|
||||
if (wakeWords.contains(t)) return '重复的唤醒词'; |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
bool addWakeWord(String text) { |
|
||||
final err = validateWakeWord(text); |
|
||||
if (err != null) { |
|
||||
Get.snackbar('提示', err); |
|
||||
return false; |
|
||||
} |
|
||||
if (wakeWords.length >= 5) { |
|
||||
Get.snackbar('提示', '最多添加5个唤醒词'); |
|
||||
return false; |
|
||||
} |
|
||||
wakeWords.add(text.trim()); |
|
||||
inputText.value = ''; |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
void removeWakeWord(String text) { |
|
||||
wakeWords.remove(text); |
|
||||
} |
|
||||
|
|
||||
Future<void> saveChanges() async { |
|
||||
Get.snackbar('保存成功', '唤醒词设置已更新'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
class WakeupVoiceItem { |
|
||||
final int id; |
|
||||
final bool enable; |
|
||||
final String name; |
|
||||
final String desc; |
|
||||
final String version; |
|
||||
final String packagaddress; |
|
||||
|
|
||||
WakeupVoiceItem({ |
|
||||
required this.id, |
|
||||
required this.enable, |
|
||||
required this.name, |
|
||||
required this.desc, |
|
||||
required this.version, |
|
||||
required this.packagaddress, |
|
||||
}); |
|
||||
|
|
||||
factory WakeupVoiceItem.fromJson(Map<String, dynamic> json) { |
|
||||
return WakeupVoiceItem( |
|
||||
id: (json['id'] ?? json['Id'] ?? 0) is String |
|
||||
? int.tryParse(json['id'] ?? json['Id']) ?? 0 |
|
||||
: (json['id'] ?? json['Id'] ?? 0) as int, |
|
||||
enable: (json['enable'] ?? json['Enable'] ?? false) == true || |
|
||||
(json['enable'] ?? 'false').toString() == 'true', |
|
||||
name: (json['name'] ?? '').toString(), |
|
||||
desc: (json['desc'] ?? '').toString(), |
|
||||
version: (json['version'] ?? '').toString(), |
|
||||
packagaddress: (json['packagaddress'] ?? '').toString(), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 从描述字段提取识别率与更新说明(供UI展示) |
|
||||
} |
|
||||
|
|
||||
extension WakeupVoiceItemExt on WakeupVoiceController { |
|
||||
String? extractAccuracy(WakeupVoiceItem item) { |
|
||||
final d = item.desc; |
|
||||
final reg = RegExp(r'(识别率|准确率)\s*([0-9]{1,3}(\.[0-9])?)%'); |
|
||||
final m = reg.firstMatch(d); |
|
||||
if (m != null) { |
|
||||
return '识别率 ${m.group(2)}%'; |
|
||||
} |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
List<String> extractUpdateNotes(WakeupVoiceItem item) { |
|
||||
final d = item.desc.trim(); |
|
||||
if (d.isEmpty) { |
|
||||
return const ['提升识别准确率至99.2%', '优化唤醒提示音的响应', '修复复杂性延迟问题']; |
|
||||
} |
|
||||
// 按常见分隔符切分 |
|
||||
final parts = d |
|
||||
.split(RegExp(r'[;;\n\r]')) |
|
||||
.map((e) => e.trim()) |
|
||||
.where((e) => e.isNotEmpty) |
|
||||
.toList(); |
|
||||
return parts; |
|
||||
} |
|
||||
} |
|
||||
@ -1,457 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/wakeup_voice_controller.dart'; |
|
||||
|
|
||||
class WakeupVoiceView extends GetView<WakeupVoiceController> { |
|
||||
const WakeupVoiceView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark; |
|
||||
return Scaffold( |
|
||||
backgroundColor: isDark ? Colors.black : Colors.grey[50], |
|
||||
body: Obx(() { |
|
||||
// 顶部渐变头部 + 内容 |
|
||||
return CustomScrollView( |
|
||||
slivers: [ |
|
||||
SliverAppBar( |
|
||||
pinned: true, |
|
||||
elevation: 0, |
|
||||
centerTitle: true, |
|
||||
backgroundColor: Colors.transparent, |
|
||||
flexibleSpace: _buildHeader(isDark), |
|
||||
expandedHeight: 180, |
|
||||
leading: IconButton( |
|
||||
icon: const Icon(Icons.arrow_back_ios, color: Colors.white), |
|
||||
onPressed: () => Get.back(), |
|
||||
), |
|
||||
title: const Text('唤醒词管理', |
|
||||
style: TextStyle(color: Colors.white, fontSize: 18)), |
|
||||
actions: const [ |
|
||||
Padding( |
|
||||
padding: EdgeInsets.only(right: 12), |
|
||||
child: Icon(Icons.more_vert, color: Colors.white), |
|
||||
) |
|
||||
], |
|
||||
), |
|
||||
SliverToBoxAdapter( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
_buildCurrentWakeWordCard(isDark), |
|
||||
const SizedBox(height: 16), |
|
||||
_buildAvailableWakeWords(isDark), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
}), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 顶部渐变头部 |
|
||||
/// 构建顶部设备状态头部 |
|
||||
/// 参数:`isDark` 是否深色模式 |
|
||||
/// 返回:`Widget` 构建好的头部组件 |
|
||||
/// 异常:无 |
|
||||
Widget _buildHeader(bool isDark) { |
|
||||
final connected = controller.isConnected.value; |
|
||||
final deviceName = controller.deviceName.value.isEmpty |
|
||||
? '未连接设备' |
|
||||
: controller.deviceName.value; |
|
||||
final mismatch = controller.wakeupVersionMismatch.value; |
|
||||
return Container( |
|
||||
decoration: const BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
begin: Alignment.topLeft, |
|
||||
end: Alignment.bottomRight, |
|
||||
colors: [Color(0xFF6A5AE0), Color(0xFF8A65E6)], |
|
||||
), |
|
||||
), |
|
||||
child: SafeArea( |
|
||||
bottom: false, |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.fromLTRB(16, 56, 16, 16), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
Container( |
|
||||
width: 56, |
|
||||
height: 56, |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.white.withOpacity(0.15), |
|
||||
borderRadius: BorderRadius.circular(12), |
|
||||
), |
|
||||
child: const Icon(Icons.headset, color: Colors.white, size: 28), |
|
||||
), |
|
||||
const SizedBox(width: 12), |
|
||||
Expanded( |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
children: [ |
|
||||
Text(deviceName, |
|
||||
style: const TextStyle( |
|
||||
color: Colors.white, |
|
||||
fontSize: 18, |
|
||||
fontWeight: FontWeight.w600)), |
|
||||
const SizedBox(height: 6), |
|
||||
Row( |
|
||||
children: [ |
|
||||
Container( |
|
||||
width: 8, |
|
||||
height: 8, |
|
||||
decoration: BoxDecoration( |
|
||||
color: connected ? Colors.greenAccent : Colors.red, |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(width: 6), |
|
||||
Text(connected ? '已连接' : '未连接', |
|
||||
style: const TextStyle( |
|
||||
color: Colors.white70, fontSize: 12)), |
|
||||
if (mismatch) ...[ |
|
||||
const SizedBox(width: 12), |
|
||||
Container( |
|
||||
padding: const EdgeInsets.symmetric( |
|
||||
horizontal: 8, vertical: 2), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.red.withOpacity(0.2), |
|
||||
borderRadius: BorderRadius.circular(6), |
|
||||
border: Border.all(color: Colors.redAccent), |
|
||||
), |
|
||||
child: const Text('设备异常', |
|
||||
style: TextStyle( |
|
||||
color: Colors.white, |
|
||||
fontSize: 11, |
|
||||
fontWeight: FontWeight.w500)), |
|
||||
), |
|
||||
] |
|
||||
], |
|
||||
) |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 当前唤醒词卡片 |
|
||||
/// 构建当前设备唤醒词卡片 |
|
||||
/// 参数:`isDark` 是否深色模式 |
|
||||
/// 返回:`Widget` 构建好的卡片 |
|
||||
/// 异常:无 |
|
||||
Widget _buildCurrentWakeWordCard(bool isDark) { |
|
||||
final curId = controller.currentDeviceVoiceId.value; |
|
||||
final curVer = controller.currentDeviceVoiceVersion.value; |
|
||||
WakeupVoiceItem? curItem; |
|
||||
for (final v in controller.voices) { |
|
||||
if (v.id == curId) { |
|
||||
curItem = v; |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
final title = '当前唤醒词'; |
|
||||
return Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||
children: [ |
|
||||
Text(title, |
|
||||
style: |
|
||||
const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), |
|
||||
Text(curVer != null ? 'v$curVer' : '', |
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)), |
|
||||
], |
|
||||
), |
|
||||
const SizedBox(height: 8), |
|
||||
if (curItem != null) |
|
||||
_buildVoiceCard( |
|
||||
isDark: isDark, |
|
||||
item: curItem, |
|
||||
isCurrent: true, |
|
||||
hasUpdate: curItem != null && |
|
||||
controller.actionTextForItem(curItem) == '升级', |
|
||||
), |
|
||||
// 版本一致的唤醒词放在“当前唤醒词”下面 |
|
||||
...controller.voices |
|
||||
.where((v) => controller.actionTextForItem(v) == '已是最新') |
|
||||
.where((v) => !identical(v, curItem)) |
|
||||
.map((v) => _buildVoiceCard( |
|
||||
isDark: isDark, |
|
||||
item: v, |
|
||||
isCurrent: true, |
|
||||
hasUpdate: false, |
|
||||
)), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 可用唤醒词列表 |
|
||||
/// 构建可用唤醒词列表 |
|
||||
/// 参数:`isDark` 是否深色模式 |
|
||||
/// 返回:`Widget` 构建好的列表 |
|
||||
/// 异常:无 |
|
||||
Widget _buildAvailableWakeWords(bool isDark) { |
|
||||
final curId = controller.currentDeviceVoiceId.value; |
|
||||
return Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text('可用唤醒词', |
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), |
|
||||
const SizedBox(height: 12), |
|
||||
if (controller.loadError.value.isNotEmpty) |
|
||||
Text(controller.loadError.value, |
|
||||
style: const TextStyle(color: Colors.red)), |
|
||||
// 仅展示非“已是最新”的可用唤醒词(排除当前使用版本) |
|
||||
...controller.voices |
|
||||
.where((v) => controller.actionTextForItem(v) != '已是最新') |
|
||||
.map((v) => _buildVoiceCard( |
|
||||
isDark: isDark, |
|
||||
item: v, |
|
||||
isCurrent: false, |
|
||||
hasUpdate: controller.actionTextForItem(v) == '升级', |
|
||||
)), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
/// 比较版本是否更高(三段比较) |
|
||||
/// 参数:`a` 候选版本;`b` 当前版本(可空) |
|
||||
/// 返回:`bool` 若 `a` 高于 `b` 返回 `true` |
|
||||
/// 异常:无 |
|
||||
bool _versionHigherThan(String a, String? b) { |
|
||||
if (b == null) return true; |
|
||||
// 简易版本比较:按点位分割逐段比较 |
|
||||
List<int> pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList(); |
|
||||
List<int> pb = b.split('.').map((e) => int.tryParse(e) ?? 0).toList(); |
|
||||
for (int i = 0; i < 3; i++) { |
|
||||
final va = i < pa.length ? pa[i] : 0; |
|
||||
final vb = i < pb.length ? pb[i] : 0; |
|
||||
if (va != vb) return va > vb; |
|
||||
} |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
/// 判断当前卡片是否存在可升级版本 |
|
||||
/// 参数:`curItem` 当前项(可空) |
|
||||
/// 返回:`bool` 存在升级返回 `true` |
|
||||
/// 异常:无 |
|
||||
bool _hasUpdateForCurrent(WakeupVoiceItem? curItem) { |
|
||||
if (curItem == null) return false; |
|
||||
final curVer = controller.currentDeviceVoiceVersion.value; |
|
||||
return _versionHigherThan(curItem.version, curVer); |
|
||||
} |
|
||||
|
|
||||
/// 构建通用语音卡片 |
|
||||
/// 参数:`isDark` 是否深色模式;`item` 语音项;`isCurrent` 是否当前项;`hasUpdate` 是否升级卡片 |
|
||||
/// 返回:`Widget` 卡片组件 |
|
||||
/// 异常:无 |
|
||||
Widget _buildVoiceCard({ |
|
||||
required bool isDark, |
|
||||
required WakeupVoiceItem? item, |
|
||||
required bool isCurrent, |
|
||||
bool hasUpdate = false, |
|
||||
}) { |
|
||||
if (item == null) { |
|
||||
if (isCurrent) { |
|
||||
return Container( |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDark ? Colors.grey[900] : Colors.white, |
|
||||
borderRadius: BorderRadius.circular(12), |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 10, |
|
||||
offset: const Offset(0, 2), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: const Text('未设置'), |
|
||||
); |
|
||||
} else { |
|
||||
return const SizedBox.shrink(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
final accuracyText = controller.extractAccuracy(item) ?? '识别率 -'; |
|
||||
final showCheck = isCurrent && !hasUpdate; |
|
||||
final actionColor = |
|
||||
hasUpdate ? const Color(0xFFFF7A00) : const Color(0xFF6A5AE0); |
|
||||
final actionText = controller.actionTextForItem(item); |
|
||||
final actionEnabled = controller.actionEnabledForItem(item); |
|
||||
final isUpgrading = |
|
||||
item != null && controller.upgradingVoiceId.value == item.id; |
|
||||
final progress = controller.upgradeProgress.value; |
|
||||
|
|
||||
return Container( |
|
||||
margin: const EdgeInsets.only(bottom: 12), |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDark ? Colors.grey[900] : Colors.white, |
|
||||
borderRadius: BorderRadius.circular(12), |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 10, |
|
||||
offset: const Offset(0, 2), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Row( |
|
||||
children: [ |
|
||||
Container( |
|
||||
width: 40, |
|
||||
height: 40, |
|
||||
decoration: BoxDecoration( |
|
||||
color: (hasUpdate |
|
||||
? const Color(0xFFFFE8D5) |
|
||||
: const Color(0xFFE8E5FF)), |
|
||||
borderRadius: BorderRadius.circular(10), |
|
||||
), |
|
||||
child: Icon( |
|
||||
hasUpdate ? Icons.mic : Icons.auto_awesome, |
|
||||
color: hasUpdate |
|
||||
? const Color(0xFFFF7A00) |
|
||||
: const Color(0xFF6A5AE0), |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(width: 10), |
|
||||
Expanded( |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Row( |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: Text(item.name, |
|
||||
style: const TextStyle( |
|
||||
fontSize: 15, fontWeight: FontWeight.w600)), |
|
||||
), |
|
||||
Text('v${item.version}', |
|
||||
style: const TextStyle( |
|
||||
fontSize: 12, color: Colors.grey)), |
|
||||
], |
|
||||
), |
|
||||
const SizedBox(height: 4), |
|
||||
Text(accuracyText, |
|
||||
style: |
|
||||
const TextStyle(fontSize: 12, color: Colors.grey)), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
if (showCheck) ...[ |
|
||||
const SizedBox(width: 8), |
|
||||
const Icon(Icons.check_circle, color: Color(0xFF6A5AE0)), |
|
||||
] |
|
||||
], |
|
||||
), |
|
||||
if (hasUpdate) ...[ |
|
||||
const SizedBox(height: 12), |
|
||||
Container( |
|
||||
padding: const EdgeInsets.all(12), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDark ? Colors.grey[850] : const Color(0xFFFFF4E8), |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Row( |
|
||||
children: const [ |
|
||||
Icon(Icons.upgrade, color: Color(0xFFFF7A00), size: 18), |
|
||||
SizedBox(width: 6), |
|
||||
Text('更新内容', |
|
||||
style: TextStyle( |
|
||||
fontSize: 13, |
|
||||
fontWeight: FontWeight.w600, |
|
||||
color: Color(0xFFFF7A00))), |
|
||||
], |
|
||||
), |
|
||||
const SizedBox(height: 6), |
|
||||
...controller.extractUpdateNotes(item).map((note) => Padding( |
|
||||
padding: const EdgeInsets.symmetric(vertical: 2), |
|
||||
child: Row( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
const Text('• ', |
|
||||
style: TextStyle(color: Colors.black54)), |
|
||||
Expanded( |
|
||||
child: Text(note, |
|
||||
style: const TextStyle( |
|
||||
fontSize: 12, color: Colors.black87)), |
|
||||
) |
|
||||
], |
|
||||
), |
|
||||
)), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
const SizedBox(height: 12), |
|
||||
SizedBox( |
|
||||
width: double.infinity, |
|
||||
child: ElevatedButton( |
|
||||
style: ElevatedButton.styleFrom( |
|
||||
backgroundColor: actionColor, |
|
||||
padding: const EdgeInsets.symmetric(vertical: 12), |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(10)), |
|
||||
), |
|
||||
onPressed: isUpgrading || !actionEnabled |
|
||||
? null |
|
||||
: () { |
|
||||
if (actionText == '升级') { |
|
||||
controller.upgradeWakeVoiceTo(item); |
|
||||
} else if (actionText == '切换') { |
|
||||
controller.switchWakeVoiceTo(item); |
|
||||
} |
|
||||
}, |
|
||||
child: Text(actionText), |
|
||||
), |
|
||||
), |
|
||||
if (isUpgrading) ...[ |
|
||||
const SizedBox(height: 12), |
|
||||
Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Row( |
|
||||
children: [ |
|
||||
const Icon(Icons.sync, size: 16, color: Color(0xFF6A5AE0)), |
|
||||
const SizedBox(width: 6), |
|
||||
Text('升级中... $progress%', |
|
||||
style: const TextStyle( |
|
||||
fontSize: 12, color: Colors.black54)), |
|
||||
], |
|
||||
), |
|
||||
const SizedBox(height: 6), |
|
||||
ClipRRect( |
|
||||
borderRadius: BorderRadius.circular(6), |
|
||||
child: LinearProgressIndicator( |
|
||||
value: (progress.clamp(0, 100)) / 100.0, |
|
||||
minHeight: 8, |
|
||||
backgroundColor: |
|
||||
isDark ? Colors.grey[800] : Colors.grey[300], |
|
||||
color: const Color(0xFF6A5AE0), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
// 底部按钮已移除,改为每个卡片独立的操作按钮以贴合设计 |
|
||||
} |
|
||||
@ -0,0 +1,33 @@ |
|||||
|
# Miscellaneous |
||||
|
*.class |
||||
|
*.log |
||||
|
*.pyc |
||||
|
*.swp |
||||
|
.DS_Store |
||||
|
.atom/ |
||||
|
.build/ |
||||
|
.buildlog/ |
||||
|
.history |
||||
|
.svn/ |
||||
|
.swiftpm/ |
||||
|
migrate_working_dir/ |
||||
|
|
||||
|
# IntelliJ related |
||||
|
*.iml |
||||
|
*.ipr |
||||
|
*.iws |
||||
|
.idea/ |
||||
|
|
||||
|
# The .vscode folder contains launch configuration and tasks you configure in |
||||
|
# VS Code which you may wish to be included in version control, so this line |
||||
|
# is commented out by default. |
||||
|
#.vscode/ |
||||
|
|
||||
|
# Flutter/Dart/Pub related |
||||
|
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. |
||||
|
/pubspec.lock |
||||
|
**/doc/api/ |
||||
|
.dart_tool/ |
||||
|
.flutter-plugins |
||||
|
.flutter-plugins-dependencies |
||||
|
build/ |
||||
@ -0,0 +1,30 @@ |
|||||
|
# This file tracks properties of this Flutter project. |
||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
||||
|
# |
||||
|
# This file should be version controlled and should not be manually edited. |
||||
|
|
||||
|
version: |
||||
|
revision: "30ade5e937e6afbd2d2c38ec53175b89b556447e" |
||||
|
channel: "stable" |
||||
|
|
||||
|
project_type: plugin |
||||
|
|
||||
|
# Tracks metadata for the flutter migrate command |
||||
|
migration: |
||||
|
platforms: |
||||
|
- platform: root |
||||
|
create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e |
||||
|
base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e |
||||
|
- platform: android |
||||
|
create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e |
||||
|
base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e |
||||
|
|
||||
|
# User provided section |
||||
|
|
||||
|
# List of Local paths (relative to this file) that should be |
||||
|
# ignored by the migrate tool. |
||||
|
# |
||||
|
# Files that are not part of the templates will be ignored by default. |
||||
|
unmanaged_files: |
||||
|
- 'lib/main.dart' |
||||
|
- 'ios/Runner.xcodeproj/project.pbxproj' |
||||
@ -0,0 +1,3 @@ |
|||||
|
## 0.0.1 |
||||
|
|
||||
|
* TODO: Describe initial release. |
||||
@ -0,0 +1 @@ |
|||||
|
TODO: Add your license here. |
||||
@ -0,0 +1,15 @@ |
|||||
|
# jielihome |
||||
|
|
||||
|
JieLi Home SDK Flutter plugin |
||||
|
|
||||
|
## Getting Started |
||||
|
|
||||
|
This project is a starting point for a Flutter |
||||
|
[plug-in package](https://flutter.dev/to/develop-plugins), |
||||
|
a specialized package that includes platform-specific implementation code for |
||||
|
Android and/or iOS. |
||||
|
|
||||
|
For help getting started with Flutter development, view the |
||||
|
[online documentation](https://docs.flutter.dev), which offers tutorials, |
||||
|
samples, guidance on mobile development, and a full API reference. |
||||
|
|
||||
@ -0,0 +1,4 @@ |
|||||
|
include: package:flutter_lints/flutter.yaml |
||||
|
|
||||
|
# Additional information about this file can be found at |
||||
|
# https://dart.dev/guides/language/analysis-options |
||||
@ -0,0 +1,9 @@ |
|||||
|
*.iml |
||||
|
.gradle |
||||
|
/local.properties |
||||
|
/.idea/workspace.xml |
||||
|
/.idea/libraries |
||||
|
.DS_Store |
||||
|
/build |
||||
|
/captures |
||||
|
.cxx |
||||
@ -0,0 +1,80 @@ |
|||||
|
group = "com.jielihome.jielihome" |
||||
|
version = "1.0-SNAPSHOT" |
||||
|
|
||||
|
buildscript { |
||||
|
ext.kotlin_version = "2.1.0" |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
classpath("com.android.tools.build:gradle:8.7.3") |
||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
allprojects { |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
flatDir { |
||||
|
dirs "libs" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
apply plugin: "com.android.library" |
||||
|
apply plugin: "kotlin-android" |
||||
|
|
||||
|
android { |
||||
|
namespace = "com.jielihome.jielihome" |
||||
|
|
||||
|
compileSdk = 36 |
||||
|
|
||||
|
compileOptions { |
||||
|
sourceCompatibility = JavaVersion.VERSION_17 |
||||
|
targetCompatibility = JavaVersion.VERSION_17 |
||||
|
} |
||||
|
|
||||
|
kotlinOptions { |
||||
|
jvmTarget = JavaVersion.VERSION_17 |
||||
|
} |
||||
|
|
||||
|
sourceSets { |
||||
|
main.java.srcDirs += "src/main/kotlin" |
||||
|
test.java.srcDirs += "src/test/kotlin" |
||||
|
} |
||||
|
|
||||
|
defaultConfig { |
||||
|
minSdk = 21 |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
// 杰理原生 SDK:library 模块只能 compileOnly(AGP 限制:library 不能直接 bundle 本地 aar)。 |
||||
|
// host app 需在 android/app/build.gradle.kts 里把同名 aar 用 implementation 引入作运行时依赖。 |
||||
|
compileOnly fileTree(dir: 'libs', include: ['*.aar', '*.jar']) |
||||
|
// device_plugin_interface 提供 DeviceCallTranslationPort 等通用接口, |
||||
|
// 让 JieliCallTranslationPort 可以无 vendor 耦合地被 translate_server 消费。 |
||||
|
implementation project(':device_plugin_interface') |
||||
|
implementation "com.squareup.okhttp3:okhttp:4.12.0" |
||||
|
implementation "com.google.code.gson:gson:2.10.1" |
||||
|
implementation "androidx.core:core-ktx:1.13.1" |
||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" |
||||
|
|
||||
|
testImplementation("org.jetbrains.kotlin:kotlin-test") |
||||
|
testImplementation("org.mockito:mockito-core:5.0.0") |
||||
|
} |
||||
|
|
||||
|
testOptions { |
||||
|
unitTests.all { |
||||
|
useJUnitPlatform() |
||||
|
|
||||
|
testLogging { |
||||
|
events "passed", "skipped", "failed", "standardOut", "standardError" |
||||
|
outputs.upToDateWhen {false} |
||||
|
showStandardStreams = true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@ |
|||||
|
rootProject.name = 'jielihome' |
||||
@ -0,0 +1,12 @@ |
|||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
<uses-permission android:name="android.permission.INTERNET" /> |
||||
|
<uses-permission android:name="android.permission.BLUETOOTH" |
||||
|
android:maxSdkVersion="30" /> |
||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
||||
|
android:maxSdkVersion="30" /> |
||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> |
||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> |
||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
||||
|
</manifest> |
||||
@ -0,0 +1,41 @@ |
|||||
|
package com.jielihome.jielihome |
||||
|
|
||||
|
import com.jielihome.jielihome.bridge.MethodRouter |
||||
|
import com.jielihome.jielihome.core.JieliHomeServer |
||||
|
import com.jielihome.jielihome.integration.JieliNativeDevicePlugin |
||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin |
||||
|
import io.flutter.plugin.common.EventChannel |
||||
|
import io.flutter.plugin.common.MethodChannel |
||||
|
|
||||
|
/** |
||||
|
* Flutter 入口,仅负责注册 Channel 并把请求转交给 MethodRouter。 |
||||
|
* 全部业务逻辑在 [JieliHomeServer] 与 feature/event 子模块中。 |
||||
|
*/ |
||||
|
class JielihomePlugin : FlutterPlugin { |
||||
|
|
||||
|
private lateinit var methodChannel: MethodChannel |
||||
|
private lateinit var eventChannel: EventChannel |
||||
|
private lateinit var router: MethodRouter |
||||
|
|
||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
||||
|
val server = JieliHomeServer.get() |
||||
|
router = MethodRouter(binding.applicationContext, server) |
||||
|
|
||||
|
methodChannel = MethodChannel(binding.binaryMessenger, "device_jieli/method") |
||||
|
methodChannel.setMethodCallHandler(router) |
||||
|
|
||||
|
eventChannel = EventChannel(binding.binaryMessenger, "device_jieli/event") |
||||
|
eventChannel.setStreamHandler(server.dispatcher) |
||||
|
|
||||
|
// 把杰理 vendor 注册到 device_manager 的 NativeDevicePluginRegistry: |
||||
|
// 这样 app 走 device_manager 路径时能 listVendors / useVendor("jieli")。 |
||||
|
// SDK 直连路径(device_jieli/method 上面那条)保留不变;两条路径并存。 |
||||
|
JieliNativeDevicePlugin.register(binding.applicationContext) |
||||
|
} |
||||
|
|
||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
||||
|
methodChannel.setMethodCallHandler(null) |
||||
|
eventChannel.setStreamHandler(null) |
||||
|
// 进程级资源不在这里 shutdown,保持后续 attach 仍可继续使用 |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,153 @@ |
|||||
|
package com.jielihome.jielihome.api |
||||
|
|
||||
|
/** |
||||
|
* 原生层事件监听器。宿主 App 在 Activity/Service 里实现并注册到 |
||||
|
* [com.jielihome.jielihome.core.JieliHomeServer.addEventListener], |
||||
|
* 即可订阅插件所有事件而不依赖 Flutter EventChannel。 |
||||
|
* |
||||
|
* 所有回调里的 payload 都是 [Map],字段与 Dart 侧 EventChannel 推上去的完全一致, |
||||
|
* 因此原生订阅与 Dart 订阅可以并存(fan-out)。 |
||||
|
* |
||||
|
* 默认提供「按 type 路由」的便捷扩展,子类只覆盖关心的方法即可。 |
||||
|
*/ |
||||
|
interface JieliEventListener { |
||||
|
/** 任意事件,原始 payload。可以自己按 payload["type"] 路由。 */ |
||||
|
fun onEvent(payload: Map<String, Any?>) {} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 按事件类型分发的便捷适配器。子类只覆盖关心的回调,未覆盖的事件会落到 [onUnknown]。 |
||||
|
*/ |
||||
|
abstract class JieliEventAdapter : JieliEventListener { |
||||
|
|
||||
|
override fun onEvent(payload: Map<String, Any?>) { |
||||
|
when (payload["type"] as? String) { |
||||
|
"adapterStatus" -> onAdapterStatus( |
||||
|
enabled = payload["enabled"] as? Boolean ?: false, |
||||
|
hasBle = payload["hasBle"] as? Boolean ?: false, |
||||
|
) |
||||
|
|
||||
|
"scanStatus" -> onScanStatus( |
||||
|
ble = payload["ble"] as? Boolean ?: false, |
||||
|
started = payload["started"] as? Boolean ?: false, |
||||
|
) |
||||
|
|
||||
|
"deviceFound" -> onDeviceFound(payload) |
||||
|
"bondStatus" -> onBondStatus( |
||||
|
address = payload["address"] as? String ?: "", |
||||
|
status = payload["status"] as? Int ?: 0, |
||||
|
) |
||||
|
"rcspInit" -> onRcspInit( |
||||
|
address = payload["address"] as? String ?: "", |
||||
|
code = payload["code"] as? Int ?: -1, |
||||
|
) |
||||
|
"connectionState" -> onConnectionState( |
||||
|
address = payload["address"] as? String ?: "", |
||||
|
state = payload["state"] as? Int ?: -1, |
||||
|
) |
||||
|
|
||||
|
"battery" -> onBattery( |
||||
|
address = payload["address"] as? String, |
||||
|
level = payload["level"] as? Int, |
||||
|
) |
||||
|
"phoneCallStatus" -> onPhoneCallStatus( |
||||
|
address = payload["address"] as? String, |
||||
|
status = payload["status"] as? Int ?: 0, |
||||
|
) |
||||
|
"voiceMode" -> onVoiceMode( |
||||
|
address = payload["address"] as? String, |
||||
|
modeId = payload["modeId"] as? Int, |
||||
|
) |
||||
|
|
||||
|
"volume" -> onVolume(payload) |
||||
|
"musicName" -> onMusicName( |
||||
|
address = payload["address"] as? String, |
||||
|
name = payload["name"] as? String, |
||||
|
) |
||||
|
"musicStatus" -> onMusicStatus(payload) |
||||
|
"playMode" -> onPlayMode( |
||||
|
address = payload["address"] as? String, |
||||
|
mode = payload["mode"] as? Int, |
||||
|
) |
||||
|
|
||||
|
"expandFunction" -> onExpandFunction(payload) |
||||
|
|
||||
|
"translationAudio" -> { |
||||
|
onTranslationAudio(payload) |
||||
|
onTranslationEvent(payload) |
||||
|
} |
||||
|
"translationResult" -> { |
||||
|
onTranslationResult(payload) |
||||
|
onTranslationEvent(payload) |
||||
|
} |
||||
|
"translationError" -> { |
||||
|
onTranslationError(payload) |
||||
|
onTranslationEvent(payload) |
||||
|
} |
||||
|
"translationLog" -> { |
||||
|
onTranslationLog(payload) |
||||
|
onTranslationEvent(payload) |
||||
|
} |
||||
|
|
||||
|
"speechStart" -> onSpeechStart(payload) |
||||
|
"speechAudio" -> onSpeechAudio(payload) |
||||
|
"speechEnd" -> onSpeechEnd(payload) |
||||
|
"speechError" -> onSpeechError(payload) |
||||
|
|
||||
|
"otaState" -> onOtaState(payload) |
||||
|
"otaError" -> onOtaError(payload) |
||||
|
|
||||
|
else -> onUnknown(payload) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
open fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) {} |
||||
|
open fun onScanStatus(ble: Boolean, started: Boolean) {} |
||||
|
open fun onDeviceFound(payload: Map<String, Any?>) {} |
||||
|
open fun onBondStatus(address: String, status: Int) {} |
||||
|
open fun onRcspInit(address: String, code: Int) {} |
||||
|
open fun onConnectionState(address: String, state: Int) {} |
||||
|
|
||||
|
open fun onBattery(address: String?, level: Int?) {} |
||||
|
open fun onPhoneCallStatus(address: String?, status: Int) {} |
||||
|
open fun onVoiceMode(address: String?, modeId: Int?) {} |
||||
|
|
||||
|
open fun onVolume(payload: Map<String, Any?>) {} |
||||
|
open fun onMusicName(address: String?, name: String?) {} |
||||
|
open fun onMusicStatus(payload: Map<String, Any?>) {} |
||||
|
open fun onPlayMode(address: String?, mode: Int?) {} |
||||
|
|
||||
|
open fun onExpandFunction(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
/** 翻译事件统一入口(兜底,默认细分回调之后还会触发一次) */ |
||||
|
open fun onTranslationEvent(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
/** payload["pcm"] 为 ByteArray;附带 streamId/sampleRate/seq/tsMs/final 字段 */ |
||||
|
open fun onTranslationAudio(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
/** payload 含 srcLang/srcText/destLang/destText/requestId */ |
||||
|
open fun onTranslationResult(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
/** payload 含 code/message */ |
||||
|
open fun onTranslationError(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
open fun onTranslationLog(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
/** 耳机检测到唤醒/语音助手触发;payload: voiceType, sampleRate, vadWay, address, tsMs */ |
||||
|
open fun onSpeechStart(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
/** 语音助手音频帧;payload: pcm(ByteArray), encoding, sampleRate, channels, address */ |
||||
|
open fun onSpeechAudio(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
/** 语音助手会话结束;payload: reason, message, address */ |
||||
|
open fun onSpeechEnd(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
open fun onSpeechError(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
/** OTA 进度/状态:state(String), sent(Long), total(Long), percent(Int) */ |
||||
|
open fun onOtaState(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
open fun onOtaError(payload: Map<String, Any?>) {} |
||||
|
|
||||
|
open fun onUnknown(payload: Map<String, Any?>) {} |
||||
|
} |
||||
@ -0,0 +1,63 @@ |
|||||
|
package com.jielihome.jielihome.audio |
||||
|
|
||||
|
import android.media.AudioAttributes |
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioManager |
||||
|
import android.media.AudioTrack |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
|
||||
|
/** |
||||
|
* 本地 PCM 播放(A2DP 路径)。用于音视频翻译模式:TTS 不回耳机,直接走 A2DP。 |
||||
|
*/ |
||||
|
class LocalPlayer( |
||||
|
private val sampleRate: Int = 16000, |
||||
|
private val channels: Int = 1, |
||||
|
) { |
||||
|
private var track: AudioTrack? = null |
||||
|
private val playing = AtomicBoolean(false) |
||||
|
|
||||
|
fun start() { |
||||
|
if (playing.get()) return |
||||
|
val cfg = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO |
||||
|
val minBuf = AudioTrack.getMinBufferSize(sampleRate, cfg, AudioFormat.ENCODING_PCM_16BIT) |
||||
|
|
||||
|
track = AudioTrack.Builder() |
||||
|
.setAudioAttributes( |
||||
|
AudioAttributes.Builder() |
||||
|
.setUsage(AudioAttributes.USAGE_MEDIA) |
||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) |
||||
|
.build() |
||||
|
) |
||||
|
.setAudioFormat( |
||||
|
AudioFormat.Builder() |
||||
|
.setSampleRate(sampleRate) |
||||
|
.setChannelMask(cfg) |
||||
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT) |
||||
|
.build() |
||||
|
) |
||||
|
.setBufferSizeInBytes(maxOf(minBuf, sampleRate * 2 * channels)) // 1s |
||||
|
.setTransferMode(AudioTrack.MODE_STREAM) |
||||
|
.build() |
||||
|
track?.play() |
||||
|
playing.set(true) |
||||
|
} |
||||
|
|
||||
|
fun feed(pcm: ByteArray) { |
||||
|
if (!playing.get()) return |
||||
|
runCatching { track?.write(pcm, 0, pcm.size) } |
||||
|
} |
||||
|
|
||||
|
fun stop() { |
||||
|
if (!playing.compareAndSet(true, false)) return |
||||
|
runCatching { track?.stop() } |
||||
|
runCatching { track?.release() } |
||||
|
track = null |
||||
|
} |
||||
|
|
||||
|
@Suppress("unused") |
||||
|
fun setVolumeFollowingSystemMedia() { |
||||
|
// hint: 用 AudioManager.STREAM_MUSIC 已由 USAGE_MEDIA 隐式选择 |
||||
|
@Suppress("DEPRECATION") |
||||
|
AudioManager.STREAM_MUSIC // no-op |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,104 @@ |
|||||
|
package com.jielihome.jielihome.audio |
||||
|
|
||||
|
import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback |
||||
|
import com.jieli.jl_audio_decode.callback.OnEncodeStreamCallback |
||||
|
import com.jieli.jl_audio_decode.opus.OpusManager |
||||
|
import com.jieli.jl_audio_decode.opus.model.OpusOption |
||||
|
|
||||
|
/** |
||||
|
* OPUS 流式解码器。 |
||||
|
* 输入:从耳机收到的 OPUS 编码帧 |
||||
|
* 输出:16kHz / mono PCM 流 |
||||
|
* |
||||
|
* 立体声场景把 [channel] 设 2 + [packetSize] 设 80。 |
||||
|
*/ |
||||
|
class OpusStreamDecoder( |
||||
|
private val channel: Int = 1, |
||||
|
private val packetSize: Int = 200, |
||||
|
private val sampleRate: Int = 16000, |
||||
|
private val hasHead: Boolean = false, |
||||
|
private val onPcm: (pcm: ByteArray) -> Unit, |
||||
|
private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> }, |
||||
|
) { |
||||
|
private val manager = OpusManager() |
||||
|
|
||||
|
@Volatile |
||||
|
private var started = false |
||||
|
|
||||
|
fun start() { |
||||
|
if (started) return |
||||
|
val option = OpusOption() |
||||
|
.setHasHead(hasHead) |
||||
|
.setChannel(channel) |
||||
|
.setPacketSize(packetSize) |
||||
|
.setSampleRate(sampleRate) |
||||
|
manager.startDecodeStream(option, object : OnDecodeStreamCallback { |
||||
|
override fun onDecodeStream(pcm: ByteArray) { runCatching { onPcm(pcm) } } |
||||
|
override fun onStart() {} |
||||
|
override fun onComplete(p0: String?) {} |
||||
|
override fun onError(code: Int, msg: String?) { |
||||
|
runCatching { onError(code, msg) } |
||||
|
} |
||||
|
}) |
||||
|
started = true |
||||
|
} |
||||
|
|
||||
|
fun feedEncoded(opus: ByteArray) { |
||||
|
if (!started) return |
||||
|
runCatching { manager.writeAudioStream(opus) } |
||||
|
} |
||||
|
|
||||
|
fun stop() { |
||||
|
if (!started) return |
||||
|
runCatching { manager.stopDecodeStream() } |
||||
|
runCatching { manager.release() } |
||||
|
started = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* OPUS 流式编码器。把 PCM 编码成 OPUS 帧后回调 [onOpus],由调用方包成 AudioData 回送给设备。 |
||||
|
*/ |
||||
|
class OpusStreamEncoder( |
||||
|
private val channel: Int = 1, |
||||
|
private val packetSize: Int = 200, |
||||
|
private val sampleRate: Int = 16000, |
||||
|
private val hasHead: Boolean = false, |
||||
|
private val onOpus: (opus: ByteArray) -> Unit, |
||||
|
private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> }, |
||||
|
) { |
||||
|
private val manager = OpusManager() |
||||
|
|
||||
|
@Volatile |
||||
|
private var started = false |
||||
|
|
||||
|
fun start() { |
||||
|
if (started) return |
||||
|
val option = OpusOption() |
||||
|
.setHasHead(hasHead) |
||||
|
.setChannel(channel) |
||||
|
.setPacketSize(packetSize) |
||||
|
.setSampleRate(sampleRate) |
||||
|
manager.startEncodeStream(option, object : OnEncodeStreamCallback { |
||||
|
override fun onEncodeStream(opus: ByteArray) { runCatching { onOpus(opus) } } |
||||
|
override fun onStart() {} |
||||
|
override fun onComplete(p0: String?) {} |
||||
|
override fun onError(code: Int, msg: String?) { |
||||
|
runCatching { onError(code, msg) } |
||||
|
} |
||||
|
}) |
||||
|
started = true |
||||
|
} |
||||
|
|
||||
|
fun feedPcm(pcm: ByteArray) { |
||||
|
if (!started) return |
||||
|
runCatching { manager.writeEncodeStream(pcm) } |
||||
|
} |
||||
|
|
||||
|
fun stop() { |
||||
|
if (!started) return |
||||
|
runCatching { manager.stopEncodeStream() } |
||||
|
runCatching { manager.release() } |
||||
|
started = false |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
package com.jielihome.jielihome.audio |
||||
|
|
||||
|
object PcmKit { |
||||
|
/** |
||||
|
* 把 16bit 立体声 PCM 拆为左/右两路单声道。 |
||||
|
* 入参字节流:L0_lo L0_hi R0_lo R0_hi L1_lo L1_hi R1_lo R1_hi ... |
||||
|
* 返回:Pair(leftMono, rightMono) |
||||
|
*/ |
||||
|
fun splitStereo16(pcm: ByteArray): Pair<ByteArray, ByteArray> { |
||||
|
val frames = pcm.size / 4 |
||||
|
val left = ByteArray(frames * 2) |
||||
|
val right = ByteArray(frames * 2) |
||||
|
var li = 0 |
||||
|
var ri = 0 |
||||
|
var i = 0 |
||||
|
while (i + 3 < pcm.size) { |
||||
|
left[li++] = pcm[i] |
||||
|
left[li++] = pcm[i + 1] |
||||
|
right[ri++] = pcm[i + 2] |
||||
|
right[ri++] = pcm[i + 3] |
||||
|
i += 4 |
||||
|
} |
||||
|
return left to right |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,102 @@ |
|||||
|
package com.jielihome.jielihome.audio |
||||
|
|
||||
|
import android.Manifest |
||||
|
import android.annotation.SuppressLint |
||||
|
import android.content.Context |
||||
|
import android.content.pm.PackageManager |
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioRecord |
||||
|
import android.media.MediaRecorder |
||||
|
import android.util.Log |
||||
|
import androidx.core.content.ContextCompat |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
import kotlin.concurrent.thread |
||||
|
|
||||
|
/** |
||||
|
* 手机麦克风采集,输出 16kHz / 16bit / mono PCM。 |
||||
|
* 每 20ms 切一帧(320 samples = 640 bytes)。 |
||||
|
* |
||||
|
* 用法: |
||||
|
* val mic = PhoneMicCapture(context) { pcm -> /* push to bridge */ } |
||||
|
* mic.start() |
||||
|
* ... |
||||
|
* mic.stop() |
||||
|
*/ |
||||
|
class PhoneMicCapture( |
||||
|
private val context: Context, |
||||
|
private val sampleRate: Int = 16000, |
||||
|
private val frameDurationMs: Int = 20, |
||||
|
private val onFrame: (pcm: ByteArray) -> Unit, |
||||
|
private val onError: (code: Int, msg: String) -> Unit = { _, _ -> }, |
||||
|
) { |
||||
|
private val running = AtomicBoolean(false) |
||||
|
private var recorder: AudioRecord? = null |
||||
|
private var worker: Thread? = null |
||||
|
|
||||
|
val frameSizeBytes: Int = sampleRate * frameDurationMs / 1000 * 2 /* 16bit */ |
||||
|
|
||||
|
@SuppressLint("MissingPermission") |
||||
|
fun start(): Boolean { |
||||
|
if (running.get()) return true |
||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) |
||||
|
!= PackageManager.PERMISSION_GRANTED |
||||
|
) { |
||||
|
Log.w(TAG, "RECORD_AUDIO permission missing") |
||||
|
onError(-101, "RECORD_AUDIO permission missing") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
val minBuf = AudioRecord.getMinBufferSize( |
||||
|
sampleRate, |
||||
|
AudioFormat.CHANNEL_IN_MONO, |
||||
|
AudioFormat.ENCODING_PCM_16BIT |
||||
|
) |
||||
|
val bufSize = maxOf(minBuf, frameSizeBytes * 4) |
||||
|
|
||||
|
val ar = AudioRecord( |
||||
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
||||
|
sampleRate, |
||||
|
AudioFormat.CHANNEL_IN_MONO, |
||||
|
AudioFormat.ENCODING_PCM_16BIT, |
||||
|
bufSize |
||||
|
) |
||||
|
if (ar.state != AudioRecord.STATE_INITIALIZED) { |
||||
|
ar.release() |
||||
|
Log.w(TAG, "AudioRecord init failed") |
||||
|
onError(-102, "AudioRecord init failed (state=${ar.state})") |
||||
|
return false |
||||
|
} |
||||
|
recorder = ar |
||||
|
running.set(true) |
||||
|
ar.startRecording() |
||||
|
|
||||
|
worker = thread(name = "device-jieli-mic", isDaemon = true) { |
||||
|
val frame = ByteArray(frameSizeBytes) |
||||
|
while (running.get()) { |
||||
|
val n = try { ar.read(frame, 0, frameSizeBytes) } catch (t: Throwable) { |
||||
|
onError(-103, "AudioRecord.read crashed: ${t.message}"); -1 |
||||
|
} |
||||
|
when { |
||||
|
n < 0 -> { onError(-104, "AudioRecord.read err code=$n"); break } |
||||
|
n == 0 -> continue |
||||
|
n == frameSizeBytes -> onFrame(frame.copyOf()) |
||||
|
else -> onFrame(frame.copyOf(n)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
fun stop() { |
||||
|
if (!running.compareAndSet(true, false)) return |
||||
|
runCatching { recorder?.stop() } |
||||
|
runCatching { recorder?.release() } |
||||
|
recorder = null |
||||
|
worker?.join(200) |
||||
|
worker = null |
||||
|
} |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "PhoneMicCapture" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
package com.jielihome.jielihome.bridge |
||||
|
|
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import com.jielihome.jielihome.api.JieliEventListener |
||||
|
import io.flutter.plugin.common.EventChannel |
||||
|
import java.util.concurrent.CopyOnWriteArrayList |
||||
|
|
||||
|
/** |
||||
|
* 事件总线。所有 forwarder / feature 调用 [send],由本类做 fan-out: |
||||
|
* - Flutter 端:通过 EventChannel.EventSink 推到 Dart |
||||
|
* - 原生端:通过 [JieliEventListener] 推给宿主直接订阅 |
||||
|
* |
||||
|
* Flutter / 原生 / 多个原生监听器 可以同时存在;payload 形态完全一致。 |
||||
|
*/ |
||||
|
class EventDispatcher : EventChannel.StreamHandler { |
||||
|
|
||||
|
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
|
|
||||
|
@Volatile |
||||
|
private var sink: EventChannel.EventSink? = null |
||||
|
|
||||
|
private val nativeListeners = CopyOnWriteArrayList<JieliEventListener>() |
||||
|
|
||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
||||
|
sink = events |
||||
|
} |
||||
|
|
||||
|
override fun onCancel(arguments: Any?) { |
||||
|
sink = null |
||||
|
} |
||||
|
|
||||
|
fun addNativeListener(listener: JieliEventListener) { |
||||
|
if (!nativeListeners.contains(listener)) nativeListeners.add(listener) |
||||
|
} |
||||
|
|
||||
|
fun removeNativeListener(listener: JieliEventListener) { |
||||
|
nativeListeners.remove(listener) |
||||
|
} |
||||
|
|
||||
|
fun send(payload: Map<String, Any?>) { |
||||
|
// 原生监听同步派发(在事件原始线程,不经主线程切换,方便高频音频帧) |
||||
|
for (l in nativeListeners) { |
||||
|
try { l.onEvent(payload) } catch (_: Throwable) { /* swallow listener errors */ } |
||||
|
} |
||||
|
// Flutter 端必须在主线程 |
||||
|
val s = sink ?: return |
||||
|
mainHandler.post { runCatching { s.success(payload) } } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,227 @@ |
|||||
|
package com.jielihome.jielihome.bridge |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jielihome.jielihome.core.JieliHomeServer |
||||
|
import io.flutter.plugin.common.MethodCall |
||||
|
import io.flutter.plugin.common.MethodChannel |
||||
|
|
||||
|
/** |
||||
|
* MethodChannel 路由层:把 Dart 调用映射到 server 内的 feature 模块。 |
||||
|
* Plugin 类不直接处理任何业务逻辑,只装配 router。 |
||||
|
*/ |
||||
|
class MethodRouter( |
||||
|
private val context: Context, |
||||
|
private val server: JieliHomeServer, |
||||
|
) : MethodChannel.MethodCallHandler { |
||||
|
|
||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { |
||||
|
try { |
||||
|
when (call.method) { |
||||
|
"getPlatformVersion" -> |
||||
|
result.success("Android ${android.os.Build.VERSION.RELEASE}") |
||||
|
|
||||
|
"initialize" -> { |
||||
|
server.initialize( |
||||
|
context = context, |
||||
|
multiDevice = call.argument<Boolean>("multiDevice") ?: true, |
||||
|
skipNoNameDev = call.argument<Boolean>("skipNoNameDev") ?: false, |
||||
|
enableLog = call.argument<Boolean>("enableLog") ?: false, |
||||
|
) |
||||
|
result.success(true) |
||||
|
} |
||||
|
|
||||
|
"startScan" -> { |
||||
|
val timeout = call.argument<Int>("timeoutMs") ?: 30000 |
||||
|
server.scanFeature.startScan(timeout) |
||||
|
.fold({ result.success(true) }, |
||||
|
{ result.error("SCAN_FAILED", it.message, null) }) |
||||
|
} |
||||
|
|
||||
|
"stopScan" -> { server.scanFeature.stopScan(); result.success(true) } |
||||
|
|
||||
|
"isScanning" -> result.success(server.scanFeature.isScanning()) |
||||
|
|
||||
|
"connect" -> { |
||||
|
val mac = call.argument<String>("address") |
||||
|
?: return result.error("BAD_ARG", "address required", null) |
||||
|
server.connectFeature.connect( |
||||
|
bleAddress = mac, |
||||
|
edrAddress = call.argument<String>("edrAddr"), |
||||
|
deviceType = call.argument<Int>("deviceType") ?: -1, |
||||
|
connectWay = call.argument<Int>("connectWay") ?: 0, |
||||
|
).fold({ result.success(true) }, |
||||
|
{ result.error("CONNECT_FAILED", it.message, null) }) |
||||
|
} |
||||
|
|
||||
|
"disconnect" -> { |
||||
|
val mac = call.argument<String>("address") |
||||
|
?: return result.error("BAD_ARG", "address required", null) |
||||
|
server.connectFeature.disconnect(mac) |
||||
|
.fold({ result.success(true) }, |
||||
|
{ result.error("DISCONNECT_FAILED", it.message, null) }) |
||||
|
} |
||||
|
|
||||
|
"isConnected" -> { |
||||
|
val mac = call.argument<String>("address") |
||||
|
?: return result.error("BAD_ARG", "address required", null) |
||||
|
result.success(server.connectFeature.isConnected(mac)) |
||||
|
} |
||||
|
|
||||
|
"connectedDevice" -> { |
||||
|
val d = server.connectFeature.connectedDevice() |
||||
|
if (d == null) result.success(null) |
||||
|
else result.success( |
||||
|
mapOf( |
||||
|
"address" to d.address, |
||||
|
"name" to runCatching { d.name }.getOrNull() |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
"deviceSnapshot" -> { |
||||
|
val mac = call.argument<String>("address") |
||||
|
?: return result.error("BAD_ARG", "address required", null) |
||||
|
result.success(server.deviceInfoFeature.snapshot(mac)) |
||||
|
} |
||||
|
|
||||
|
"queryTargetInfo" -> { |
||||
|
val mac = call.argument<String>("address") |
||||
|
?: return result.error("BAD_ARG", "address required", null) |
||||
|
val mask = call.argument<Int>("mask") ?: 0x0F |
||||
|
server.deviceInfoFeature.queryTargetInfo( |
||||
|
mac, mask, |
||||
|
onSuccess = { result.success(it) }, |
||||
|
onError = { c, m -> result.error("TARGET_INFO_ERR", m, c) } |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
"sendCustomCmd" -> { |
||||
|
val mac = call.argument<String>("address") |
||||
|
?: return result.error("BAD_ARG", "address required", null) |
||||
|
val opCode = call.argument<Int>("opCode") |
||||
|
?: return result.error("BAD_ARG", "opCode required", null) |
||||
|
val payload = call.argument<ByteArray>("payload") ?: ByteArray(0) |
||||
|
server.customCmdFeature.send( |
||||
|
mac, opCode, payload, |
||||
|
onSuccess = { result.success(it) }, |
||||
|
onError = { c, m -> result.error("CUSTOM_CMD_ERR", m, c) } |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
"startTranslation" -> { |
||||
|
val modeId = call.argument<Int>("modeId") |
||||
|
?: return result.error("BAD_ARG", "modeId required", null) |
||||
|
@Suppress("UNCHECKED_CAST") |
||||
|
val args = (call.argument<Map<String, Any?>>("args") ?: emptyMap()) |
||||
|
server.translationFeature.start(modeId, args) |
||||
|
.fold({ result.success(true) }, |
||||
|
{ result.error("TRANSLATION_ERR", it.message, null) }) |
||||
|
} |
||||
|
|
||||
|
"stopTranslation" -> { |
||||
|
server.translationFeature.stop(); result.success(true) |
||||
|
} |
||||
|
|
||||
|
"translationStatus" -> result.success( |
||||
|
mapOf( |
||||
|
"working" to server.translationFeature.isWorking(), |
||||
|
"modeId" to server.translationFeature.currentModeId(), |
||||
|
"inputStreams" to server.translationFeature.currentInputStreams(), |
||||
|
"outputStreams" to server.translationFeature.currentOutputStreams() |
||||
|
) |
||||
|
) |
||||
|
|
||||
|
"feedTranslatedAudio" -> { |
||||
|
val streamId = call.argument<String>("streamId") |
||||
|
?: return result.error("BAD_ARG", "streamId required", null) |
||||
|
val pcm = call.argument<ByteArray>("pcm") |
||||
|
?: return result.error("BAD_ARG", "pcm required", null) |
||||
|
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
||||
|
val channels = call.argument<Int>("channels") ?: 1 |
||||
|
val bits = call.argument<Int>("bitsPerSample") ?: 16 |
||||
|
val isFinal = call.argument<Boolean>("final") ?: false |
||||
|
val ok = server.translationFeature.feedTranslatedAudio( |
||||
|
streamId, pcm, |
||||
|
com.jielihome.jielihome.feature.translation.AudioFormat(sampleRate, channels, bits), |
||||
|
isFinal |
||||
|
) |
||||
|
result.success(ok) |
||||
|
} |
||||
|
|
||||
|
"feedTranslationResult" -> { |
||||
|
server.translationFeature.feedTranslationResult( |
||||
|
srcLang = call.argument<String>("srcLang"), |
||||
|
srcText = call.argument<String>("srcText"), |
||||
|
destLang = call.argument<String>("destLang"), |
||||
|
destText = call.argument<String>("destText"), |
||||
|
requestId = call.argument<String>("requestId"), |
||||
|
) |
||||
|
result.success(true) |
||||
|
} |
||||
|
|
||||
|
"isSupportCallTranslationWithStereo" -> { |
||||
|
val mac = call.argument<String>("address") |
||||
|
result.success(server.translationFeature.isSupportCallTranslationWithStereo(mac)) |
||||
|
} |
||||
|
|
||||
|
"feedAudioFilePcm" -> { |
||||
|
val pcm = call.argument<ByteArray>("pcm") |
||||
|
?: return result.error("BAD_ARG", "pcm required", null) |
||||
|
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
||||
|
result.success(server.translationFeature.feedAudioFilePcm(pcm, sampleRate)) |
||||
|
} |
||||
|
|
||||
|
"speechIsRecording" -> |
||||
|
result.success(server.speechFeature.isRecording(call.argument<String>("address"))) |
||||
|
|
||||
|
"speechStart" -> { |
||||
|
server.speechFeature.start( |
||||
|
address = call.argument<String>("address"), |
||||
|
voiceType = call.argument<Int>("voiceType") |
||||
|
?: com.jieli.bluetooth.bean.record.RecordParam.VOICE_TYPE_OPUS, |
||||
|
sampleRate = call.argument<Int>("sampleRate") |
||||
|
?: com.jieli.bluetooth.bean.record.RecordParam.SAMPLE_RATE_16K, |
||||
|
vadWay = call.argument<Int>("vadWay") |
||||
|
?: com.jieli.bluetooth.bean.record.RecordParam.VAD_WAY_DEVICE, |
||||
|
onResult = { ok, msg -> |
||||
|
if (ok) result.success(true) |
||||
|
else result.error("SPEECH_START_ERR", msg, null) |
||||
|
} |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
"speechStop" -> { |
||||
|
server.speechFeature.stop( |
||||
|
address = call.argument<String>("address"), |
||||
|
reason = call.argument<Int>("reason") |
||||
|
?: com.jieli.bluetooth.bean.record.RecordState.REASON_NORMAL, |
||||
|
onResult = { ok, msg -> |
||||
|
if (ok) result.success(true) |
||||
|
else result.error("SPEECH_STOP_ERR", msg, null) |
||||
|
} |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
"otaStart" -> { |
||||
|
val path = call.argument<String>("firmwareFilePath") |
||||
|
?: return result.error("BAD_ARG", "firmwareFilePath required", null) |
||||
|
server.otaFeature.start( |
||||
|
address = call.argument<String>("address"), |
||||
|
firmwareFilePath = path, |
||||
|
blockSize = call.argument<Int>("blockSize") ?: 512, |
||||
|
fileFlagBytes = call.argument<ByteArray>("fileFlag") ?: ByteArray(0), |
||||
|
) |
||||
|
result.success(true) |
||||
|
} |
||||
|
|
||||
|
"otaCancel" -> { server.otaFeature.cancel(); result.success(true) } |
||||
|
|
||||
|
"otaIsRunning" -> result.success(server.otaFeature.isRunning()) |
||||
|
|
||||
|
else -> result.notImplemented() |
||||
|
} |
||||
|
} catch (t: Throwable) { |
||||
|
result.error("PLUGIN_ERR", t.message, t.stackTraceToString()) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,195 @@ |
|||||
|
package com.jielihome.jielihome.core |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jieli.bluetooth.bean.BluetoothOption |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.impl.rcsp.RCSPController |
||||
|
import com.jieli.bluetooth.utils.JL_Log |
||||
|
import com.jielihome.jielihome.api.JieliEventListener |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
import com.jielihome.jielihome.event.BluetoothEventForwarder |
||||
|
import com.jielihome.jielihome.event.CustomEventForwarder |
||||
|
import com.jielihome.jielihome.event.DeviceInfoEventForwarder |
||||
|
import com.jielihome.jielihome.event.MediaEventForwarder |
||||
|
import com.jielihome.jielihome.feature.ConnectFeature |
||||
|
import com.jielihome.jielihome.feature.CustomCmdFeature |
||||
|
import com.jielihome.jielihome.feature.DeviceInfoFeature |
||||
|
import com.jielihome.jielihome.feature.ScanFeature |
||||
|
import com.jielihome.jielihome.feature.ota.OtaFeature |
||||
|
import com.jielihome.jielihome.feature.translation.EventChannelAudioBridge |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationFeature |
||||
|
import com.jielihome.jielihome.feature.voice.SpeechFeature |
||||
|
|
||||
|
/** |
||||
|
* 全局单例。原生层和 Flutter 层共用同一个 server。 |
||||
|
* |
||||
|
* 原生用法: |
||||
|
* ``` |
||||
|
* val server = JieliHomeServer.get() |
||||
|
* server.initialize(context, multiDevice = true, skipNoNameDev = false, enableLog = true) |
||||
|
* server.addEventListener(myListener) // 订阅事件 |
||||
|
* server.scanFeature.startScan(30_000) |
||||
|
* server.translationFeature.start(MODE_CALL_TRANSLATION, mapOf("address" to mac)) |
||||
|
* server.translationFeature.feedTranslatedAudio("uplink", pcm, AudioFormat(), false) |
||||
|
* ``` |
||||
|
* |
||||
|
* Flutter 侧由 [com.jielihome.jielihome.JielihomePlugin] 自动绑定 EventChannel + MethodChannel。 |
||||
|
* 即使没有 Flutter,原生也能完整使用 server。 |
||||
|
*/ |
||||
|
class JieliHomeServer private constructor() { |
||||
|
|
||||
|
companion object { |
||||
|
@Volatile |
||||
|
private var instance: JieliHomeServer? = null |
||||
|
|
||||
|
@JvmStatic |
||||
|
fun get(): JieliHomeServer = instance ?: synchronized(this) { |
||||
|
instance ?: JieliHomeServer().also { instance = it } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Volatile |
||||
|
var initialized: Boolean = false |
||||
|
private set |
||||
|
|
||||
|
/** 事件总线,原生层通过 [addEventListener] 订阅;Flutter 自动注入 EventChannel sink */ |
||||
|
val dispatcher: EventDispatcher = EventDispatcher() |
||||
|
|
||||
|
private lateinit var btManager: JL_BluetoothManager |
||||
|
|
||||
|
private lateinit var bluetoothForwarder: BluetoothEventForwarder |
||||
|
private lateinit var deviceInfoForwarder: DeviceInfoEventForwarder |
||||
|
private lateinit var mediaForwarder: MediaEventForwarder |
||||
|
private lateinit var customForwarder: CustomEventForwarder |
||||
|
|
||||
|
lateinit var scanFeature: ScanFeature |
||||
|
private set |
||||
|
lateinit var connectFeature: ConnectFeature |
||||
|
private set |
||||
|
lateinit var deviceInfoFeature: DeviceInfoFeature |
||||
|
private set |
||||
|
lateinit var customCmdFeature: CustomCmdFeature |
||||
|
private set |
||||
|
lateinit var translationFeature: TranslationFeature |
||||
|
private set |
||||
|
lateinit var speechFeature: SpeechFeature |
||||
|
private set |
||||
|
lateinit var otaFeature: OtaFeature |
||||
|
private set |
||||
|
|
||||
|
/** 默认翻译音频桥(EventChannel 透传到 Dart);宿主 native 想完全自管时可调 |
||||
|
* [TranslationFeature.setBridge] 替换。 |
||||
|
* |
||||
|
* 暴露 getter 供 native 编排器(如 [JieliCallTranslationPort])在 enter/exit |
||||
|
* 时拿到原始引用做"装上自己的 capture bridge / 退出后还原"切换。 |
||||
|
*/ |
||||
|
lateinit var defaultBridge: EventChannelAudioBridge |
||||
|
private set |
||||
|
|
||||
|
fun initialize( |
||||
|
context: Context, |
||||
|
multiDevice: Boolean = true, |
||||
|
skipNoNameDev: Boolean = false, |
||||
|
enableLog: Boolean = false, |
||||
|
) { |
||||
|
if (initialized) return |
||||
|
|
||||
|
JL_Log.configureLog(context, enableLog, enableLog) |
||||
|
|
||||
|
val option = BluetoothOption.createDefaultOption().also { |
||||
|
it.isUseMultiDevice = multiDevice |
||||
|
it.isSkipNoNameDev = skipNoNameDev |
||||
|
// 清空杰理协议广播过滤头:默认值会让 SDK 只回调匹配杰理私有广播头的设备, |
||||
|
// 导致 onDiscovery 在普通 BLE 设备 / 测试场景下完全不触发。 |
||||
|
it.scanFilterData = "" |
||||
|
it.otaScanFilterData = "" |
||||
|
} |
||||
|
RCSPController.init(context, option) |
||||
|
|
||||
|
btManager = RCSPController.getInstance().bluetoothManager |
||||
|
?: error("RCSPController.bluetoothManager is null after init") |
||||
|
|
||||
|
// 事件转发层 |
||||
|
bluetoothForwarder = BluetoothEventForwarder(btManager, dispatcher).also { it.attach() } |
||||
|
deviceInfoForwarder = DeviceInfoEventForwarder(btManager, dispatcher).also { it.attach() } |
||||
|
mediaForwarder = MediaEventForwarder(btManager, dispatcher).also { it.attach() } |
||||
|
customForwarder = CustomEventForwarder(btManager, dispatcher).also { it.attach() } |
||||
|
|
||||
|
// 业务功能层 |
||||
|
scanFeature = ScanFeature(btManager) |
||||
|
connectFeature = ConnectFeature(btManager) |
||||
|
deviceInfoFeature = DeviceInfoFeature(btManager) |
||||
|
customCmdFeature = CustomCmdFeature(btManager) |
||||
|
translationFeature = TranslationFeature(context.applicationContext, btManager, connectFeature) |
||||
|
speechFeature = SpeechFeature(btManager, connectFeature, dispatcher).also { it.attach() } |
||||
|
otaFeature = OtaFeature(btManager, connectFeature, dispatcher) |
||||
|
|
||||
|
// 默认音频桥:EventChannel;injector 把 Dart 推过来的 PCM 路由回当前 ModeHandler |
||||
|
defaultBridge = EventChannelAudioBridge(dispatcher) { _, streamId, pcm, fmt, isFinal -> |
||||
|
translationFeature.feedTranslatedAudio(streamId, pcm, fmt, isFinal) |
||||
|
} |
||||
|
translationFeature.setBridge(defaultBridge) |
||||
|
|
||||
|
// 内部级联收尾:连接断开 / 来电状态变化时,自动停止当前翻译,避免脏状态 |
||||
|
dispatcher.addNativeListener(internalCleanupListener) |
||||
|
|
||||
|
initialized = true |
||||
|
} |
||||
|
|
||||
|
/** 监听若干生命周期事件并触发自动收尾 */ |
||||
|
private val internalCleanupListener = object : com.jielihome.jielihome.api.JieliEventListener { |
||||
|
override fun onEvent(payload: Map<String, Any?>) { |
||||
|
when (payload["type"] as? String) { |
||||
|
"connectionState" -> { |
||||
|
val state = payload["state"] as? Int ?: return |
||||
|
// StateCode.CONNECTION_DISCONNECT == 0 |
||||
|
if (state == 0 && translationFeature.isWorking()) { |
||||
|
translationFeature.stop() |
||||
|
} |
||||
|
} |
||||
|
"phoneCallStatus" -> { |
||||
|
// 来电/通话进行中 (status != 0) 且当前不是通话翻译,自动 exit |
||||
|
val status = payload["status"] as? Int ?: return |
||||
|
if (status == 0) return |
||||
|
val mid = translationFeature.currentModeId() ?: return |
||||
|
val isCallMode = mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION || |
||||
|
mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO |
||||
|
if (!isCallMode && translationFeature.isWorking()) { |
||||
|
translationFeature.stop() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 原生事件订阅 */ |
||||
|
fun addEventListener(listener: JieliEventListener) = dispatcher.addNativeListener(listener) |
||||
|
fun removeEventListener(listener: JieliEventListener) = dispatcher.removeNativeListener(listener) |
||||
|
|
||||
|
/** 替换翻译音频桥(完全 native 直连翻译服务时使用) */ |
||||
|
fun setTranslationBridge(bridge: TranslationAudioBridge) { |
||||
|
translationFeature.setBridge(bridge) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 通话翻译能力端口([com.aiagent.device_plugin_interface.DeviceCallTranslationPort] |
||||
|
* 的杰理实现)。延迟创建;翻译编排器(translate_server native)通过 |
||||
|
* [DeviceSession.invokeFeature("jieli.callTranslationPort")] 或类似入口拿到引用。 |
||||
|
*/ |
||||
|
val callTranslationPort: com.jielihome.jielihome.feature.translation.JieliCallTranslationPort by lazy { |
||||
|
com.jielihome.jielihome.feature.translation.JieliCallTranslationPort(this) |
||||
|
} |
||||
|
|
||||
|
fun shutdown() { |
||||
|
if (!initialized) return |
||||
|
runCatching { otaFeature.cancel() } |
||||
|
runCatching { translationFeature.stop() } |
||||
|
runCatching { speechFeature.detach() } |
||||
|
runCatching { bluetoothForwarder.detach() } |
||||
|
runCatching { deviceInfoForwarder.detach() } |
||||
|
runCatching { mediaForwarder.detach() } |
||||
|
runCatching { customForwarder.detach() } |
||||
|
initialized = false |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,114 @@ |
|||||
|
package com.jielihome.jielihome.event |
||||
|
|
||||
|
import android.annotation.SuppressLint |
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import android.util.Log |
||||
|
import com.jieli.bluetooth.bean.BleScanMessage |
||||
|
import com.jieli.bluetooth.constant.BluetoothConstant |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.interfaces.bluetooth.BluetoothCallbackImpl |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspCallback |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
|
||||
|
private const val TAG = "JLBtEvtFwd" |
||||
|
|
||||
|
/** |
||||
|
* 蓝牙基础事件:适配器开关、扫描状态、设备发现、配对、RCSP init、连接状态。 |
||||
|
* 与设备「业务数据」(电量、音乐等)解耦。 |
||||
|
*/ |
||||
|
class BluetoothEventForwarder( |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
private val btCallback = object : BluetoothCallbackImpl() { |
||||
|
override fun onAdapterStatus(bEnabled: Boolean, bHasBle: Boolean) { |
||||
|
Log.i(TAG, "onAdapterStatus enabled=$bEnabled hasBle=$bHasBle") |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "adapterStatus", "enabled" to bEnabled, "hasBle" to bHasBle) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onDiscoveryStatus(bBle: Boolean, bStart: Boolean) { |
||||
|
Log.i(TAG, "onDiscoveryStatus ble=$bBle started=$bStart") |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "scanStatus", "ble" to bBle, "started" to bStart) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
@SuppressLint("MissingPermission") |
||||
|
override fun onDiscovery(device: BluetoothDevice?, msg: BleScanMessage?) { |
||||
|
Log.i(TAG, "onDiscovery raw: device=$device msg=$msg") |
||||
|
if (device == null) { |
||||
|
Log.w(TAG, "onDiscovery 跳过 device==null") |
||||
|
return |
||||
|
} |
||||
|
// 只放行杰理协议设备(vid == JL_VID=1494)。 |
||||
|
// 其它厂商广播(Apple vid=76、Microsoft vid=6 等)跳过,避免列表里出来一堆不能连的设备。 |
||||
|
if (msg == null || msg.vid != BluetoothConstant.JL_VID) { |
||||
|
Log.d(TAG, "onDiscovery 跳过非杰理: addr=${device.address} vid=${msg?.vid}") |
||||
|
return |
||||
|
} |
||||
|
val name = try { device.name } catch (_: SecurityException) { null } ?: "" |
||||
|
Log.i( |
||||
|
TAG, |
||||
|
"onDiscovery deliver name='$name' addr=${device.address} " + |
||||
|
"edr=${msg?.edrAddr} type=${msg?.deviceType} way=${msg?.connectWay} rssi=${msg?.rssi}" |
||||
|
) |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "deviceFound", |
||||
|
"name" to name, |
||||
|
"address" to device.address, |
||||
|
"edrAddr" to (msg?.edrAddr ?: ""), |
||||
|
"deviceType" to (msg?.deviceType ?: 0), |
||||
|
"connectWay" to (msg?.connectWay ?: 0), |
||||
|
"rssi" to (msg?.rssi ?: 0), |
||||
|
// BleScanMessage 已经包含双耳/盒子电量及充电状态——直接透传, |
||||
|
// 上层就不需要等 RCSP 拉快照了。0 视为「该位置无数据」(如单耳设备的 rightDeviceQuantity) |
||||
|
"leftBattery" to (msg?.leftDeviceQuantity ?: 0), |
||||
|
"rightBattery" to (msg?.rightDeviceQuantity ?: 0), |
||||
|
"caseBattery" to (msg?.chargingBinQuantity ?: 0), |
||||
|
"leftCharging" to (msg?.isLeftCharging ?: false), |
||||
|
"rightCharging" to (msg?.isRightCharging ?: false), |
||||
|
"caseCharging" to (msg?.isDeviceCharging ?: false) |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onBondStatus(device: BluetoothDevice?, status: Int) { |
||||
|
if (device == null) return |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "bondStatus", "address" to device.address, "status" to status) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private val rcspCallback = object : OnRcspCallback() { |
||||
|
override fun onRcspInit(device: BluetoothDevice?, code: Int) { |
||||
|
if (device == null) return |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "rcspInit", "address" to device.address, "code" to code) |
||||
|
) |
||||
|
if (code != 0) btManager.disconnect(device) |
||||
|
} |
||||
|
|
||||
|
override fun onConnectStateChange(device: BluetoothDevice?, status: Int) { |
||||
|
if (device == null) return |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "connectionState", "address" to device.address, "state" to status) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun attach() { |
||||
|
Log.i(TAG, "attach: 注册 btCallback / rcspCallback") |
||||
|
btManager.addEventListener(btCallback) |
||||
|
btManager.registerOnRcspCallback(rcspCallback) |
||||
|
} |
||||
|
|
||||
|
fun detach() { |
||||
|
Log.i(TAG, "detach: 注销 btCallback / rcspCallback") |
||||
|
btManager.removeEventListener(btCallback) |
||||
|
btManager.unregisterOnRcspCallback(rcspCallback) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,40 @@ |
|||||
|
package com.jielihome.jielihome.event |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import android.util.Base64 |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
|
||||
|
/** |
||||
|
* 厂商私有协议入口。 |
||||
|
* SDK 将所有未在内置协议表中匹配的「设备主动推」事件统一从 onExpandFunction 抛出, |
||||
|
* 这里把它原样透传到 Dart(payload 走 base64),由业务层自行解析。 |
||||
|
* |
||||
|
* 适用:自定义指令的设备->APP 反向通知,例如「检测到唤醒词」「自定义按键事件」。 |
||||
|
*/ |
||||
|
class CustomEventForwarder( |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
private val listener = object : OnRcspEventListener() { |
||||
|
override fun onExpandFunction(device: BluetoothDevice?, opCode: Int, payload: ByteArray?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "expandFunction", |
||||
|
"address" to device?.address, |
||||
|
"opCode" to opCode, |
||||
|
"payloadBase64" to payload?.let { Base64.encodeToString(it, Base64.NO_WRAP) } |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun attach() { |
||||
|
btManager.registerOnRcspEventListener(listener) |
||||
|
} |
||||
|
|
||||
|
fun detach() { |
||||
|
btManager.unregisterOnRcspEventListener(listener) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,73 @@ |
|||||
|
package com.jielihome.jielihome.event |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import com.jieli.bluetooth.bean.base.VoiceMode |
||||
|
import com.jieli.bluetooth.bean.device.status.BatteryInfo |
||||
|
import com.jieli.bluetooth.bean.device.voice.VoiceFunc |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
|
||||
|
/** |
||||
|
* 设备信息类事件(设备主动推送):电量、来电状态、语音模式/功能、双连状态。 |
||||
|
* |
||||
|
* 实现思路:注册一个 OnRcspEventListener 实例,按主题分配到不同 forwarder。 |
||||
|
* 这里只负责「设备级状态」,多媒体放 MediaEventForwarder,私有协议放 CustomEventForwarder。 |
||||
|
* |
||||
|
* 多个 forwarder 各注册一个独立 listener,SDK 会全部派发,互不干扰。 |
||||
|
*/ |
||||
|
class DeviceInfoEventForwarder( |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
private val listener = object : OnRcspEventListener() { |
||||
|
|
||||
|
override fun onBatteryChange(device: BluetoothDevice?, info: BatteryInfo?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "battery", |
||||
|
"address" to device?.address, |
||||
|
"level" to info?.battery |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onPhoneCallStatusChange(device: BluetoothDevice?, status: Int) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "phoneCallStatus", |
||||
|
"address" to device?.address, |
||||
|
"status" to status |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onCurrentVoiceMode(device: BluetoothDevice?, mode: VoiceMode?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "voiceMode", |
||||
|
"address" to device?.address, |
||||
|
"modeId" to mode?.mode |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onVoiceFunctionChange(device: BluetoothDevice?, func: VoiceFunc?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "voiceFunction", |
||||
|
"address" to device?.address, |
||||
|
"function" to func?.toString() |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun attach() { |
||||
|
btManager.registerOnRcspEventListener(listener) |
||||
|
} |
||||
|
|
||||
|
fun detach() { |
||||
|
btManager.unregisterOnRcspEventListener(listener) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,79 @@ |
|||||
|
package com.jielihome.jielihome.event |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import com.jieli.bluetooth.bean.device.eq.EqInfo |
||||
|
import com.jieli.bluetooth.bean.device.music.MusicNameInfo |
||||
|
import com.jieli.bluetooth.bean.device.music.MusicStatusInfo |
||||
|
import com.jieli.bluetooth.bean.device.music.PlayModeInfo |
||||
|
import com.jieli.bluetooth.bean.device.voice.VolumeInfo |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
|
||||
|
/** |
||||
|
* 多媒体事件:音乐名/状态/播放模式、EQ、音量。 |
||||
|
*/ |
||||
|
class MediaEventForwarder( |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
private val listener = object : OnRcspEventListener() { |
||||
|
|
||||
|
override fun onVolumeChange(device: BluetoothDevice?, info: VolumeInfo?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "volume", |
||||
|
"address" to device?.address, |
||||
|
"current" to info?.volume, |
||||
|
"max" to info?.maxVol |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onEqChange(device: BluetoothDevice?, info: EqInfo?) { |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "eq", "address" to device?.address, "info" to info?.toString()) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onMusicNameChange(device: BluetoothDevice?, info: MusicNameInfo?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "musicName", |
||||
|
"address" to device?.address, |
||||
|
"name" to info?.name |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onMusicStatusChange(device: BluetoothDevice?, info: MusicStatusInfo?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "musicStatus", |
||||
|
"address" to device?.address, |
||||
|
"playing" to info?.isPlay, |
||||
|
"currentTime" to info?.currentTime, |
||||
|
"totalTime" to info?.totalTime |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onPlayModeChange(device: BluetoothDevice?, info: PlayModeInfo?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "playMode", |
||||
|
"address" to device?.address, |
||||
|
"mode" to info?.playMode |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun attach() { |
||||
|
btManager.registerOnRcspEventListener(listener) |
||||
|
} |
||||
|
|
||||
|
fun detach() { |
||||
|
btManager.unregisterOnRcspEventListener(listener) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,56 @@ |
|||||
|
package com.jielihome.jielihome.feature |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import com.jieli.bluetooth.constant.BluetoothConstant |
||||
|
import com.jieli.bluetooth.constant.JL_DeviceType |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.utils.BluetoothUtil |
||||
|
|
||||
|
class ConnectFeature(private val btManager: JL_BluetoothManager) { |
||||
|
|
||||
|
fun connect( |
||||
|
bleAddress: String, |
||||
|
edrAddress: String?, |
||||
|
deviceType: Int, |
||||
|
connectWay: Int, |
||||
|
): Result<Unit> { |
||||
|
val target = resolveTarget(bleAddress, edrAddress, deviceType, connectWay) |
||||
|
?: return Result.failure(IllegalStateException("remote device not found")) |
||||
|
btManager.connect(target.first, target.second) |
||||
|
return Result.success(Unit) |
||||
|
} |
||||
|
|
||||
|
fun disconnect(address: String): Result<Unit> { |
||||
|
val device = BluetoothUtil.getRemoteDevice(address) |
||||
|
?: return Result.failure(IllegalStateException("remote device not found")) |
||||
|
btManager.disconnect(device) |
||||
|
return Result.success(Unit) |
||||
|
} |
||||
|
|
||||
|
fun isConnected(address: String): Boolean { |
||||
|
val device = BluetoothUtil.getRemoteDevice(address) ?: return false |
||||
|
return btManager.isConnectedBtDevice(device) |
||||
|
} |
||||
|
|
||||
|
fun connectedDevice(): BluetoothDevice? = btManager.connectedDevice |
||||
|
|
||||
|
fun deviceByAddress(address: String): BluetoothDevice? = |
||||
|
BluetoothUtil.getRemoteDevice(address) |
||||
|
|
||||
|
private fun resolveTarget( |
||||
|
bleAddress: String, |
||||
|
edrAddress: String?, |
||||
|
deviceType: Int, |
||||
|
connectWay: Int, |
||||
|
): Pair<BluetoothDevice, Int>? { |
||||
|
val ble = BluetoothUtil.getRemoteDevice(bleAddress) ?: return null |
||||
|
if (deviceType != JL_DeviceType.JL_DEVICE_TYPE_WATCH && |
||||
|
!edrAddress.isNullOrEmpty() && |
||||
|
(connectWay == BluetoothConstant.PROTOCOL_TYPE_SPP || |
||||
|
connectWay == BluetoothConstant.PROTOCOL_TYPE_GATT_OVER_BR_EDR) |
||||
|
) { |
||||
|
BluetoothUtil.getRemoteDevice(edrAddress)?.let { return it to connectWay } |
||||
|
} |
||||
|
return ble to BluetoothConstant.PROTOCOL_TYPE_BLE |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,42 @@ |
|||||
|
package com.jielihome.jielihome.feature |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import com.jieli.bluetooth.bean.base.BaseError |
||||
|
import com.jieli.bluetooth.bean.base.CommandBase |
||||
|
import com.jieli.bluetooth.bean.command.custom.CustomCmd |
||||
|
import com.jieli.bluetooth.bean.parameter.CustomParam |
||||
|
import com.jieli.bluetooth.bean.response.CustomResponse |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback |
||||
|
import com.jieli.bluetooth.utils.BluetoothUtil |
||||
|
|
||||
|
/** |
||||
|
* 厂商扩展指令通道。 |
||||
|
* 用法:业务方约定一个 opCode + payload 字节流,从这里下发;设备应答的 payload 透传回 Dart。 |
||||
|
* 设备「主动推」的扩展事件由 CustomEventForwarder.onExpandFunction 接收。 |
||||
|
*/ |
||||
|
class CustomCmdFeature(private val btManager: JL_BluetoothManager) { |
||||
|
|
||||
|
fun send( |
||||
|
address: String, |
||||
|
opCode: Int, |
||||
|
payload: ByteArray, |
||||
|
onSuccess: (ByteArray?) -> Unit, |
||||
|
onError: (Int, String?) -> Unit, |
||||
|
) { |
||||
|
val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) |
||||
|
?: return onError(-1, "remote device not found") |
||||
|
|
||||
|
val cmd = CustomCmd(opCode, CustomParam(payload)) |
||||
|
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { |
||||
|
override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) { |
||||
|
val r = resp?.response as? CustomResponse |
||||
|
onSuccess(r?.data) |
||||
|
} |
||||
|
|
||||
|
override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { |
||||
|
onError(err?.code ?: -2, err?.message) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,73 @@ |
|||||
|
package com.jielihome.jielihome.feature |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import com.jieli.bluetooth.bean.base.BaseError |
||||
|
import com.jieli.bluetooth.bean.base.CommandBase |
||||
|
import com.jieli.bluetooth.bean.command.GetTargetInfoCmd |
||||
|
import com.jieli.bluetooth.bean.parameter.GetTargetInfoParam |
||||
|
import com.jieli.bluetooth.bean.response.TargetInfoResponse |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback |
||||
|
import com.jieli.bluetooth.utils.BluetoothUtil |
||||
|
|
||||
|
/** |
||||
|
* 查询电量、版本号等设备静态信息(拉取式)。 |
||||
|
* 设备主动推的电量变化由 DeviceInfoEventForwarder 处理。 |
||||
|
*/ |
||||
|
class DeviceInfoFeature(private val btManager: JL_BluetoothManager) { |
||||
|
|
||||
|
/** 直接读 SDK 缓存(连接成功后已自动拉取过一次) */ |
||||
|
fun snapshot(address: String): Map<String, Any?>? { |
||||
|
val device = BluetoothUtil.getRemoteDevice(address) ?: return null |
||||
|
val info = btManager.getDeviceInfo(device) ?: return null |
||||
|
return mapOf( |
||||
|
"address" to device.address, |
||||
|
"battery" to info.quantity, |
||||
|
"volume" to info.volume, |
||||
|
"maxVolume" to info.maxVol, |
||||
|
"versionCode" to info.versionCode, |
||||
|
"versionName" to info.versionName, |
||||
|
"name" to info.name, |
||||
|
"vid" to info.vid, |
||||
|
"pid" to info.pid, |
||||
|
"uid" to info.uid, |
||||
|
"edrAddr" to info.edrAddr |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 主动向设备发指令拉取最新 TargetInfo。 |
||||
|
* @param mask 设备字段位掩码,常见 0x0F 拉常用字段;具体 mask 见杰理协议文档 |
||||
|
*/ |
||||
|
fun queryTargetInfo( |
||||
|
address: String, |
||||
|
mask: Int, |
||||
|
onSuccess: (Map<String, Any?>) -> Unit, |
||||
|
onError: (Int, String?) -> Unit, |
||||
|
) { |
||||
|
val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) |
||||
|
?: return onError(-1, "remote device not found") |
||||
|
|
||||
|
val cmd = GetTargetInfoCmd(GetTargetInfoParam(mask)) |
||||
|
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { |
||||
|
override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) { |
||||
|
val r = resp?.response as? TargetInfoResponse |
||||
|
?: return onError(-2, "no response") |
||||
|
onSuccess( |
||||
|
mapOf( |
||||
|
"address" to (dev?.address ?: address), |
||||
|
"battery" to r.quantity, |
||||
|
"volume" to r.volume, |
||||
|
"versionCode" to r.versionCode, |
||||
|
"versionName" to r.versionName, |
||||
|
"name" to r.name |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { |
||||
|
onError(err?.code ?: -3, err?.message) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.jielihome.jielihome.feature |
||||
|
|
||||
|
import com.jieli.bluetooth.constant.ErrorCode |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
|
||||
|
class ScanFeature(private val btManager: JL_BluetoothManager) { |
||||
|
|
||||
|
fun startScan(timeoutMs: Int): Result<Unit> { |
||||
|
val code = btManager.scan(timeoutMs) |
||||
|
return if (code == ErrorCode.ERR_NONE) Result.success(Unit) |
||||
|
else Result.failure(IllegalStateException("scan returned $code")) |
||||
|
} |
||||
|
|
||||
|
fun stopScan() { |
||||
|
btManager.stopScan() |
||||
|
} |
||||
|
|
||||
|
fun isScanning(): Boolean = btManager.bluetoothOperation.isScanning |
||||
|
} |
||||
@ -0,0 +1,264 @@ |
|||||
|
package com.jielihome.jielihome.feature.ota |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import com.jieli.bluetooth.bean.base.BaseError |
||||
|
import com.jieli.bluetooth.bean.base.CommandBase |
||||
|
import com.jieli.bluetooth.bean.command.ota.EnterUpdateModeCmd |
||||
|
import com.jieli.bluetooth.bean.command.ota.ExitUpdateModeCmd |
||||
|
import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateBlockCmd |
||||
|
import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateStatusCmd |
||||
|
import com.jieli.bluetooth.bean.command.ota.GetUpdateFileOffsetCmd |
||||
|
import com.jieli.bluetooth.bean.command.ota.InquireUpdateCmd |
||||
|
import com.jieli.bluetooth.bean.command.ota.NotifyUpdateContentSizeCmd |
||||
|
import com.jieli.bluetooth.bean.command.ota.RebootDeviceCmd |
||||
|
import com.jieli.bluetooth.bean.parameter.FirmwareUpdateBlockParam |
||||
|
import com.jieli.bluetooth.bean.parameter.InquireUpdateParam |
||||
|
import com.jieli.bluetooth.bean.parameter.NotifyUpdateContentSizeParam |
||||
|
import com.jieli.bluetooth.bean.parameter.RebootDeviceParam |
||||
|
import com.jieli.bluetooth.bean.response.EnterUpdateModeResponse |
||||
|
import com.jieli.bluetooth.bean.response.FirmwareUpdateBlockResponse |
||||
|
import com.jieli.bluetooth.bean.response.FirmwareUpdateStatusResponse |
||||
|
import com.jieli.bluetooth.bean.response.InquireUpdateResponse |
||||
|
import com.jieli.bluetooth.bean.response.UpdateFileOffsetResponse |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
import com.jielihome.jielihome.feature.ConnectFeature |
||||
|
import kotlinx.coroutines.CancellationException |
||||
|
import kotlinx.coroutines.CoroutineScope |
||||
|
import kotlinx.coroutines.Dispatchers |
||||
|
import kotlinx.coroutines.Job |
||||
|
import kotlinx.coroutines.SupervisorJob |
||||
|
import kotlinx.coroutines.delay |
||||
|
import kotlinx.coroutines.launch |
||||
|
import kotlinx.coroutines.suspendCancellableCoroutine |
||||
|
import java.io.File |
||||
|
import java.io.RandomAccessFile |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
import kotlin.coroutines.resume |
||||
|
import kotlin.coroutines.resumeWithException |
||||
|
|
||||
|
/** |
||||
|
* OTA 升级。 |
||||
|
* |
||||
|
* # 重要说明 |
||||
|
* **当前 SDK 包(jl_bluetooth_rcsp)只提供 OTA 协议的原始 Cmd,没有高层 OTA 管理器。** |
||||
|
* 完整 OTA 流(含 CRC 校验、断点续传、双备份切换)通常在杰理另发的 `jl_otalib_*.aar` |
||||
|
* 里实现。本实现是基于公开 cmd 的「最小可工作版」,**生产前需在真机验证**。 |
||||
|
* |
||||
|
* # 状态机 |
||||
|
* IDLE → INQUIRING → NOTIFYING_SIZE → ENTERING → TRANSFERRING(progress) → VERIFYING |
||||
|
* → REBOOTING → DONE / FAILED / CANCELLED |
||||
|
* |
||||
|
* # 数据流(推测的协议,需真机验证) |
||||
|
* 1. InquireUpdate(file flag) → device 决定能否升级 |
||||
|
* 2. NotifyUpdateContentSize(totalSize) → 告知固件包大小 |
||||
|
* 3. EnterUpdateMode → device 进入升级态 |
||||
|
* 4. Loop: |
||||
|
* GetUpdateFileOffset → device 告知它需要从哪个 offset 开始 |
||||
|
* FirmwareUpdateBlock(offset, len) → APP 发块(数据塞在响应里) |
||||
|
* until offset >= totalSize |
||||
|
* 5. FirmwareUpdateStatus → device 验证结果 |
||||
|
* 6. ExitUpdateMode + RebootDevice |
||||
|
* |
||||
|
* # 调用方式 |
||||
|
* server.otaFeature.start(address, "/sdcard/firmware.ufw") |
||||
|
* server.otaFeature.cancel() |
||||
|
*/ |
||||
|
class OtaFeature( |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val connectFeature: ConnectFeature, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) |
||||
|
private val running = AtomicBoolean(false) |
||||
|
private var job: Job? = null |
||||
|
|
||||
|
fun isRunning(): Boolean = running.get() |
||||
|
|
||||
|
fun start( |
||||
|
address: String? = null, |
||||
|
firmwareFilePath: String, |
||||
|
/** 块大小(设备可能在响应里给出建议值;这里只是默认) */ |
||||
|
blockSize: Int = 512, |
||||
|
/** 升级文件标记(杰理协议要求;常见空数组或自定义校验) */ |
||||
|
fileFlagBytes: ByteArray = ByteArray(0), |
||||
|
) { |
||||
|
if (!running.compareAndSet(false, true)) { |
||||
|
emitError(-1, "ota already running"); return |
||||
|
} |
||||
|
val device = address?.let { connectFeature.deviceByAddress(it) } |
||||
|
?: connectFeature.connectedDevice() |
||||
|
if (device == null) { |
||||
|
emitError(-2, "no connected device") |
||||
|
running.set(false); return |
||||
|
} |
||||
|
val file = File(firmwareFilePath) |
||||
|
if (!file.isFile || file.length() <= 0) { |
||||
|
emitError(-3, "firmware file invalid: $firmwareFilePath") |
||||
|
running.set(false); return |
||||
|
} |
||||
|
emitState(State.INQUIRING, 0, file.length()) |
||||
|
job = scope.launch { runFlow(device, file, blockSize, fileFlagBytes) } |
||||
|
} |
||||
|
|
||||
|
fun cancel() { |
||||
|
job?.cancel() |
||||
|
job = null |
||||
|
running.set(false) |
||||
|
emitState(State.CANCELLED, -1, 0) |
||||
|
} |
||||
|
|
||||
|
// ───── 内部主流程 ───── |
||||
|
|
||||
|
private suspend fun runFlow( |
||||
|
device: BluetoothDevice, |
||||
|
file: File, |
||||
|
blockSize: Int, |
||||
|
fileFlagBytes: ByteArray, |
||||
|
) { |
||||
|
try { |
||||
|
val totalSize = file.length() |
||||
|
|
||||
|
// 1. Inquire |
||||
|
val inquire: InquireUpdateResponse = sendCmd( |
||||
|
device, InquireUpdateCmd(InquireUpdateParam(fileFlagBytes)) |
||||
|
) |
||||
|
if (inquire.canUpdateFlag != 0) { |
||||
|
throw IllegalStateException("device refused update, flag=${inquire.canUpdateFlag}") |
||||
|
} |
||||
|
|
||||
|
// 2. NotifyContentSize |
||||
|
emitState(State.NOTIFYING_SIZE, 0, totalSize) |
||||
|
sendCmd<com.jieli.bluetooth.bean.base.CommonResponse>( |
||||
|
device, |
||||
|
NotifyUpdateContentSizeCmd(NotifyUpdateContentSizeParam(totalSize.toInt())) |
||||
|
) |
||||
|
|
||||
|
// 3. Enter update mode |
||||
|
emitState(State.ENTERING, 0, totalSize) |
||||
|
val enter: EnterUpdateModeResponse = sendCmd(device, EnterUpdateModeCmd()) |
||||
|
if (enter.canUpdateFlag != 0) { |
||||
|
throw IllegalStateException("enter update mode failed, flag=${enter.canUpdateFlag}") |
||||
|
} |
||||
|
|
||||
|
// 4. Block transfer loop |
||||
|
emitState(State.TRANSFERRING, 0, totalSize) |
||||
|
RandomAccessFile(file, "r").use { raf -> |
||||
|
var safety = 0 |
||||
|
while (running.get()) { |
||||
|
val ofs: UpdateFileOffsetResponse = sendCmd(device, GetUpdateFileOffsetCmd()) |
||||
|
val nextOffset = ofs.updateFileFlagOffset.toLong() |
||||
|
val askLen = ofs.updateFileFlagLen.takeIf { it > 0 } ?: blockSize |
||||
|
if (nextOffset >= totalSize) break |
||||
|
|
||||
|
val len = minOf(askLen.toLong(), totalSize - nextOffset).toInt() |
||||
|
val data = ByteArray(len) |
||||
|
raf.seek(nextOffset) |
||||
|
raf.readFully(data) |
||||
|
|
||||
|
val blockCmd = FirmwareUpdateBlockCmd( |
||||
|
FirmwareUpdateBlockParam(nextOffset.toInt(), len) |
||||
|
) |
||||
|
// 在响应里塞数据:杰理协议要求 APP 把块数据放在 response 里, |
||||
|
// 而不是 param 里(FirmwareUpdateBlockParam 没有 data 字段)。 |
||||
|
// 这里我们走 sendRcspResponse 模式:先把 cmd 当作"已收到的 device 请求" |
||||
|
// 处理,然后回响应。需要 SDK 内部支持;如果直接 sendRcspCommand 不行, |
||||
|
// 真机调试时切到 device-initiated 流(见类注释)。 |
||||
|
val resp = FirmwareUpdateBlockResponse().setFirmwareUpdateBlockData(data) |
||||
|
blockCmd.response = resp |
||||
|
sendBlock(device, blockCmd) |
||||
|
|
||||
|
val written = nextOffset + len |
||||
|
emitState(State.TRANSFERRING, written, totalSize) |
||||
|
safety++ |
||||
|
if (safety > 200_000) throw IllegalStateException("too many block iterations") |
||||
|
delay(0L) // yield |
||||
|
} |
||||
|
if (!running.get()) throw CancellationException() |
||||
|
} |
||||
|
|
||||
|
// 5. Verify |
||||
|
emitState(State.VERIFYING, totalSize, totalSize) |
||||
|
val status: FirmwareUpdateStatusResponse = sendCmd(device, FirmwareUpdateStatusCmd()) |
||||
|
if (status.result != 0) throw IllegalStateException("verify failed result=${status.result}") |
||||
|
|
||||
|
// 6. Exit + Reboot |
||||
|
emitState(State.REBOOTING, totalSize, totalSize) |
||||
|
runCatching { sendCmd<com.jieli.bluetooth.bean.response.ExitUpdateModeResponse>(device, ExitUpdateModeCmd()) } |
||||
|
runCatching { sendCmd<com.jieli.bluetooth.bean.response.RebootDeviceResponse>(device, RebootDeviceCmd(RebootDeviceParam(0))) } |
||||
|
|
||||
|
emitState(State.DONE, totalSize, totalSize) |
||||
|
} catch (ce: CancellationException) { |
||||
|
emitState(State.CANCELLED, -1, file.length()) |
||||
|
} catch (t: Throwable) { |
||||
|
emitError(-100, "ota failed: ${t.message}") |
||||
|
emitState(State.FAILED, -1, file.length()) |
||||
|
} finally { |
||||
|
running.set(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 普通 OTA 控制命令:APP 发,等设备响应 */ |
||||
|
private suspend inline fun <reified R> sendCmd( |
||||
|
device: BluetoothDevice, |
||||
|
cmd: CommandBase<*, *>, |
||||
|
): R = suspendCancellableCoroutine { cont -> |
||||
|
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { |
||||
|
override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { |
||||
|
@Suppress("UNCHECKED_CAST") |
||||
|
val r = resp?.response as? R |
||||
|
if (r != null) cont.resume(r) |
||||
|
else cont.resumeWithException(IllegalStateException("unexpected response")) |
||||
|
} |
||||
|
|
||||
|
override fun onErrCode(d: BluetoothDevice?, err: BaseError?) { |
||||
|
cont.resumeWithException(IllegalStateException("err ${err?.code}: ${err?.message}")) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
/** 块响应:把已经填好 response 的 cmd 通过 sendRcspResponse 回设备 */ |
||||
|
private suspend fun sendBlock( |
||||
|
device: BluetoothDevice, |
||||
|
cmd: FirmwareUpdateBlockCmd, |
||||
|
) = suspendCancellableCoroutine<Unit> { cont -> |
||||
|
try { |
||||
|
// 先尝试 sendRcspResponse(device-initiated 模式) |
||||
|
btManager.sendRcspResponse(device, cmd) |
||||
|
cont.resume(Unit) |
||||
|
} catch (t: Throwable) { |
||||
|
// 兜底:sendRcspCommand |
||||
|
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { |
||||
|
override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { cont.resume(Unit) } |
||||
|
override fun onErrCode(d: BluetoothDevice?, err: BaseError?) { |
||||
|
cont.resumeWithException(IllegalStateException("block err ${err?.code}: ${err?.message}")) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ───── 事件 ───── |
||||
|
|
||||
|
enum class State { IDLE, INQUIRING, NOTIFYING_SIZE, ENTERING, TRANSFERRING, VERIFYING, REBOOTING, DONE, FAILED, CANCELLED } |
||||
|
|
||||
|
private fun emitState(state: State, sent: Long, total: Long) { |
||||
|
val percent = if (total > 0 && sent in 0..total) (sent * 100.0 / total).toInt() else -1 |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "otaState", |
||||
|
"state" to state.name, |
||||
|
"sent" to sent, |
||||
|
"total" to total, |
||||
|
"percent" to percent, |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
private fun emitError(code: Int, msg: String?) { |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "otaError", "code" to code, "message" to msg) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,105 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation |
||||
|
|
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
|
||||
|
/** |
||||
|
* 默认桥实现:采集帧通过 EventChannel 推到 Dart(base64 PCM), |
||||
|
* Dart 把 PCM 喂给外部翻译服务,再通过 MethodChannel 回调 [TranslationFeature.feedTranslatedAudio]。 |
||||
|
* |
||||
|
* 16kHz / 16bit / 单声道 PCM 流量约 32KB/s,base64 后 ~43KB/s,跨通道开销可控。 |
||||
|
* 若宿主想完全 native 直连翻译服务,可实现自己的 [TranslationAudioBridge] 并通过 |
||||
|
* [TranslationFeature.setBridge] 替换。 |
||||
|
*/ |
||||
|
class EventChannelAudioBridge( |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
/** 由 TranslationFeature 注入:把 PCM 真正塞回当前 ModeHandler */ |
||||
|
var injector: TranslationAudioInjector? = null, |
||||
|
) : TranslationAudioBridge { |
||||
|
|
||||
|
override fun emitAudioFrame( |
||||
|
modeId: Int, |
||||
|
streamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
seq: Long, |
||||
|
tsMs: Long, |
||||
|
isFinal: Boolean, |
||||
|
) { |
||||
|
// EventChannel 原生支持 byte[] → Uint8List,直接传字节流,避免 base64 33% 体积膨胀。 |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "translationAudio", |
||||
|
"modeId" to modeId, |
||||
|
"streamId" to streamId, |
||||
|
"sampleRate" to format.sampleRate, |
||||
|
"channels" to format.channels, |
||||
|
"bitsPerSample" to format.bitsPerSample, |
||||
|
"seq" to seq, |
||||
|
"tsMs" to tsMs, |
||||
|
"final" to isFinal, |
||||
|
"pcm" to pcm, |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun emitTranslationResult( |
||||
|
modeId: Int, |
||||
|
srcLang: String?, |
||||
|
srcText: String?, |
||||
|
destLang: String?, |
||||
|
destText: String?, |
||||
|
requestId: String?, |
||||
|
) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "translationResult", |
||||
|
"modeId" to modeId, |
||||
|
"srcLang" to srcLang, |
||||
|
"srcText" to srcText, |
||||
|
"destLang" to destLang, |
||||
|
"destText" to destText, |
||||
|
"requestId" to requestId, |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun emitLog(modeId: Int, content: String) { |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "translationLog", "modeId" to modeId, "content" to content) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun emitError(modeId: Int, code: Int, message: String?) { |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "translationError", |
||||
|
"modeId" to modeId, |
||||
|
"code" to code, |
||||
|
"message" to message |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun feedTranslatedAudio( |
||||
|
modeId: Int, |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
return injector?.inject(modeId, outputStreamId, pcm, format, isFinal) ?: false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 内部接口:TranslationFeature 决定当前 ModeHandler 是谁,由它消费 PCM。 |
||||
|
*/ |
||||
|
fun interface TranslationAudioInjector { |
||||
|
fun inject( |
||||
|
modeId: Int, |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean |
||||
|
} |
||||
@ -0,0 +1,210 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation |
||||
|
|
||||
|
import android.util.Log |
||||
|
import com.aiagent.device_plugin_interface.CallAudioCodec |
||||
|
import com.aiagent.device_plugin_interface.CallAudioFormat |
||||
|
import com.aiagent.device_plugin_interface.CallAudioFrame |
||||
|
import com.aiagent.device_plugin_interface.CallTranslationError |
||||
|
import com.aiagent.device_plugin_interface.CallTranslationLeg |
||||
|
import com.aiagent.device_plugin_interface.DeviceCallTranslationPort |
||||
|
import com.aiagent.device_plugin_interface.TranslatedAudioFrame |
||||
|
import com.jielihome.jielihome.core.JieliHomeServer |
||||
|
import kotlinx.coroutines.channels.BufferOverflow |
||||
|
import kotlinx.coroutines.flow.Flow |
||||
|
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
|
import kotlinx.coroutines.flow.asSharedFlow |
||||
|
|
||||
|
/** |
||||
|
* `DeviceCallTranslationPort` 的杰理实现。 |
||||
|
* |
||||
|
* 当前阶段策略:**只支持 PCM_S16LE / 16kHz / mono / 20ms**。 |
||||
|
* 新版 SDK 已经在 native 内部把耳机端 OPUS 解码成 PCM 上推(见 [TranslationFeature] |
||||
|
* 的 `mode` handlers),上层零编解码即可贯通。OPUS 直通模式后续按需补。 |
||||
|
* |
||||
|
* # enter / exit 与 SDK bridge 的关系 |
||||
|
* - [enter] 时把 [JieliHomeServer.defaultBridge](EventChannel → Dart 的桥)替换为 |
||||
|
* 本实现内的 capture bridge:拦截 `IN_UPLINK` / `IN_DOWNLINK` 上推的 PCM 帧, |
||||
|
* 翻译成 [CallAudioFrame] 写入 [audioFrames] 流;同时把 `emitError` 接到 |
||||
|
* [errors] 流。 |
||||
|
* - 期间 Dart 的 `device_jieli/event` 不会再收到翻译音频事件——这正是"完全 native |
||||
|
* 直连翻译服务"的预期路径,与 `translate_server` native 编排器配套。 |
||||
|
* - [exit] 时还原默认桥,Dart 端的 EventChannel 恢复正常使用。 |
||||
|
* |
||||
|
* # TTS 回灌 |
||||
|
* 编排器把翻译完成的 PCM 通过 [reportTranslated] 回写: |
||||
|
* - `CallTranslationLeg.UPLINK` → SDK 的 `OUT_UPLINK`(对方耳机听) |
||||
|
* - `CallTranslationLeg.DOWNLINK` → SDK 的 `OUT_DOWNLINK`(本机用户耳机听) |
||||
|
* |
||||
|
* # 线程安全 |
||||
|
* `enter`/`exit`/`reportTranslated` 通过 `synchronized(this)` 互斥; |
||||
|
* SharedFlow `tryEmit` 自身线程安全,capture bridge 可被任意线程回调。 |
||||
|
*/ |
||||
|
class JieliCallTranslationPort( |
||||
|
private val server: JieliHomeServer, |
||||
|
) : DeviceCallTranslationPort { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "JieliCallTransPort" |
||||
|
private val PCM_16K_MONO_20MS = CallAudioFormat.PCM_S16LE_16K_MONO_20MS |
||||
|
} |
||||
|
|
||||
|
private val _audioFrames = MutableSharedFlow<CallAudioFrame>( |
||||
|
replay = 0, |
||||
|
extraBufferCapacity = 128, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
private val _errors = MutableSharedFlow<CallTranslationError>( |
||||
|
replay = 0, |
||||
|
extraBufferCapacity = 16, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
|
||||
|
@Volatile private var entered = false |
||||
|
@Volatile private var savedBridge: TranslationAudioBridge? = null |
||||
|
|
||||
|
override fun supportedSourceFormats(): Set<CallAudioFormat> = setOf(PCM_16K_MONO_20MS) |
||||
|
override fun supportedSinkFormats(): Set<CallAudioFormat> = setOf(PCM_16K_MONO_20MS) |
||||
|
|
||||
|
override val audioFrames: Flow<CallAudioFrame> = _audioFrames.asSharedFlow() |
||||
|
override val errors: Flow<CallTranslationError> = _errors.asSharedFlow() |
||||
|
|
||||
|
@Synchronized |
||||
|
override fun enter(sourceFormat: CallAudioFormat) { |
||||
|
require(sourceFormat == PCM_16K_MONO_20MS) { |
||||
|
"JieliCallTranslationPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)" |
||||
|
} |
||||
|
check(!entered) { "device.call_translation.busy" } |
||||
|
|
||||
|
val translationFeature = server.translationFeature |
||||
|
|
||||
|
// 调试:统计 SDK 给我们推了多少帧、按 leg 分桶;每秒打一次。 |
||||
|
val countByStream = java.util.concurrent.ConcurrentHashMap<String, Long>() |
||||
|
var lastReportMs = System.currentTimeMillis() |
||||
|
|
||||
|
val captureBridge = object : TranslationAudioBridge { |
||||
|
override fun emitAudioFrame( |
||||
|
modeId: Int, |
||||
|
streamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
seq: Long, |
||||
|
tsMs: Long, |
||||
|
isFinal: Boolean, |
||||
|
) { |
||||
|
// 调试用:周期性打印 SDK 上推帧统计,确认设备端有没有真出 PCM。 |
||||
|
countByStream.merge(streamId, 1L) { old, _ -> old + 1L } |
||||
|
val now = System.currentTimeMillis() |
||||
|
if (now - lastReportMs >= 1000L) { |
||||
|
Log.d(TAG, "SDK frame stats (last 1s): $countByStream fmt=${format.sampleRate}Hz/${format.channels}ch") |
||||
|
countByStream.clear() |
||||
|
lastReportMs = now |
||||
|
} |
||||
|
val leg = when (streamId) { |
||||
|
TranslationStreams.IN_UPLINK -> CallTranslationLeg.UPLINK |
||||
|
TranslationStreams.IN_DOWNLINK -> CallTranslationLeg.DOWNLINK |
||||
|
else -> return // ignore non-call streams in this mode |
||||
|
} |
||||
|
val frame = CallAudioFrame( |
||||
|
leg = leg, |
||||
|
codec = CallAudioCodec.PCM_S16LE, |
||||
|
sampleRate = format.sampleRate, |
||||
|
channels = format.channels, |
||||
|
bytes = pcm, |
||||
|
sequence = seq, |
||||
|
timestampUs = tsMs * 1000L, |
||||
|
) |
||||
|
if (!_audioFrames.tryEmit(frame)) { |
||||
|
Log.w(TAG, "audioFrames buffer overflow; dropped seq=$seq leg=$leg") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun emitTranslationResult( |
||||
|
modeId: Int, |
||||
|
srcLang: String?, srcText: String?, |
||||
|
destLang: String?, destText: String?, |
||||
|
requestId: String?, |
||||
|
) { |
||||
|
// 字幕在 native 编排器里由 agent 直接出,不依赖这个回调;忽略。 |
||||
|
} |
||||
|
|
||||
|
override fun emitLog(modeId: Int, content: String) { |
||||
|
Log.d(TAG, "translation log: modeId=$modeId $content") |
||||
|
} |
||||
|
|
||||
|
override fun emitError(modeId: Int, code: Int, message: String?) { |
||||
|
_errors.tryEmit( |
||||
|
CallTranslationError( |
||||
|
code = "device.feature_failed", |
||||
|
message = "code=$code msg=${message ?: ""}", |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun feedTranslatedAudio( |
||||
|
modeId: Int, |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
// capture bridge 不负责回灌——编排器走 [reportTranslated] 直接调 |
||||
|
// server.translationFeature.feedTranslatedAudio。 |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
savedBridge = server.defaultBridge |
||||
|
server.setTranslationBridge(captureBridge) |
||||
|
|
||||
|
val result = translationFeature.start( |
||||
|
TranslationModeIds.MODE_CALL_TRANSLATION, |
||||
|
emptyMap(), |
||||
|
) |
||||
|
if (result.isFailure) { |
||||
|
// 启动失败:还原桥并抛出 |
||||
|
savedBridge?.let { runCatching { server.setTranslationBridge(it) } } |
||||
|
savedBridge = null |
||||
|
throw IllegalStateException( |
||||
|
"failed to enter MODE_CALL_TRANSLATION: ${result.exceptionOrNull()?.message}", |
||||
|
result.exceptionOrNull(), |
||||
|
) |
||||
|
} |
||||
|
entered = true |
||||
|
Log.d(TAG, "entered MODE_CALL_TRANSLATION") |
||||
|
} |
||||
|
|
||||
|
@Synchronized |
||||
|
override fun reportTranslated(frame: TranslatedAudioFrame) { |
||||
|
check(entered) { "device.call_translation.not_active" } |
||||
|
require(frame.codec == CallAudioCodec.PCM_S16LE) { |
||||
|
"JieliCallTranslationPort only accepts PCM_S16LE for sink (got ${frame.codec})" |
||||
|
} |
||||
|
val streamId = when (frame.leg) { |
||||
|
CallTranslationLeg.UPLINK -> TranslationStreams.OUT_UPLINK |
||||
|
CallTranslationLeg.DOWNLINK -> TranslationStreams.OUT_DOWNLINK |
||||
|
} |
||||
|
val ok = server.translationFeature.feedTranslatedAudio( |
||||
|
outputStreamId = streamId, |
||||
|
pcm = frame.bytes, |
||||
|
format = AudioFormat( |
||||
|
sampleRate = frame.sampleRate, |
||||
|
channels = frame.channels, |
||||
|
bitsPerSample = 16, |
||||
|
), |
||||
|
isFinal = frame.isFinal, |
||||
|
) |
||||
|
if (!ok) { |
||||
|
Log.w(TAG, "feedTranslatedAudio rejected: streamId=$streamId leg=${frame.leg}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Synchronized |
||||
|
override fun exit() { |
||||
|
if (!entered) return |
||||
|
entered = false |
||||
|
runCatching { server.translationFeature.stop() } |
||||
|
savedBridge?.let { runCatching { server.setTranslationBridge(it) } } |
||||
|
savedBridge = null |
||||
|
Log.d(TAG, "exited call translation") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,52 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation |
||||
|
|
||||
|
/** |
||||
|
* 翻译音频桥:插件 ↔ 外部翻译服务 的双向接口。 |
||||
|
* |
||||
|
* - 「采集面」:ModeHandler 调用 [emitAudioFrame] 把解码后的 PCM 帧推出去。 |
||||
|
* 默认实现走 EventChannel 推到 Dart,由 Dart 转发到外部翻译服务。 |
||||
|
* 也可以由宿主原生代码替换成自定义实现(比如直接走 gRPC)。 |
||||
|
* |
||||
|
* - 「回放面」:[feedTranslatedAudio] 是入口,外部翻译服务调用,把 TTS PCM 注入进来。 |
||||
|
* 交给当前 ModeHandler 编码并回送到耳机/A2DP。 |
||||
|
* |
||||
|
* - 文本结果(字幕)走 [emitTranslationResult],纯展示用,不影响音频路径。 |
||||
|
*/ |
||||
|
interface TranslationAudioBridge { |
||||
|
|
||||
|
/** 插件→外部 :推一帧采集到的 PCM 音频 */ |
||||
|
fun emitAudioFrame( |
||||
|
modeId: Int, |
||||
|
streamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
seq: Long, |
||||
|
tsMs: Long, |
||||
|
isFinal: Boolean = false, |
||||
|
) |
||||
|
|
||||
|
/** 插件→外部 :推翻译文本(可选)。requestId 由调用方贯穿同一段翻译,便于字幕拼接 */ |
||||
|
fun emitTranslationResult( |
||||
|
modeId: Int, |
||||
|
srcLang: String?, |
||||
|
srcText: String?, |
||||
|
destLang: String?, |
||||
|
destText: String?, |
||||
|
requestId: String? = null, |
||||
|
) |
||||
|
|
||||
|
/** 插件→外部 :日志 */ |
||||
|
fun emitLog(modeId: Int, content: String) |
||||
|
|
||||
|
/** 插件→外部 :错误 */ |
||||
|
fun emitError(modeId: Int, code: Int, message: String?) |
||||
|
|
||||
|
/** 外部→插件 :注入翻译完成的 TTS PCM;插件负责回送到 outputStreamId 对应的物理通道 */ |
||||
|
fun feedTranslatedAudio( |
||||
|
modeId: Int, |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean |
||||
|
} |
||||
@ -0,0 +1,151 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jielihome.jielihome.feature.ConnectFeature |
||||
|
import com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.mode.CallTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.mode.FaceToFaceTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.mode.RecordModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.mode.RecordingTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.mode.StereoCallTranslationModeHandler |
||||
|
|
||||
|
/** |
||||
|
* 翻译特性入口。 |
||||
|
* |
||||
|
* # TTS PCM 回灌的两条路径 |
||||
|
* 宿主把外部翻译服务回送的 PCM 注入回插件时,**两条路径同时存在、宿主只需选一条**: |
||||
|
* |
||||
|
* 1) Dart / 远端进程 → MethodChannel `feedTranslatedAudio` → MethodRouter → |
||||
|
* `TranslationFeature.feedTranslatedAudio` → 当前 [TranslationModeHandler.onTranslatedAudio] |
||||
|
* 场景:Flutter UI 调用,或宿主原生代码使用通用入口(不区分桥实现时)。 |
||||
|
* |
||||
|
* 2) 自定义 native bridge → `TranslationAudioBridge.feedTranslatedAudio` → |
||||
|
* (桥内部决定如何下发;默认实现 [EventChannelAudioBridge] 通过 injector 转回 1)。 |
||||
|
* 场景:宿主用 [setBridge] 替换桥,希望把音频生命周期完全留在 native 层管理。 |
||||
|
* |
||||
|
* 推荐:除非有性能/隔离强需求,**统一走路径 1**(更简单、字幕和音频走同一条命令通道)。 |
||||
|
* |
||||
|
* # 立体声通话翻译 |
||||
|
* `MODE_CALL_TRANSLATION` 永远是双流单声道;`MODE_CALL_TRANSLATION_WITH_STEREO` |
||||
|
* 永远走立体声软件分声道方案。是否切到后者由调用方自行判断,可先调 |
||||
|
* [isSupportCallTranslationWithStereo] 探测设备能力。 |
||||
|
*/ |
||||
|
class TranslationFeature( |
||||
|
private val context: Context, |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val connectFeature: ConnectFeature, |
||||
|
) { |
||||
|
@Volatile |
||||
|
private var bridge: TranslationAudioBridge? = null |
||||
|
|
||||
|
private var handlers: Map<Int, TranslationModeHandler> = emptyMap() |
||||
|
|
||||
|
@Volatile |
||||
|
private var current: TranslationModeHandler? = null |
||||
|
|
||||
|
/** 由 JieliHomeServer 在 init 时调一次;后续宿主可调 [setBridge] 替换 */ |
||||
|
fun setBridge(bridge: TranslationAudioBridge) { |
||||
|
this.bridge = bridge |
||||
|
// 重建 handlers,把新桥 + 上下文注入下去 |
||||
|
handlers = mapOf( |
||||
|
TranslationModeIds.MODE_RECORD to RecordModeHandler(context, btManager, bridge), |
||||
|
TranslationModeIds.MODE_RECORDING_TRANSLATION to RecordingTranslationModeHandler(context, btManager, bridge), |
||||
|
TranslationModeIds.MODE_CALL_TRANSLATION to CallTranslationModeHandler(context, btManager, connectFeature, bridge), |
||||
|
TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO to StereoCallTranslationModeHandler(context, btManager, connectFeature, bridge), |
||||
|
TranslationModeIds.MODE_AUDIO_TRANSLATION to AudioTranslationModeHandler(btManager, bridge), |
||||
|
TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION to FaceToFaceTranslationModeHandler(context, btManager, bridge), |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
fun start(modeId: Int, args: Map<String, Any?>): Result<Unit> { |
||||
|
// 与官方 demo (TranslationViewModel.enterMode) 对齐:MODE_CALL_TRANSLATION |
||||
|
// + OPUS + 设备支持立体声方案时,自动升级到 MODE_CALL_TRANSLATION_WITH_STEREO |
||||
|
// (channel=2)。很多固件版本下 mono CALL_TRANSLATION 通路实际上不出帧, |
||||
|
// 真正出数据的是立体声方案——上下行 mix 成一路立体声 PCM 上推。 |
||||
|
val effectiveModeId = if ( |
||||
|
modeId == TranslationModeIds.MODE_CALL_TRANSLATION && |
||||
|
(args["audioType"] == null || |
||||
|
args["audioType"] == com.jieli.bluetooth.constant.Constants.AUDIO_TYPE_OPUS || |
||||
|
(args["audioType"] as? String)?.lowercase() == "opus") && |
||||
|
isSupportCallTranslationWithStereo(args["address"] as? String) |
||||
|
) { |
||||
|
android.util.Log.d( |
||||
|
"TranslationFeature", |
||||
|
"MODE_CALL_TRANSLATION + OPUS + stereo-supported → upgrade to MODE_CALL_TRANSLATION_WITH_STEREO", |
||||
|
) |
||||
|
TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO |
||||
|
} else modeId |
||||
|
|
||||
|
val handler = handlers[effectiveModeId] |
||||
|
?: return Result.failure(IllegalArgumentException("unknown modeId=$effectiveModeId")) |
||||
|
current?.takeIf { it.isWorking }?.stop() |
||||
|
handler.start(args) |
||||
|
current = handler |
||||
|
return Result.success(Unit) |
||||
|
} |
||||
|
|
||||
|
fun stop() { |
||||
|
current?.takeIf { it.isWorking }?.stop() |
||||
|
current = null |
||||
|
} |
||||
|
|
||||
|
fun isWorking(): Boolean = current?.isWorking == true |
||||
|
fun currentModeId(): Int? = current?.modeId |
||||
|
fun currentInputStreams(): List<String> = current?.inputStreams ?: emptyList() |
||||
|
fun currentOutputStreams(): List<String> = current?.outputStreams ?: emptyList() |
||||
|
|
||||
|
/** |
||||
|
* 翻译服务回送 TTS PCM 的统一入口。 |
||||
|
* 路由到当前活动 ModeHandler 的 onTranslatedAudio。 |
||||
|
*/ |
||||
|
fun feedTranslatedAudio( |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
val h = current ?: return false |
||||
|
if (!h.isWorking) return false |
||||
|
if (outputStreamId !in h.outputStreams) return false |
||||
|
return h.onTranslatedAudio(outputStreamId, pcm, format, isFinal) |
||||
|
} |
||||
|
|
||||
|
/** 透传文本结果(字幕);requestId 由调用方贯穿同一段翻译,便于聚合 */ |
||||
|
fun feedTranslationResult( |
||||
|
srcLang: String?, srcText: String?, |
||||
|
destLang: String?, destText: String?, |
||||
|
requestId: String? = null, |
||||
|
) { |
||||
|
val mid = current?.modeId ?: return |
||||
|
bridge?.emitTranslationResult(mid, srcLang, srcText, destLang, destText, requestId) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 探测当前已连耳机是否支持立体声通话翻译方案。 |
||||
|
* 内部根据 SDK [com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl.isSupportCallTranslationWithStereo] |
||||
|
* 判断;若设备未连或未握手 RCSP 则返回 false。 |
||||
|
*/ |
||||
|
fun isSupportCallTranslationWithStereo(connectedDeviceAddress: String?): Boolean { |
||||
|
val device = connectedDeviceAddress?.let { |
||||
|
com.jieli.bluetooth.utils.BluetoothUtil.getRemoteDevice(it) |
||||
|
} ?: btManager.connectedDevice ?: return false |
||||
|
val tmp = com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl( |
||||
|
btManager, |
||||
|
com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi(), |
||||
|
device, |
||||
|
) |
||||
|
return runCatching { tmp.isSupportCallTranslationWithStereo }.getOrDefault(false) |
||||
|
.also { runCatching { tmp.destroy() } } |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 把外部音频文件解码后的 PCM 灌入「音视频翻译」模式(仅在该模式生效)。 |
||||
|
*/ |
||||
|
fun feedAudioFilePcm(pcm: ByteArray, sampleRate: Int = 16000): Boolean { |
||||
|
val h = current as? com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler |
||||
|
?: return false |
||||
|
h.feedFilePcm(pcm, sampleRate) |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,67 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation |
||||
|
|
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
|
||||
|
/** |
||||
|
* 翻译模式处理器统一接口。每种模式独立实现: |
||||
|
* - 声明自己消费哪些「输入流」(采集面)和产出哪些「输出流」(回放面) |
||||
|
* - 实现 start:启动 SDK 端音频流(进入翻译模式 / 起手机麦),把解码后的 PCM 通过 bridge 推出 |
||||
|
* - 实现 stop:关闭音频流,释放编解码器 |
||||
|
* - 实现 onTranslatedAudio:接到外部翻译服务回送的 PCM,按 outputStreamId 路由 |
||||
|
*/ |
||||
|
interface TranslationModeHandler { |
||||
|
val modeId: Int |
||||
|
val inputStreams: List<String> |
||||
|
val outputStreams: List<String> |
||||
|
|
||||
|
fun start(args: Map<String, Any?>) |
||||
|
fun stop() |
||||
|
val isWorking: Boolean |
||||
|
|
||||
|
/** 外部翻译服务回送的 PCM 注入点,由 TranslationFeature 路由到当前活动 handler */ |
||||
|
fun onTranslatedAudio( |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean |
||||
|
} |
||||
|
|
||||
|
abstract class BaseTranslationModeHandler( |
||||
|
protected val btManager: JL_BluetoothManager, |
||||
|
protected val bridge: TranslationAudioBridge, |
||||
|
) : TranslationModeHandler { |
||||
|
|
||||
|
@Volatile |
||||
|
protected var working: Boolean = false |
||||
|
|
||||
|
override val isWorking: Boolean get() = working |
||||
|
|
||||
|
/** 把采集到的一帧 PCM 推给外部翻译服务 */ |
||||
|
protected fun pushFrame( |
||||
|
streamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat = AudioFormat(), |
||||
|
seq: Long = System.nanoTime(), |
||||
|
isFinal: Boolean = false, |
||||
|
) { |
||||
|
bridge.emitAudioFrame(modeId, streamId, pcm, format, seq, System.currentTimeMillis(), isFinal) |
||||
|
} |
||||
|
|
||||
|
protected fun emitLog(content: String) = bridge.emitLog(modeId, content) |
||||
|
protected fun emitError(code: Int, msg: String?) = bridge.emitError(modeId, code, msg) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 与 SDK [com.jieli.bluetooth.bean.translation.TranslationMode] 完全一致的取值, |
||||
|
* 方便直接拿插件 modeId 去构造 SDK 的 TranslationMode。 |
||||
|
*/ |
||||
|
object TranslationModeIds { |
||||
|
const val MODE_IDLE = 0 |
||||
|
const val MODE_RECORD = 1 |
||||
|
const val MODE_RECORDING_TRANSLATION = 2 |
||||
|
const val MODE_CALL_TRANSLATION = 3 |
||||
|
const val MODE_AUDIO_TRANSLATION = 4 |
||||
|
const val MODE_FACE_TO_FACE_TRANSLATION = 5 |
||||
|
const val MODE_CALL_TRANSLATION_WITH_STEREO = 6 |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation |
||||
|
|
||||
|
/** |
||||
|
* 翻译流逻辑 ID 常量。 |
||||
|
* 「输入流」= 插件解码后推给翻译服务(设备麦克风、eSCO 上行/下行、本地音频文件等)。 |
||||
|
* 「输出流」= 翻译服务返回 TTS PCM 后,插件回送的目标(耳机扬声器、A2DP 本地、对端通话上行等)。 |
||||
|
*/ |
||||
|
object TranslationStreams { |
||||
|
// 输入流:插件解码后推给翻译服务的音频 |
||||
|
const val IN_MIC = "in.mic" |
||||
|
const val IN_UPLINK = "in.uplink" // 通话上行:本机用户 |
||||
|
const val IN_DOWNLINK = "in.downlink" // 通话下行:对端 |
||||
|
const val IN_AUDIO_FILE = "in.audioFile" // 本地音频文件 |
||||
|
|
||||
|
// 输出流:翻译服务回送 TTS 后插件应送达的目的地 |
||||
|
const val OUT_SPEAKER = "out.speaker" |
||||
|
const val OUT_UPLINK = "out.uplink" // 译文给对端听 |
||||
|
const val OUT_DOWNLINK = "out.downlink" // 译文给本机用户听 |
||||
|
const val OUT_LOCAL_PLAYBACK = "out.localPlayback" |
||||
|
} |
||||
|
|
||||
|
/** 一帧 PCM 音频规格 */ |
||||
|
data class AudioFormat( |
||||
|
val sampleRate: Int = 16000, |
||||
|
val channels: Int = 1, |
||||
|
val bitsPerSample: Int = 16, |
||||
|
) |
||||
@ -0,0 +1,61 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation.mode |
||||
|
|
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jielihome.jielihome.audio.LocalPlayer |
||||
|
import com.jielihome.jielihome.feature.translation.AudioFormat |
||||
|
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationStreams |
||||
|
|
||||
|
/** |
||||
|
* MODE_AUDIO_TRANSLATION —— 音视频翻译。 |
||||
|
* 输入:本地音频文件(由调用方自行解码并通过 [feedFilePcm] 灌进来,避免插件依赖各种封装格式解析) |
||||
|
* 输出:本机扬声器(A2DP),不回耳机 |
||||
|
*/ |
||||
|
class AudioTranslationModeHandler( |
||||
|
btManager: JL_BluetoothManager, |
||||
|
bridge: TranslationAudioBridge, |
||||
|
) : BaseTranslationModeHandler(btManager, bridge) { |
||||
|
|
||||
|
override val modeId = TranslationModeIds.MODE_AUDIO_TRANSLATION |
||||
|
override val inputStreams = listOf(TranslationStreams.IN_AUDIO_FILE) |
||||
|
override val outputStreams = listOf(TranslationStreams.OUT_LOCAL_PLAYBACK) |
||||
|
|
||||
|
private var player: LocalPlayer? = null |
||||
|
|
||||
|
override fun start(args: Map<String, Any?>) { |
||||
|
if (working) return |
||||
|
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
||||
|
val channels = (args["channels"] as? Int) ?: 1 |
||||
|
player = LocalPlayer(sampleRate, channels).also { it.start() } |
||||
|
working = true |
||||
|
emitLog("AudioTranslation start sampleRate=$sampleRate channels=$channels") |
||||
|
} |
||||
|
|
||||
|
/** 调用方把解码出的 PCM 灌进来推给翻译服务 */ |
||||
|
@Suppress("unused") |
||||
|
fun feedFilePcm(pcm: ByteArray, sampleRate: Int = 16000) { |
||||
|
if (!working) return |
||||
|
pushFrame(TranslationStreams.IN_AUDIO_FILE, pcm, AudioFormat(sampleRate, 1, 16)) |
||||
|
} |
||||
|
|
||||
|
override fun stop() { |
||||
|
if (!working) return |
||||
|
player?.stop() |
||||
|
player = null |
||||
|
working = false |
||||
|
emitLog("AudioTranslation stop") |
||||
|
} |
||||
|
|
||||
|
override fun onTranslatedAudio( |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
if (outputStreamId != TranslationStreams.OUT_LOCAL_PLAYBACK) return false |
||||
|
player?.feed(pcm) |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,138 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation.mode |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jieli.bluetooth.bean.translation.AudioData |
||||
|
import com.jieli.bluetooth.bean.translation.TranslationMode |
||||
|
import com.jieli.bluetooth.constant.Constants |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jielihome.jielihome.feature.ConnectFeature |
||||
|
import com.jielihome.jielihome.feature.translation.AudioFormat |
||||
|
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationStreams |
||||
|
import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime |
||||
|
import java.io.File |
||||
|
|
||||
|
/** |
||||
|
* MODE_CALL_TRANSLATION —— 通话翻译,eSCO 上下行各一路单声道。 |
||||
|
* 输入:uplink(本机用户)+ downlink(对端) |
||||
|
* 输出:uplink(TTS 给对端听)+ downlink(TTS 给本机用户听) |
||||
|
* |
||||
|
* 实现:通过 [RcspTranslationRuntime] 让耳机进入 SDK 翻译模式, |
||||
|
* OPUS 流式解码后按 source 分流推到 bridge; |
||||
|
* 回送 TTS PCM 时编码 OPUS 后通过同一 runtime 写回耳机。 |
||||
|
*/ |
||||
|
class CallTranslationModeHandler( |
||||
|
private val context: Context, |
||||
|
btManager: JL_BluetoothManager, |
||||
|
private val connectFeature: ConnectFeature, |
||||
|
bridge: TranslationAudioBridge, |
||||
|
) : BaseTranslationModeHandler(btManager, bridge) { |
||||
|
|
||||
|
override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION |
||||
|
override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK) |
||||
|
override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK) |
||||
|
|
||||
|
private var runtime: RcspTranslationRuntime? = null |
||||
|
|
||||
|
override fun start(args: Map<String, Any?>) { |
||||
|
if (working) return |
||||
|
val address = args["address"] as? String |
||||
|
?: connectFeature.connectedDevice()?.address |
||||
|
val device = address?.let { connectFeature.deviceByAddress(it) } |
||||
|
?: connectFeature.connectedDevice() |
||||
|
if (device == null) { |
||||
|
emitError(-200, "no connected device; pass args.address or connect first") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
||||
|
val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS |
||||
|
// Recording strategy 选择: |
||||
|
// STRATEGY_DEVICE_AUTO_RECORDING(2) —— 耳机自动检测真实 SCO 通话才开始录, |
||||
|
// 手机端没在打电话时耳机不会上推 PCM,本场景(双向 AI 通话翻译)行不通。 |
||||
|
// STRATEGY_DEVICE_ALWAYS_RECORDING(1) —— 一进入翻译模式就持续上推双声道 |
||||
|
// PCM,正是 AI 通话翻译需要的"始终开麦"语义。 |
||||
|
// 允许通过 args["strategy"] 覆盖(数值或字符串 "auto"/"always"/"custom")。 |
||||
|
val strategy = parseStrategy(args["strategy"]) |
||||
|
?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING |
||||
|
val sdkMode = TranslationMode( |
||||
|
TranslationMode.MODE_CALL_TRANSLATION, |
||||
|
audioType, |
||||
|
1, // 单声道 |
||||
|
sampleRate, |
||||
|
).setRecordingStrategy(strategy) |
||||
|
|
||||
|
val rt = RcspTranslationRuntime( |
||||
|
btManager = btManager, |
||||
|
device = device, |
||||
|
mode = sdkMode, |
||||
|
tempDir = File(context.cacheDir, "jieli_translation_tts"), |
||||
|
onPcm = { source, pcm -> |
||||
|
val streamId = when (source) { |
||||
|
AudioData.SOURCE_E_SCO_UP_LINK -> TranslationStreams.IN_UPLINK |
||||
|
AudioData.SOURCE_E_SCO_DOWN_LINK -> TranslationStreams.IN_DOWNLINK |
||||
|
else -> return@RcspTranslationRuntime |
||||
|
} |
||||
|
pushFrame(streamId, pcm, AudioFormat(sampleRate, 1, 16)) |
||||
|
}, |
||||
|
onError = { code, msg -> emitError(code, msg) } |
||||
|
) |
||||
|
rt.start().fold( |
||||
|
{ |
||||
|
runtime = rt |
||||
|
working = true |
||||
|
emitLog("CallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate") |
||||
|
}, |
||||
|
{ emitError(-201, it.message); rt.stop() }, |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
private fun parseStrategy(raw: Any?): Int? = when (raw) { |
||||
|
null -> null |
||||
|
is Int -> raw |
||||
|
is Number -> raw.toInt() |
||||
|
is String -> when (raw.lowercase()) { |
||||
|
"custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING |
||||
|
"always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING |
||||
|
"auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING |
||||
|
else -> raw.toIntOrNull() |
||||
|
} |
||||
|
else -> null |
||||
|
} |
||||
|
|
||||
|
private fun parseAudioType(raw: Any?): Int? = when (raw) { |
||||
|
null -> null |
||||
|
is Int -> raw |
||||
|
is Number -> raw.toInt() |
||||
|
is String -> when (raw.lowercase()) { |
||||
|
"opus" -> Constants.AUDIO_TYPE_OPUS |
||||
|
"pcm" -> Constants.AUDIO_TYPE_PCM |
||||
|
"speex" -> Constants.AUDIO_TYPE_SPEEX |
||||
|
"msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC |
||||
|
"jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2 |
||||
|
else -> raw.toIntOrNull() |
||||
|
} |
||||
|
else -> null |
||||
|
} |
||||
|
|
||||
|
override fun stop() { |
||||
|
if (!working) return |
||||
|
runtime?.stop() |
||||
|
runtime = null |
||||
|
working = false |
||||
|
emitLog("CallTranslation stop") |
||||
|
} |
||||
|
|
||||
|
override fun onTranslatedAudio( |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
if (outputStreamId != TranslationStreams.OUT_UPLINK && |
||||
|
outputStreamId != TranslationStreams.OUT_DOWNLINK) return false |
||||
|
return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,63 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation.mode |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jielihome.jielihome.audio.LocalPlayer |
||||
|
import com.jielihome.jielihome.audio.PhoneMicCapture |
||||
|
import com.jielihome.jielihome.feature.translation.AudioFormat |
||||
|
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationStreams |
||||
|
|
||||
|
/** |
||||
|
* MODE_FACE_TO_FACE_TRANSLATION —— 面对面翻译。 |
||||
|
* 输入:手机麦 |
||||
|
* 输出:本机扬声器(自带的 LocalPlayer,A2DP/外放都行) |
||||
|
*/ |
||||
|
class FaceToFaceTranslationModeHandler( |
||||
|
private val context: Context, |
||||
|
btManager: JL_BluetoothManager, |
||||
|
bridge: TranslationAudioBridge, |
||||
|
) : BaseTranslationModeHandler(btManager, bridge) { |
||||
|
|
||||
|
override val modeId = TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION |
||||
|
override val inputStreams = listOf(TranslationStreams.IN_MIC) |
||||
|
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) |
||||
|
|
||||
|
private var mic: PhoneMicCapture? = null |
||||
|
private var player: LocalPlayer? = null |
||||
|
|
||||
|
override fun start(args: Map<String, Any?>) { |
||||
|
if (working) return |
||||
|
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
||||
|
mic = PhoneMicCapture( |
||||
|
context, sampleRate, |
||||
|
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, |
||||
|
onError = { code, msg -> emitError(code, msg) }, |
||||
|
) |
||||
|
if (mic?.start() != true) return |
||||
|
player = LocalPlayer(sampleRate, 1).also { it.start() } |
||||
|
working = true |
||||
|
emitLog("FaceToFaceTranslation start") |
||||
|
} |
||||
|
|
||||
|
override fun stop() { |
||||
|
if (!working) return |
||||
|
mic?.stop(); mic = null |
||||
|
player?.stop(); player = null |
||||
|
working = false |
||||
|
emitLog("FaceToFaceTranslation stop") |
||||
|
} |
||||
|
|
||||
|
override fun onTranslatedAudio( |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false |
||||
|
player?.feed(pcm) |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation.mode |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jielihome.jielihome.audio.PhoneMicCapture |
||||
|
import com.jielihome.jielihome.feature.translation.AudioFormat |
||||
|
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationStreams |
||||
|
|
||||
|
/** |
||||
|
* MODE_RECORD —— 单向录音翻译。 |
||||
|
* 输入:手机麦 → 推到外部 |
||||
|
* 输出:speaker ← 外部回送 TTS PCM;当前不依赖耳机回送,直接交给宿主自行播放(可选)。 |
||||
|
* |
||||
|
* 注:本模式完全不动 RCSP,只用手机麦。耳机端不需要进入翻译模式。 |
||||
|
*/ |
||||
|
class RecordModeHandler( |
||||
|
private val context: Context, |
||||
|
btManager: JL_BluetoothManager, |
||||
|
bridge: TranslationAudioBridge, |
||||
|
) : BaseTranslationModeHandler(btManager, bridge) { |
||||
|
|
||||
|
override val modeId = TranslationModeIds.MODE_RECORD |
||||
|
override val inputStreams = listOf(TranslationStreams.IN_MIC) |
||||
|
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) |
||||
|
|
||||
|
private var mic: PhoneMicCapture? = null |
||||
|
|
||||
|
override fun start(args: Map<String, Any?>) { |
||||
|
if (working) return |
||||
|
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
||||
|
val frameMs = (args["frameDurationMs"] as? Int) ?: 20 |
||||
|
mic = PhoneMicCapture( |
||||
|
context, sampleRate, frameMs, |
||||
|
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, |
||||
|
onError = { code, msg -> emitError(code, msg) }, |
||||
|
) |
||||
|
if (mic?.start() != true) return |
||||
|
working = true |
||||
|
emitLog("RecordMode start sampleRate=$sampleRate") |
||||
|
} |
||||
|
|
||||
|
override fun stop() { |
||||
|
if (!working) return |
||||
|
mic?.stop() |
||||
|
mic = null |
||||
|
working = false |
||||
|
emitLog("RecordMode stop") |
||||
|
} |
||||
|
|
||||
|
override fun onTranslatedAudio( |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false |
||||
|
// RECORD 模式下耳机不参与回送;如果宿主想播给用户听,自己用 AudioTrack 播即可。 |
||||
|
emitLog("recv tts pcm=${pcm.size}B final=$isFinal (host-side playback)") |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,59 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation.mode |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jielihome.jielihome.audio.PhoneMicCapture |
||||
|
import com.jielihome.jielihome.feature.translation.AudioFormat |
||||
|
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationStreams |
||||
|
|
||||
|
/** |
||||
|
* MODE_RECORDING_TRANSLATION —— 流式录音翻译。等同于 RecordMode 的实时版,差别只在语义: |
||||
|
* 调用方实现实时 STT/翻译流水线、不缓存整段录音再发。 |
||||
|
*/ |
||||
|
class RecordingTranslationModeHandler( |
||||
|
private val context: Context, |
||||
|
btManager: JL_BluetoothManager, |
||||
|
bridge: TranslationAudioBridge, |
||||
|
) : BaseTranslationModeHandler(btManager, bridge) { |
||||
|
|
||||
|
override val modeId = TranslationModeIds.MODE_RECORDING_TRANSLATION |
||||
|
override val inputStreams = listOf(TranslationStreams.IN_MIC) |
||||
|
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) |
||||
|
|
||||
|
private var mic: PhoneMicCapture? = null |
||||
|
|
||||
|
override fun start(args: Map<String, Any?>) { |
||||
|
if (working) return |
||||
|
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
||||
|
mic = PhoneMicCapture( |
||||
|
context, sampleRate, |
||||
|
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, |
||||
|
onError = { code, msg -> emitError(code, msg) }, |
||||
|
) |
||||
|
if (mic?.start() != true) return |
||||
|
working = true |
||||
|
emitLog("RecordingTranslation start sampleRate=$sampleRate") |
||||
|
} |
||||
|
|
||||
|
override fun stop() { |
||||
|
if (!working) return |
||||
|
mic?.stop() |
||||
|
mic = null |
||||
|
working = false |
||||
|
emitLog("RecordingTranslation stop") |
||||
|
} |
||||
|
|
||||
|
override fun onTranslatedAudio( |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false |
||||
|
emitLog("recv tts ${pcm.size}B final=$isFinal") |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,128 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation.mode |
||||
|
|
||||
|
import android.content.Context |
||||
|
import com.jieli.bluetooth.bean.translation.AudioData |
||||
|
import com.jieli.bluetooth.bean.translation.TranslationMode |
||||
|
import com.jieli.bluetooth.constant.Constants |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jielihome.jielihome.audio.PcmKit |
||||
|
import com.jielihome.jielihome.feature.ConnectFeature |
||||
|
import com.jielihome.jielihome.feature.translation.AudioFormat |
||||
|
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationModeIds |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationStreams |
||||
|
import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime |
||||
|
import java.io.File |
||||
|
|
||||
|
/** |
||||
|
* MODE_CALL_TRANSLATION_WITH_STEREO —— 立体声通话翻译。 |
||||
|
* SDK 上来一路立体声 PCM(左=上行,右=下行),软件分离后等价于普通通话翻译两路。 |
||||
|
*/ |
||||
|
class StereoCallTranslationModeHandler( |
||||
|
private val context: Context, |
||||
|
btManager: JL_BluetoothManager, |
||||
|
private val connectFeature: ConnectFeature, |
||||
|
bridge: TranslationAudioBridge, |
||||
|
) : BaseTranslationModeHandler(btManager, bridge) { |
||||
|
|
||||
|
override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO |
||||
|
override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK) |
||||
|
override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK) |
||||
|
|
||||
|
private var runtime: RcspTranslationRuntime? = null |
||||
|
|
||||
|
override fun start(args: Map<String, Any?>) { |
||||
|
if (working) return |
||||
|
val address = args["address"] as? String |
||||
|
?: connectFeature.connectedDevice()?.address |
||||
|
val device = address?.let { connectFeature.deviceByAddress(it) } |
||||
|
?: connectFeature.connectedDevice() |
||||
|
if (device == null) { |
||||
|
emitError(-200, "no connected device") |
||||
|
return |
||||
|
} |
||||
|
val sampleRate = (args["sampleRate"] as? Int) ?: 16000 |
||||
|
val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS |
||||
|
// 与 demo 对齐:默认 ALWAYS_RECORDING,进 mode 即持续上推 PCM; |
||||
|
// AUTO 会在没有真实通话事件时静默不出帧。 |
||||
|
val strategy = parseStrategy(args["strategy"]) |
||||
|
?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING |
||||
|
val sdkMode = TranslationMode( |
||||
|
TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO, |
||||
|
audioType, |
||||
|
2, // 立体声 |
||||
|
sampleRate, |
||||
|
).setRecordingStrategy(strategy) |
||||
|
|
||||
|
val rt = RcspTranslationRuntime( |
||||
|
btManager = btManager, |
||||
|
device = device, |
||||
|
mode = sdkMode, |
||||
|
tempDir = File(context.cacheDir, "jieli_translation_tts"), |
||||
|
onPcm = { source, stereoPcm -> |
||||
|
if (source != AudioData.SOURCE_E_SCO_MIX) return@RcspTranslationRuntime |
||||
|
val (left, right) = PcmKit.splitStereo16(stereoPcm) |
||||
|
val fmt = AudioFormat(sampleRate, 1, 16) |
||||
|
pushFrame(TranslationStreams.IN_UPLINK, left, fmt) |
||||
|
pushFrame(TranslationStreams.IN_DOWNLINK, right, fmt) |
||||
|
}, |
||||
|
onError = { code, msg -> emitError(code, msg) } |
||||
|
) |
||||
|
rt.start().fold( |
||||
|
{ |
||||
|
runtime = rt |
||||
|
working = true |
||||
|
emitLog("StereoCallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate") |
||||
|
}, |
||||
|
{ emitError(-201, it.message); rt.stop() }, |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
private fun parseStrategy(raw: Any?): Int? = when (raw) { |
||||
|
null -> null |
||||
|
is Int -> raw |
||||
|
is Number -> raw.toInt() |
||||
|
is String -> when (raw.lowercase()) { |
||||
|
"custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING |
||||
|
"always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING |
||||
|
"auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING |
||||
|
else -> raw.toIntOrNull() |
||||
|
} |
||||
|
else -> null |
||||
|
} |
||||
|
|
||||
|
private fun parseAudioType(raw: Any?): Int? = when (raw) { |
||||
|
null -> null |
||||
|
is Int -> raw |
||||
|
is Number -> raw.toInt() |
||||
|
is String -> when (raw.lowercase()) { |
||||
|
"opus" -> Constants.AUDIO_TYPE_OPUS |
||||
|
"pcm" -> Constants.AUDIO_TYPE_PCM |
||||
|
"speex" -> Constants.AUDIO_TYPE_SPEEX |
||||
|
"msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC |
||||
|
"jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2 |
||||
|
else -> raw.toIntOrNull() |
||||
|
} |
||||
|
else -> null |
||||
|
} |
||||
|
|
||||
|
override fun stop() { |
||||
|
if (!working) return |
||||
|
runtime?.stop() |
||||
|
runtime = null |
||||
|
working = false |
||||
|
emitLog("StereoCallTranslation stop") |
||||
|
} |
||||
|
|
||||
|
override fun onTranslatedAudio( |
||||
|
outputStreamId: String, |
||||
|
pcm: ByteArray, |
||||
|
format: AudioFormat, |
||||
|
isFinal: Boolean, |
||||
|
): Boolean { |
||||
|
if (outputStreamId != TranslationStreams.OUT_UPLINK && |
||||
|
outputStreamId != TranslationStreams.OUT_DOWNLINK) return false |
||||
|
return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation.runtime |
||||
|
|
||||
|
import com.jieli.bluetooth.bean.translation.AudioData |
||||
|
import com.jieli.bluetooth.bean.translation.TranslationMode |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.translation.AITranslationCallback |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi |
||||
|
|
||||
|
/** |
||||
|
* SDK 的 [TranslationImpl] 构造函数要求一个 [IAITranslationApi]。 |
||||
|
* 我们走的是「外部翻译服务」路线,SDK 自带的 AI 流程不启用,所以这里给个空实现。 |
||||
|
*/ |
||||
|
internal class NoOpAITranslationApi : IAITranslationApi { |
||||
|
override fun isWorking(): Boolean = false |
||||
|
override fun startTranslating(mode: TranslationMode, cb: AITranslationCallback) { /* no-op */ } |
||||
|
override fun stopTranslating() { /* no-op */ } |
||||
|
override fun writeAudio(data: AudioData) { /* no-op */ } |
||||
|
} |
||||
@ -0,0 +1,470 @@ |
|||||
|
package com.jielihome.jielihome.feature.translation.runtime |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import com.jieli.bluetooth.bean.translation.AudioData |
||||
|
import com.jieli.bluetooth.bean.translation.TranslationMode |
||||
|
import com.jieli.bluetooth.constant.Constants |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback |
||||
|
import android.util.Log |
||||
|
import com.jielihome.jielihome.audio.OpusStreamDecoder |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationStreams |
||||
|
import com.jieli.jl_audio_decode.callback.OnStateCallback |
||||
|
import com.jieli.jl_audio_decode.opus.OpusManager |
||||
|
import java.io.ByteArrayOutputStream |
||||
|
import java.io.File |
||||
|
import java.util.ArrayDeque |
||||
|
import java.util.concurrent.Executors |
||||
|
import java.util.concurrent.ScheduledExecutorService |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
import java.util.concurrent.atomic.AtomicBoolean |
||||
|
|
||||
|
/** |
||||
|
* RCSP 翻译模式运行时。 |
||||
|
* |
||||
|
* audioType: |
||||
|
* - [Constants.AUDIO_TYPE_OPUS](默认):上行 OPUS → 解码 PCM;下行 PCM → **整段** 编码 OPUS 写回 |
||||
|
* - [Constants.AUDIO_TYPE_PCM]:上下行直接走 PCM,不经编解码 |
||||
|
* |
||||
|
* # TTS 回灌策略(与官方 demo `MachineTranslation.tryToTTS` / `OpusHelper.encodeFile` 对齐) |
||||
|
* |
||||
|
* 之前是流式 [com.jielihome.jielihome.audio.OpusStreamEncoder]:每出一帧 OPUS 立即包成 |
||||
|
* AudioData 调 [TranslationImpl.writeAudioData],导致耳机端 RCSP 把每帧当成"独立的一段 |
||||
|
* TTS 起点",解码器频繁 reset → 杂音 / 断续。 |
||||
|
* |
||||
|
* 现在按 demo:[feedTtsPcm] 把 PCM 累积到 per-leg 缓冲;上层在每段 utterance 末尾把 |
||||
|
* `isFinal=true` 透传过来;runtime 这一刻: |
||||
|
* 1. 把累积 PCM 写到临时 .pcm 文件; |
||||
|
* 2. `OpusManager.encodeFile(pcmFile, opusFile, callback)` 离线整段编码(带 head 的默认 OpusOption); |
||||
|
* 3. 整段读出 → **一个** [AudioData] → [WriteScheduler] 单次入队下发。 |
||||
|
* |
||||
|
* # 文件命名 |
||||
|
* 临时文件位于 [tempDir],按 `tts_<source>_<seq>.<pcm|opus>` 命名;编码完成异步删除。 |
||||
|
* |
||||
|
* # source 字段写回方向 |
||||
|
* - 通话翻译给「对端听」 → AudioData.source = SOURCE_E_SCO_UP_LINK |
||||
|
* - 通话翻译给「本机听」 → AudioData.source = SOURCE_E_SCO_DOWN_LINK |
||||
|
* - 录音/音视频/面对面 → AudioData.source = SOURCE_PHONE_MIC(SDK 按 mode 自分发) |
||||
|
*/ |
||||
|
class RcspTranslationRuntime( |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val device: BluetoothDevice, |
||||
|
private val mode: TranslationMode, |
||||
|
private val tempDir: File, |
||||
|
/** 解码(或直传 PCM)后的音频上行;source 为 SDK 原值 */ |
||||
|
private val onPcm: (source: Int, pcm: ByteArray) -> Unit, |
||||
|
private val onError: (code: Int, msg: String?) -> Unit, |
||||
|
private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200, |
||||
|
) { |
||||
|
|
||||
|
private val translationImpl = TranslationImpl(btManager, NoOpAITranslationApi(), device) |
||||
|
private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM |
||||
|
private val sampleRateHz = mode.sampleRate.takeIf { it > 0 } ?: 16000 |
||||
|
|
||||
|
/** 入栈解码器:仅 OPUS 模式下创建 */ |
||||
|
private val upDecoder: OpusStreamDecoder? = if (isPcmMode) null else OpusStreamDecoder( |
||||
|
channel = 1, |
||||
|
packetSize = opusPacketSize, |
||||
|
sampleRate = sampleRateHz, |
||||
|
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_UP_LINK, pcm) }, |
||||
|
onError = { c, m -> onError(c, "upDecoder: $m") }, |
||||
|
) |
||||
|
|
||||
|
private val downDecoder: OpusStreamDecoder? = |
||||
|
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION) OpusStreamDecoder( |
||||
|
channel = 1, |
||||
|
packetSize = opusPacketSize, |
||||
|
sampleRate = sampleRateHz, |
||||
|
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_DOWN_LINK, pcm) }, |
||||
|
onError = { c, m -> onError(c, "downDecoder: $m") }, |
||||
|
) else null |
||||
|
|
||||
|
private val stereoDecoder: OpusStreamDecoder? = |
||||
|
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO) OpusStreamDecoder( |
||||
|
channel = 2, packetSize = 80, |
||||
|
sampleRate = sampleRateHz, |
||||
|
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_MIX, pcm) }, |
||||
|
onError = { c, m -> onError(c, "stereoDecoder: $m") }, |
||||
|
) else null |
||||
|
|
||||
|
/** 调试统计:SDK onReceiveAudioData 被回调多少次、按 (source,type) 分桶 */ |
||||
|
@Volatile private var rxCount = 0L |
||||
|
@Volatile private var rxTypeMismatch = 0L |
||||
|
@Volatile private var rxNullPayload = 0L |
||||
|
@Volatile private var rxLastReportMs = 0L |
||||
|
|
||||
|
private val translationCallback = object : TranslationCallback { |
||||
|
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { |
||||
|
Log.d(TAG, "TranslationCallback.onModeChange: mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} ch=${m.channel}") |
||||
|
} |
||||
|
|
||||
|
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { |
||||
|
rxCount++ |
||||
|
val now = System.currentTimeMillis() |
||||
|
if (now - rxLastReportMs >= 1000L) { |
||||
|
Log.d(TAG, "RX stats (last 1s): total=$rxCount typeMismatch=$rxTypeMismatch nullPayload=$rxNullPayload expect.type=${mode.audioType} last.source=${data.source} last.type=${data.type} payloadSize=${data.audioData?.size ?: 0}") |
||||
|
rxCount = 0; rxTypeMismatch = 0; rxNullPayload = 0; rxLastReportMs = now |
||||
|
} |
||||
|
if (data.type != mode.audioType) { |
||||
|
rxTypeMismatch++ |
||||
|
return |
||||
|
} |
||||
|
val payload = data.audioData |
||||
|
if (payload == null) { |
||||
|
rxNullPayload++ |
||||
|
return |
||||
|
} |
||||
|
if (isPcmMode) { |
||||
|
onPcm(data.source, payload) |
||||
|
return |
||||
|
} |
||||
|
when (data.source) { |
||||
|
AudioData.SOURCE_E_SCO_UP_LINK -> upDecoder?.feedEncoded(payload) |
||||
|
AudioData.SOURCE_E_SCO_DOWN_LINK -> downDecoder?.feedEncoded(payload) |
||||
|
AudioData.SOURCE_E_SCO_MIX -> stereoDecoder?.feedEncoded(payload) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onError(d: BluetoothDevice, code: Int, msg: String) { |
||||
|
Log.e(TAG, "TranslationCallback.onError code=$code msg=$msg") |
||||
|
this@RcspTranslationRuntime.onError(code, msg) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "RcspTranslationRuntime" |
||||
|
/** 每腿 writeAudioData 缓冲队列上限。整段下发后队列里几乎不会堆积,留 8 个槽足够。 */ |
||||
|
private const val WRITE_QUEUE_LIMIT = 8 |
||||
|
/** 单段 PCM 上限:≈ 60s 16k mono 16bit = 1.92MB,正常 utterance 远低于此值。 */ |
||||
|
private const val PCM_BUFFER_HARD_LIMIT_BYTES = 2 * 1024 * 1024 |
||||
|
/** |
||||
|
* 周期性 flush 间隔:每隔此毫秒数巡检一次 buffer,**只要非空就 flush**。 |
||||
|
* |
||||
|
* 实测:低于 1s(试过 200ms)耳机端 RCSP 解码器会被频繁 reset 直至死机, |
||||
|
* 因此周期上限只能保持 1s。降低段间延迟改走"首段优先 flush"路线(B)。 |
||||
|
*/ |
||||
|
private const val PERIODIC_FLUSH_MS = 1_000L |
||||
|
/** |
||||
|
* 首段优先 flush 阈值(路线 B):每个 utterance 的**首段**达到此毫秒数对应字节量 |
||||
|
* 就立即 flush,不等周期。让用户开口到对方听到的延迟尽可能小(< 200ms)。 |
||||
|
* |
||||
|
* 触发后转入周期模式直到 isFinal=true 重置。火山不发 isFinal 的会话里, |
||||
|
* 只有第一句享受首段优先;后续句统一走 [PERIODIC_FLUSH_MS] 周期。这是为了 |
||||
|
* 避免连续讲话被反复识别成"新 utterance"导致每 100ms 切一段、reset 频率失控。 |
||||
|
*/ |
||||
|
private const val FIRST_SEGMENT_FLUSH_MS = 100L |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 串行化的 writeAudioData 调度器(每个 source 一个)。 |
||||
|
* |
||||
|
* RCSP/SPP 写入要求:上一帧 [writeAudioData] 的回调返回(成功或失败)之前不要再发, |
||||
|
* 否则 SDK 会拒收新请求并报 "Operation in progress"。这里维护一个 in-flight ≤ 1 |
||||
|
* 的 FIFO 队列,溢出时丢最老的帧(保实时性,不堆积延迟)。 |
||||
|
*/ |
||||
|
private class WriteScheduler( |
||||
|
private val tag: String, |
||||
|
private val send: (AudioData, OnRcspActionCallback<Boolean>) -> Unit, |
||||
|
) { |
||||
|
private val queue = ArrayDeque<AudioData>(WRITE_QUEUE_LIMIT) |
||||
|
private val inFlight = AtomicBoolean(false) |
||||
|
private val lock = Any() |
||||
|
@Volatile private var droppedSinceReport = 0L |
||||
|
@Volatile private var lastReportMs = 0L |
||||
|
|
||||
|
fun enqueue(data: AudioData) { |
||||
|
val toSend: AudioData? = synchronized(lock) { |
||||
|
if (inFlight.compareAndSet(false, true)) { |
||||
|
data |
||||
|
} else { |
||||
|
if (queue.size >= WRITE_QUEUE_LIMIT) { |
||||
|
queue.pollFirst() // 丢最老 |
||||
|
droppedSinceReport++ |
||||
|
val now = System.currentTimeMillis() |
||||
|
if (now - lastReportMs >= 1000L) { |
||||
|
Log.w(TAG, "writeAudioData[$tag] queue full, dropped=$droppedSinceReport (last 1s)") |
||||
|
droppedSinceReport = 0 |
||||
|
lastReportMs = now |
||||
|
} |
||||
|
} |
||||
|
queue.offerLast(data) |
||||
|
null |
||||
|
} |
||||
|
} |
||||
|
if (toSend != null) dispatch(toSend) |
||||
|
} |
||||
|
|
||||
|
private fun dispatch(data: AudioData) { |
||||
|
send(data, object : OnRcspActionCallback<Boolean> { |
||||
|
override fun onSuccess(d: BluetoothDevice?, ok: Boolean?) = onComplete() |
||||
|
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { |
||||
|
val msg = err?.message.orEmpty() |
||||
|
if (!msg.contains("Operation in progress", ignoreCase = true)) { |
||||
|
Log.w(TAG, "writeAudioData[$tag] error: code=${err?.code} msg=$msg") |
||||
|
} |
||||
|
onComplete() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
private fun onComplete() { |
||||
|
val next: AudioData? = synchronized(lock) { |
||||
|
val n = queue.pollFirst() |
||||
|
if (n == null) inFlight.set(false) |
||||
|
n |
||||
|
} |
||||
|
if (next != null) dispatch(next) |
||||
|
} |
||||
|
|
||||
|
fun clear() { |
||||
|
synchronized(lock) { |
||||
|
queue.clear() |
||||
|
inFlight.set(false) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private val uplinkWriter = WriteScheduler("uplink") { data, cb -> |
||||
|
translationImpl.writeAudioData(data, cb) |
||||
|
} |
||||
|
private val downlinkWriter = WriteScheduler("downlink") { data, cb -> |
||||
|
translationImpl.writeAudioData(data, cb) |
||||
|
} |
||||
|
private val phoneMicWriter = WriteScheduler("phoneMic") { data, cb -> |
||||
|
translationImpl.writeAudioData(data, cb) |
||||
|
} |
||||
|
/** 兜底:PCM 直传 / 未识别 source 走这条;和 OPUS 编码器互不抢 in-flight 槽位。 */ |
||||
|
private val rawWriter = WriteScheduler("raw") { data, cb -> |
||||
|
translationImpl.writeAudioData(data, cb) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Per-leg PCM 缓冲。`feedTtsPcm` 累积写入,`isFinal=true` 时整段编码下发后清空。 |
||||
|
* |
||||
|
* key = outputStreamId(OUT_UPLINK / OUT_DOWNLINK / OUT_SPEAKER 等) |
||||
|
*/ |
||||
|
private val pcmBuffers = mutableMapOf<String, ByteArrayOutputStream>() |
||||
|
/** 每条 leg 是否仍处于"等待首段触发"状态(路线 B 首段优先 flush 用)。 */ |
||||
|
private val firstSegmentPending = mutableMapOf<String, Boolean>() |
||||
|
private val bufferLock = Any() |
||||
|
private var encodeSeq = 0L |
||||
|
|
||||
|
/** 周期性 flush 巡检线程;start() 启动,stop() 关闭。 */ |
||||
|
private var flushWatcher: ScheduledExecutorService? = null |
||||
|
|
||||
|
/** 启动前置校验 + 进入翻译模式 */ |
||||
|
fun start(): Result<Unit> { |
||||
|
if (!translationImpl.isInit) { |
||||
|
return Result.failure(IllegalStateException("RCSP not init for ${device.address}")) |
||||
|
} |
||||
|
if (!translationImpl.isSupportTranslation) { |
||||
|
return Result.failure(IllegalStateException("device does not support translation")) |
||||
|
} |
||||
|
if (mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO && |
||||
|
!translationImpl.isSupportCallTranslationWithStereo |
||||
|
) { |
||||
|
return Result.failure(IllegalStateException("device does not support stereo call translation")) |
||||
|
} |
||||
|
if (!tempDir.exists()) tempDir.mkdirs() |
||||
|
|
||||
|
upDecoder?.start() |
||||
|
downDecoder?.start() |
||||
|
stereoDecoder?.start() |
||||
|
|
||||
|
translationImpl.addTranslationCallback(translationCallback) |
||||
|
Log.d(TAG, "enterMode: mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} (waiting for onModeChange/onReceiveAudioData)") |
||||
|
translationImpl.enterMode(mode, translationCallback) |
||||
|
|
||||
|
// OPUS 模式启动周期性 flush 巡检:每 [PERIODIC_FLUSH_MS] 切一次缓冲。 |
||||
|
if (!isPcmMode) startPeriodicFlushWatcher() |
||||
|
return Result.success(Unit) |
||||
|
} |
||||
|
|
||||
|
private fun startPeriodicFlushWatcher() { |
||||
|
flushWatcher?.shutdownNow() |
||||
|
val exec = Executors.newSingleThreadScheduledExecutor { r -> |
||||
|
Thread(r, "rcsp-tts-flush").apply { isDaemon = true } |
||||
|
} |
||||
|
flushWatcher = exec |
||||
|
exec.scheduleAtFixedRate( |
||||
|
{ runCatching { periodicFlush() } }, |
||||
|
PERIODIC_FLUSH_MS, PERIODIC_FLUSH_MS, TimeUnit.MILLISECONDS, |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** 每秒巡检:每条 leg 的 buffer 只要非空就立即切走 + 编码 + 下发(不依赖任何外部信号)。 */ |
||||
|
private fun periodicFlush() { |
||||
|
val toEncode = mutableListOf<Triple<Int, String, ByteArray>>() |
||||
|
synchronized(bufferLock) { |
||||
|
for ((leg, buf) in pcmBuffers) { |
||||
|
if (buf.size() == 0) continue |
||||
|
val source = when (leg) { |
||||
|
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK |
||||
|
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK |
||||
|
else -> AudioData.SOURCE_PHONE_MIC |
||||
|
} |
||||
|
val out = buf.toByteArray() |
||||
|
buf.reset() |
||||
|
Log.d(TAG, "periodicFlush: leg=$leg bytes=${out.size}") |
||||
|
toEncode.add(Triple(source, leg, out)) |
||||
|
} |
||||
|
} |
||||
|
// encode 走 async,必须释放锁后做。 |
||||
|
for ((source, leg, pcm) in toEncode) { |
||||
|
encodeAndDispatchAsync(source, leg, pcm) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 把外部翻译服务回送的 PCM 注入回耳机。 |
||||
|
* |
||||
|
* 策略: |
||||
|
* - PCM 模式:每次调用直接当作一段 AudioData 下发(SDK 内部按 blockMtu 分片)。 |
||||
|
* - OPUS 模式:纯时间驱动 flush —— 火山等端到端服务的段尾事件不可靠, |
||||
|
* 不能依赖 isFinal 或字节量阈值。改为: |
||||
|
* a) feedTtsPcm 仅追加 buffer,不主动 flush(除非 isFinal=true); |
||||
|
* b) [PERIODIC_FLUSH_MS] (1s) 巡检线程每秒切走 buffer 并整段编码下发; |
||||
|
* c) `isFinal=true` 仍短路立即 flush(兼容主动信号,不再依赖)。 |
||||
|
* 最大听感延迟 ≤ 1s,且短句 / 没有段尾事件的服务都不会卡死。 |
||||
|
* |
||||
|
* @param outputStreamId 决定 source: |
||||
|
* [TranslationStreams.OUT_UPLINK] / [TranslationStreams.OUT_DOWNLINK] 用于通话翻译; |
||||
|
* 其它(speaker/localPlayback)由 ModeHandler 自己处理,不会落到这里。 |
||||
|
* @param isFinal 本帧是否为当前 utterance 的最后一帧;触发 buffer 残余立即 flush。 |
||||
|
*/ |
||||
|
fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean { |
||||
|
val source = when (outputStreamId) { |
||||
|
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK |
||||
|
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK |
||||
|
else -> AudioData.SOURCE_PHONE_MIC |
||||
|
} |
||||
|
if (isPcmMode) { |
||||
|
// PCM 直传:每次调用一段 AudioData。`isFinal` 在该模式下不影响下发节奏。 |
||||
|
writeBack(AudioData(source, Constants.AUDIO_TYPE_PCM, pcm)) |
||||
|
return true |
||||
|
} |
||||
|
// OPUS 模式:累积到 buffer,三条 flush 触发链(按优先级): |
||||
|
// 1. isFinal=true → 立即 flush,并把首段优先重置回 true(下一句新算) |
||||
|
// 2. 首段优先(路线 B) → 当前 leg 处于 firstSegmentPending=true 且 buffer |
||||
|
// 累积到 FIRST_SEGMENT_FLUSH_MS 字节量时立即 flush, |
||||
|
// 让"开口到对方听见"的延迟最小化(< 200ms) |
||||
|
// 3. 周期 flush(路线 A) → 由 [PERIODIC_FLUSH_MS] 巡检线程接管 |
||||
|
val pendingFlush: ByteArray? = synchronized(bufferLock) { |
||||
|
val buf = pcmBuffers.getOrPut(outputStreamId) { ByteArrayOutputStream() } |
||||
|
if (pcm.isNotEmpty()) { |
||||
|
if (buf.size() + pcm.size > PCM_BUFFER_HARD_LIMIT_BYTES) { |
||||
|
Log.w(TAG, "feedTtsPcm: leg=$outputStreamId pcm buffer hit hard limit ${PCM_BUFFER_HARD_LIMIT_BYTES}, dropping segment") |
||||
|
buf.reset() |
||||
|
return false |
||||
|
} |
||||
|
buf.write(pcm) |
||||
|
} |
||||
|
// 1) 段尾信号:立即下发尾段并把首段标志重置回 true。 |
||||
|
if (isFinal) { |
||||
|
firstSegmentPending[outputStreamId] = true |
||||
|
val out = buf.toByteArray() |
||||
|
buf.reset() |
||||
|
return@synchronized if (out.isEmpty()) null else out |
||||
|
} |
||||
|
// 2) 首段优先:仅当 leg 处于 pending 状态(首次 utterance 起始或 isFinal 重置后)。 |
||||
|
val pending = firstSegmentPending.getOrDefault(outputStreamId, true) |
||||
|
if (pending) { |
||||
|
val firstFlushBytes = (sampleRateHz * 2 * FIRST_SEGMENT_FLUSH_MS / 1000L).toInt() |
||||
|
if (buf.size() >= firstFlushBytes) { |
||||
|
firstSegmentPending[outputStreamId] = false |
||||
|
val out = buf.toByteArray() |
||||
|
buf.reset() |
||||
|
Log.d(TAG, "firstSegmentFlush: leg=$outputStreamId bytes=${out.size}") |
||||
|
return@synchronized if (out.isEmpty()) null else out |
||||
|
} |
||||
|
} |
||||
|
null |
||||
|
} |
||||
|
if (pendingFlush != null) { |
||||
|
encodeAndDispatchAsync(source, outputStreamId, pendingFlush) |
||||
|
} |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
private fun encodeAndDispatchAsync(source: Int, leg: String, pcmBytes: ByteArray) { |
||||
|
val seq = synchronized(bufferLock) { ++encodeSeq } |
||||
|
val pcmFile = File(tempDir, "tts_${source}_$seq.pcm") |
||||
|
val opusFile = File(tempDir, "tts_${source}_$seq.opus") |
||||
|
try { |
||||
|
pcmFile.writeBytes(pcmBytes) |
||||
|
} catch (e: Throwable) { |
||||
|
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq write pcm file failed: ${e.message}") |
||||
|
return |
||||
|
} |
||||
|
// OpusManager.encodeFile(in, out, callback) 默认 OpusOption(带 head),与 demo 一致。 |
||||
|
val encoder = OpusManager() |
||||
|
encoder.encodeFile(pcmFile.absolutePath, opusFile.absolutePath, object : OnStateCallback { |
||||
|
override fun onStart() {} |
||||
|
override fun onComplete(path: String?) { |
||||
|
try { |
||||
|
val opusBytes = if (opusFile.exists()) opusFile.readBytes() else null |
||||
|
if (opusBytes == null || opusBytes.isEmpty()) { |
||||
|
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq: empty opus output") |
||||
|
} else { |
||||
|
Log.d(TAG, "encodeAndDispatch leg=$leg seq=$seq pcm=${pcmBytes.size}B opus=${opusBytes.size}B") |
||||
|
writeBack(AudioData(source, Constants.AUDIO_TYPE_OPUS, opusBytes)) |
||||
|
} |
||||
|
} finally { |
||||
|
runCatching { encoder.release() } |
||||
|
runCatching { pcmFile.delete() } |
||||
|
runCatching { opusFile.delete() } |
||||
|
} |
||||
|
} |
||||
|
override fun onError(code: Int, message: String?) { |
||||
|
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq encodeFile error code=$code msg=$message") |
||||
|
runCatching { encoder.release() } |
||||
|
runCatching { pcmFile.delete() } |
||||
|
runCatching { opusFile.delete() } |
||||
|
onError(code, "encodeFile $leg: $message") |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun stop() { |
||||
|
runCatching { flushWatcher?.shutdownNow() } |
||||
|
flushWatcher = null |
||||
|
runCatching { |
||||
|
translationImpl.exitMode(object : OnRcspActionCallback<Int> { |
||||
|
override fun onSuccess(d: BluetoothDevice?, t: Int?) {} |
||||
|
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {} |
||||
|
}) |
||||
|
} |
||||
|
runCatching { translationImpl.removeTranslationCallback(translationCallback) } |
||||
|
runCatching { translationImpl.destroy() } |
||||
|
runCatching { upDecoder?.stop() } |
||||
|
runCatching { downDecoder?.stop() } |
||||
|
runCatching { stereoDecoder?.stop() } |
||||
|
runCatching { |
||||
|
uplinkWriter.clear() |
||||
|
downlinkWriter.clear() |
||||
|
phoneMicWriter.clear() |
||||
|
rawWriter.clear() |
||||
|
} |
||||
|
synchronized(bufferLock) { |
||||
|
pcmBuffers.values.forEach { it.reset() } |
||||
|
pcmBuffers.clear() |
||||
|
firstSegmentPending.clear() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 把 OPUS / PCM 帧入队到对应 source 的 [WriteScheduler],由调度器串行发起 RCSP |
||||
|
* writeAudioData,避免并发写入触发 "Operation in progress"。 |
||||
|
*/ |
||||
|
private fun writeBack(data: AudioData) { |
||||
|
val scheduler = when (data.source) { |
||||
|
AudioData.SOURCE_E_SCO_UP_LINK -> uplinkWriter |
||||
|
AudioData.SOURCE_E_SCO_DOWN_LINK -> downlinkWriter |
||||
|
AudioData.SOURCE_PHONE_MIC -> phoneMicWriter |
||||
|
else -> rawWriter |
||||
|
} |
||||
|
scheduler.enqueue(data) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,196 @@ |
|||||
|
package com.jielihome.jielihome.feature.voice |
||||
|
|
||||
|
import android.bluetooth.BluetoothDevice |
||||
|
import com.jieli.bluetooth.bean.base.BaseError |
||||
|
import com.jieli.bluetooth.bean.record.RecordParam |
||||
|
import com.jieli.bluetooth.bean.record.RecordState |
||||
|
import com.jieli.bluetooth.impl.JL_BluetoothManager |
||||
|
import com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback |
||||
|
import com.jieli.bluetooth.interfaces.rcsp.record.OnRecordStateCallback |
||||
|
import com.jielihome.jielihome.audio.OpusStreamDecoder |
||||
|
import com.jielihome.jielihome.bridge.EventDispatcher |
||||
|
import com.jielihome.jielihome.feature.ConnectFeature |
||||
|
|
||||
|
/** |
||||
|
* 语音助手 / 唤醒功能。 |
||||
|
* |
||||
|
* # 工作模型(重要) |
||||
|
* 唤醒是「**耳机端主动检测 + 主动推**」模式。零等待: |
||||
|
* |
||||
|
* 1. 插件初始化时立即注册一个常驻 [OnRecordStateCallback]([attach]) |
||||
|
* 2. 耳机本地检测到唤醒词 / 用户按下语音键 |
||||
|
* → SDK 直接回调 onStateChange(state=START),**不需要 APP 先发任何指令** |
||||
|
* → 紧接着持续回调 state=WORKING + voiceData(PCM 或 OPUS 帧) |
||||
|
* → 通话结束/取消时回调 state=IDLE |
||||
|
* 3. 插件解码后通过 EventDispatcher 推三类事件给宿主: |
||||
|
* `speechStart` / `speechAudio` / `speechEnd` |
||||
|
* |
||||
|
* # 主动控制(可选) |
||||
|
* - [start]:APP 主动触发语音助手(用户点 UI 的"按住说话"按钮场景) |
||||
|
* - [stop]:APP 主动结束(用户松手 / 超时取消) |
||||
|
* - 这两个调用内部封装了 SDK 的 StartSpeechCmd/StopSpeechCmd。 |
||||
|
* 大部分场景下你不需要调它们——耳机会自动管理。 |
||||
|
*/ |
||||
|
class SpeechFeature( |
||||
|
private val btManager: JL_BluetoothManager, |
||||
|
private val connectFeature: ConnectFeature, |
||||
|
private val dispatcher: EventDispatcher, |
||||
|
) { |
||||
|
private val recordOp: RecordOpImpl = RecordOpImpl.getInstance(btManager) |
||||
|
|
||||
|
@Volatile |
||||
|
private var decoder: OpusStreamDecoder? = null |
||||
|
|
||||
|
@Volatile |
||||
|
private var currentParam: RecordParam? = null |
||||
|
|
||||
|
private val recordCallback = OnRecordStateCallback { device, state -> |
||||
|
if (device == null || state == null) return@OnRecordStateCallback |
||||
|
when (state.state) { |
||||
|
RecordState.RECORD_STATE_START -> onStart(device, state) |
||||
|
RecordState.RECORD_STATE_WORKING -> onWorking(device, state) |
||||
|
RecordState.RECORD_STATE_IDLE -> onEnd(device, state) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun attach() { |
||||
|
recordOp.addOnRecordStateCallback(recordCallback) |
||||
|
} |
||||
|
|
||||
|
fun detach() { |
||||
|
recordOp.removeOnRecordStateCallback(recordCallback) |
||||
|
releaseDecoder() |
||||
|
} |
||||
|
|
||||
|
fun isRecording(address: String?): Boolean { |
||||
|
val d = address?.let { connectFeature.deviceByAddress(it) } |
||||
|
?: connectFeature.connectedDevice() ?: return false |
||||
|
return recordOp.isRecording(d) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* APP 主动启动语音助手;通常无需调用(耳机会自动推)。 |
||||
|
* @param voiceType RecordParam.VOICE_TYPE_PCM / SPEEX / OPUS |
||||
|
* @param sampleRate RecordParam.SAMPLE_RATE_8K / 16K(注意取值是 8/16,不是 8000/16000) |
||||
|
* @param vadWay RecordParam.VAD_WAY_DEVICE / SDK |
||||
|
*/ |
||||
|
fun start( |
||||
|
address: String?, |
||||
|
voiceType: Int = RecordParam.VOICE_TYPE_OPUS, |
||||
|
sampleRate: Int = RecordParam.SAMPLE_RATE_16K, |
||||
|
vadWay: Int = RecordParam.VAD_WAY_DEVICE, |
||||
|
onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> }, |
||||
|
) { |
||||
|
val device = address?.let { connectFeature.deviceByAddress(it) } |
||||
|
?: connectFeature.connectedDevice() |
||||
|
?: return onResult(false, "no connected device") |
||||
|
val param = RecordParam(voiceType, sampleRate, vadWay) |
||||
|
recordOp.startRecord(device, param, object : OnRcspActionCallback<Boolean> { |
||||
|
override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) } |
||||
|
override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) } |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun stop( |
||||
|
address: String?, |
||||
|
reason: Int = RecordState.REASON_NORMAL, |
||||
|
onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> }, |
||||
|
) { |
||||
|
val device = address?.let { connectFeature.deviceByAddress(it) } |
||||
|
?: connectFeature.connectedDevice() |
||||
|
?: return onResult(false, "no connected device") |
||||
|
recordOp.stopRecord(device, reason, object : OnRcspActionCallback<Boolean> { |
||||
|
override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) } |
||||
|
override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) } |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// ───── 内部回调处理 ───── |
||||
|
|
||||
|
private fun onStart(device: BluetoothDevice, state: RecordState) { |
||||
|
currentParam = state.recordParam |
||||
|
ensureDecoder(state.recordParam) |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "speechStart", |
||||
|
"address" to device.address, |
||||
|
"voiceType" to state.recordParam?.voiceType, |
||||
|
"sampleRate" to (state.recordParam?.sampleRate ?: 16) * 1000, // 8/16 → 8000/16000 |
||||
|
"vadWay" to state.recordParam?.vadWay, |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
private fun onWorking(device: BluetoothDevice, state: RecordState) { |
||||
|
val payload = state.voiceDataBlock ?: state.voiceData ?: return |
||||
|
val type = (state.recordParam ?: currentParam)?.voiceType ?: RecordParam.VOICE_TYPE_OPUS |
||||
|
when (type) { |
||||
|
RecordParam.VOICE_TYPE_OPUS -> decoder?.feedEncoded(payload) ?: emitFrame(device, payload) |
||||
|
RecordParam.VOICE_TYPE_PCM -> emitFrame(device, payload) |
||||
|
RecordParam.VOICE_TYPE_SPEEX -> { |
||||
|
// 当前未挂 speex 解码器;原样透出,由宿主自己解(极少使用) |
||||
|
emitFrame(device, payload, encoding = "speex") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun onEnd(device: BluetoothDevice, state: RecordState) { |
||||
|
releaseDecoder() |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "speechEnd", |
||||
|
"address" to device.address, |
||||
|
"reason" to state.reason, |
||||
|
"message" to state.message, |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
) |
||||
|
) |
||||
|
currentParam = null |
||||
|
} |
||||
|
|
||||
|
private fun ensureDecoder(param: RecordParam?) { |
||||
|
if (decoder != null) return |
||||
|
if (param?.voiceType != RecordParam.VOICE_TYPE_OPUS) return |
||||
|
val srKhz = param.sampleRate.takeIf { it > 0 } ?: RecordParam.SAMPLE_RATE_16K |
||||
|
val sampleRateHz = srKhz * 1000 |
||||
|
val packetSize = if (srKhz == RecordParam.SAMPLE_RATE_8K) 50 else 200 |
||||
|
decoder = OpusStreamDecoder( |
||||
|
channel = 1, |
||||
|
packetSize = packetSize, |
||||
|
sampleRate = sampleRateHz, |
||||
|
onPcm = { pcm -> emitFrame(connectFeature.connectedDevice(), pcm) }, |
||||
|
onError = { c, m -> |
||||
|
dispatcher.send( |
||||
|
mapOf("type" to "speechError", "code" to c, "message" to "decoder: $m") |
||||
|
) |
||||
|
} |
||||
|
).also { it.start() } |
||||
|
} |
||||
|
|
||||
|
private fun releaseDecoder() { |
||||
|
runCatching { decoder?.stop() } |
||||
|
decoder = null |
||||
|
} |
||||
|
|
||||
|
private fun emitFrame( |
||||
|
device: BluetoothDevice?, |
||||
|
pcm: ByteArray, |
||||
|
encoding: String = "pcm16", |
||||
|
) { |
||||
|
val sr = (currentParam?.sampleRate ?: RecordParam.SAMPLE_RATE_16K) * 1000 |
||||
|
dispatcher.send( |
||||
|
mapOf( |
||||
|
"type" to "speechAudio", |
||||
|
"address" to device?.address, |
||||
|
"encoding" to encoding, |
||||
|
"sampleRate" to sr, |
||||
|
"channels" to 1, |
||||
|
"bitsPerSample" to 16, |
||||
|
"tsMs" to System.currentTimeMillis(), |
||||
|
"pcm" to pcm, |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,314 @@ |
|||||
|
package com.jielihome.jielihome.integration |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.util.Log |
||||
|
import com.aiagent.device_plugin_interface.DeviceCapability |
||||
|
import com.aiagent.device_plugin_interface.DeviceConnectOptions |
||||
|
import com.aiagent.device_plugin_interface.DeviceConnectionState |
||||
|
import com.aiagent.device_plugin_interface.DeviceErrorCode |
||||
|
import com.aiagent.device_plugin_interface.DeviceException |
||||
|
import com.aiagent.device_plugin_interface.DevicePluginConfig |
||||
|
import com.aiagent.device_plugin_interface.DevicePluginEvent |
||||
|
import com.aiagent.device_plugin_interface.DevicePluginEventType |
||||
|
import com.aiagent.device_plugin_interface.DeviceScanFilter |
||||
|
import com.aiagent.device_plugin_interface.DiscoveredDevice |
||||
|
import com.aiagent.device_plugin_interface.NativeDevicePlugin |
||||
|
import com.aiagent.device_plugin_interface.NativeDevicePluginRegistry |
||||
|
import com.aiagent.device_plugin_interface.NativeDeviceSession |
||||
|
import com.jielihome.jielihome.api.JieliEventAdapter |
||||
|
import com.jielihome.jielihome.api.JieliEventListener |
||||
|
import com.jielihome.jielihome.core.JieliHomeServer |
||||
|
import kotlinx.coroutines.channels.BufferOverflow |
||||
|
import kotlinx.coroutines.flow.Flow |
||||
|
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
|
import kotlinx.coroutines.flow.asSharedFlow |
||||
|
import java.util.concurrent.CompletableFuture |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
|
||||
|
/** |
||||
|
* 杰理 [NativeDevicePlugin] 实现 —— 把 [JieliHomeServer] 包成 vendor-agnostic 接口, |
||||
|
* 由 device_manager native ([com.aiagent.device_manager.DefaultNativeDeviceManager]) |
||||
|
* 通过 [NativeDevicePluginRegistry] 创建并管理。 |
||||
|
* |
||||
|
* 注册入口:[register],由 `JielihomePlugin.onAttachedToEngine` 调用一次。 |
||||
|
* |
||||
|
* 注意:本类**不**关闭 [JieliHomeServer] —— server 是进程级单例,可能被 Dart 直连 |
||||
|
* 路径 / translate_server / OTA 等并发使用,本类的 [dispose] 只解订阅事件 + 切断 |
||||
|
* 当前 session,server 自身保留。 |
||||
|
*/ |
||||
|
class JieliNativeDevicePlugin( |
||||
|
private val context: Context, |
||||
|
) : NativeDevicePlugin { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "JieliNativeDevicePlugin" |
||||
|
const val VENDOR_KEY = "jieli" |
||||
|
const val DISPLAY_NAME = "JieLi (杰理)" |
||||
|
|
||||
|
val CAPABILITIES: Set<DeviceCapability> = setOf( |
||||
|
DeviceCapability.SCAN, |
||||
|
DeviceCapability.CONNECT, |
||||
|
DeviceCapability.BOND, |
||||
|
DeviceCapability.BATTERY, |
||||
|
DeviceCapability.CUSTOM_COMMAND, |
||||
|
DeviceCapability.WAKE_WORD, |
||||
|
DeviceCapability.OTA, |
||||
|
DeviceCapability.ON_DEVICE_CALL_TRANSLATION, |
||||
|
DeviceCapability.ON_DEVICE_FACE_TO_FACE_TRANSLATION, |
||||
|
DeviceCapability.ON_DEVICE_RECORDING_TRANSLATION, |
||||
|
) |
||||
|
|
||||
|
/** |
||||
|
* 把杰理工厂注册到 [NativeDevicePluginRegistry],由 [JielihomePlugin] |
||||
|
* 在 onAttachedToEngine 调一次(FlutterPlugin 生命周期保证它早于 device_manager |
||||
|
* 的 listVendors / useVendor 调用)。 |
||||
|
*/ |
||||
|
fun register(appContext: Context) { |
||||
|
NativeDevicePluginRegistry.register( |
||||
|
vendorKey = VENDOR_KEY, |
||||
|
displayName = DISPLAY_NAME, |
||||
|
capabilities = CAPABILITIES, |
||||
|
) { JieliNativeDevicePlugin(appContext) } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override val vendorKey: String = VENDOR_KEY |
||||
|
override val displayName: String = DISPLAY_NAME |
||||
|
override val capabilities: Set<DeviceCapability> = CAPABILITIES |
||||
|
|
||||
|
private val server: JieliHomeServer = JieliHomeServer.get() |
||||
|
|
||||
|
@Volatile private var _initialized = false |
||||
|
@Volatile private var _disposed = false |
||||
|
|
||||
|
@Volatile private var _activeSession: JieliNativeDeviceSession? = null |
||||
|
@Volatile private var _pendingConnect: CompletableFuture<NativeDeviceSession>? = null |
||||
|
@Volatile private var _pendingConnectAddress: String? = null |
||||
|
|
||||
|
private val _events = MutableSharedFlow<DevicePluginEvent>( |
||||
|
replay = 0, |
||||
|
extraBufferCapacity = 64, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
|
||||
|
override val eventStream: Flow<DevicePluginEvent> = _events.asSharedFlow() |
||||
|
|
||||
|
private val listener: JieliEventListener = object : JieliEventAdapter() { |
||||
|
override fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) { |
||||
|
emit(DevicePluginEvent( |
||||
|
type = DevicePluginEventType.BLUETOOTH_STATE_CHANGED, |
||||
|
bluetoothEnabled = enabled, |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
override fun onScanStatus(ble: Boolean, started: Boolean) { |
||||
|
emit(DevicePluginEvent( |
||||
|
type = if (started) DevicePluginEventType.SCAN_STARTED |
||||
|
else DevicePluginEventType.SCAN_STOPPED, |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
override fun onDeviceFound(payload: Map<String, Any?>) { |
||||
|
emit(DevicePluginEvent( |
||||
|
type = DevicePluginEventType.DEVICE_DISCOVERED, |
||||
|
discovered = DiscoveredDevice( |
||||
|
id = (payload["address"] as? String) ?: "", |
||||
|
name = (payload["name"] as? String) ?: "", |
||||
|
rssi = (payload["rssi"] as? Number)?.toInt(), |
||||
|
vendor = VENDOR_KEY, |
||||
|
metadata = mapOf( |
||||
|
"edrAddr" to payload["edrAddr"], |
||||
|
"deviceType" to payload["deviceType"], |
||||
|
"connectWay" to payload["connectWay"], |
||||
|
), |
||||
|
), |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
override fun onConnectionState(address: String, state: Int) { |
||||
|
val session = _activeSession ?: return |
||||
|
if (session.deviceId != address) return |
||||
|
when (state) { |
||||
|
2 -> session.setState(DeviceConnectionState.CONNECTING) |
||||
|
1 -> session.setState(DeviceConnectionState.LINK_CONNECTED) |
||||
|
0 -> { |
||||
|
session.setState(DeviceConnectionState.DISCONNECTED) |
||||
|
_activeSession = null |
||||
|
val pending = _pendingConnect |
||||
|
if (pending != null && !pending.isDone) { |
||||
|
pending.completeExceptionally( |
||||
|
DeviceException( |
||||
|
DeviceErrorCode.DISCONNECTED_REMOTE, |
||||
|
"disconnected before ready", |
||||
|
) |
||||
|
) |
||||
|
_pendingConnect = null |
||||
|
_pendingConnectAddress = null |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onRcspInit(address: String, code: Int) { |
||||
|
val session = _activeSession ?: return |
||||
|
if (session.deviceId != address) return |
||||
|
if (code == 0) { |
||||
|
session.setState(DeviceConnectionState.READY) |
||||
|
val pending = _pendingConnect |
||||
|
if (pending != null && _pendingConnectAddress == address && !pending.isDone) { |
||||
|
pending.complete(session) |
||||
|
_pendingConnect = null |
||||
|
_pendingConnectAddress = null |
||||
|
} |
||||
|
} else { |
||||
|
val pending = _pendingConnect |
||||
|
if (pending != null && _pendingConnectAddress == address && !pending.isDone) { |
||||
|
pending.completeExceptionally( |
||||
|
DeviceException( |
||||
|
DeviceErrorCode.HANDSHAKE_FAILED, |
||||
|
"RCSP init failed code=$code", |
||||
|
) |
||||
|
) |
||||
|
_pendingConnect = null |
||||
|
_pendingConnectAddress = null |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onBattery(address: String?, level: Int?) { |
||||
|
val session = _activeSession ?: return |
||||
|
if (address != null && session.deviceId != address) return |
||||
|
// 由 session 自己 refresh info 拉一次最新快照 |
||||
|
runCatching { session.refreshInfo() } |
||||
|
} |
||||
|
|
||||
|
override fun onTranslationAudio(payload: Map<String, Any?>) { |
||||
|
_activeSession?.emitFeature("jieli.translation.audio", payload) |
||||
|
} |
||||
|
|
||||
|
override fun onTranslationResult(payload: Map<String, Any?>) { |
||||
|
_activeSession?.emitFeature("jieli.translation.result", payload) |
||||
|
} |
||||
|
|
||||
|
override fun onTranslationLog(payload: Map<String, Any?>) { |
||||
|
_activeSession?.emitFeature("jieli.translation.log", payload) |
||||
|
} |
||||
|
|
||||
|
override fun onTranslationError(payload: Map<String, Any?>) { |
||||
|
_activeSession?.emitError( |
||||
|
"device.feature_failed", |
||||
|
"translation: ${payload["code"]} ${payload["message"] ?: ""}", |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ─── NativeDevicePlugin 接口 ────────────────────────────────────────── |
||||
|
|
||||
|
override fun initialize(config: DevicePluginConfig) { |
||||
|
checkAlive() |
||||
|
if (_initialized) return |
||||
|
if (!server.initialized) { |
||||
|
server.initialize( |
||||
|
context = context, |
||||
|
multiDevice = (config.extra["multiDevice"] as? Boolean) ?: false, |
||||
|
skipNoNameDev = (config.extra["skipNoNameDev"] as? Boolean) ?: false, |
||||
|
enableLog = (config.extra["enableLog"] as? Boolean) ?: false, |
||||
|
) |
||||
|
} |
||||
|
server.addEventListener(listener) |
||||
|
_initialized = true |
||||
|
emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_READY)) |
||||
|
} |
||||
|
|
||||
|
override fun startScan(filter: DeviceScanFilter?, timeoutMs: Long?) { |
||||
|
requireInit() |
||||
|
val ms = (timeoutMs ?: 30_000L).toInt() |
||||
|
server.scanFeature.startScan(ms).onFailure { |
||||
|
throw DeviceException("device.scan_failed", it.message, it) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun stopScan() { |
||||
|
if (!_initialized) return |
||||
|
server.scanFeature.stopScan() |
||||
|
} |
||||
|
|
||||
|
override fun isScanning(): Boolean = |
||||
|
_initialized && server.scanFeature.isScanning() |
||||
|
|
||||
|
override fun bondedDevices(): List<DiscoveredDevice> { |
||||
|
// 杰理 SDK 没暴露公开的"已配对"接口;不混入系统经典蓝牙列表。 |
||||
|
return emptyList() |
||||
|
} |
||||
|
|
||||
|
override fun connect(deviceId: String, options: DeviceConnectOptions?): NativeDeviceSession { |
||||
|
requireInit() |
||||
|
// 单设备:有旧 session 时先断 |
||||
|
_activeSession?.let { if (it.deviceId != deviceId) runCatching { it.disconnect() } } |
||||
|
|
||||
|
val extra = options?.extra ?: emptyMap() |
||||
|
val session = JieliNativeDeviceSession( |
||||
|
server = server, |
||||
|
deviceId = deviceId, |
||||
|
initialName = (extra["name"] as? String) ?: "", |
||||
|
capabilities = capabilities, |
||||
|
) |
||||
|
_activeSession = session |
||||
|
|
||||
|
val future = CompletableFuture<NativeDeviceSession>() |
||||
|
_pendingConnect = future |
||||
|
_pendingConnectAddress = deviceId |
||||
|
|
||||
|
server.connectFeature.connect( |
||||
|
bleAddress = deviceId, |
||||
|
edrAddress = extra["edrAddr"] as? String, |
||||
|
deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0, |
||||
|
connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0, |
||||
|
).onFailure { |
||||
|
future.completeExceptionally( |
||||
|
DeviceException(DeviceErrorCode.CONNECT_FAILED, it.message, it) |
||||
|
) |
||||
|
_pendingConnect = null |
||||
|
_pendingConnectAddress = null |
||||
|
} |
||||
|
|
||||
|
val timeout = (options?.timeoutMs ?: 15_000L) |
||||
|
return try { |
||||
|
future.get(timeout, TimeUnit.MILLISECONDS) |
||||
|
} catch (e: java.util.concurrent.TimeoutException) { |
||||
|
_pendingConnect = null |
||||
|
_pendingConnectAddress = null |
||||
|
throw DeviceException(DeviceErrorCode.CONNECT_TIMEOUT, |
||||
|
"connect timeout after ${timeout}ms") |
||||
|
} catch (e: java.util.concurrent.ExecutionException) { |
||||
|
throw e.cause ?: e |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override val activeSession: NativeDeviceSession? |
||||
|
get() = _activeSession?.takeIf { it.state != DeviceConnectionState.DISCONNECTED } |
||||
|
|
||||
|
override fun dispose() { |
||||
|
if (_disposed) return |
||||
|
_disposed = true |
||||
|
runCatching { _activeSession?.disconnect() } |
||||
|
runCatching { server.removeEventListener(listener) } |
||||
|
_activeSession = null |
||||
|
emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_DISPOSED)) |
||||
|
} |
||||
|
|
||||
|
// ─── 内部 ────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
private fun checkAlive() { |
||||
|
if (_disposed) throw IllegalStateException("JieliNativeDevicePlugin disposed") |
||||
|
} |
||||
|
|
||||
|
private fun requireInit() { |
||||
|
checkAlive() |
||||
|
if (!_initialized) throw DeviceException(DeviceErrorCode.PLUGIN_NOT_INITIALIZED) |
||||
|
} |
||||
|
|
||||
|
private fun emit(e: DevicePluginEvent) { |
||||
|
if (!_events.tryEmit(e)) Log.w(TAG, "event buffer full; dropped ${e.type}") |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,266 @@ |
|||||
|
package com.jielihome.jielihome.integration |
||||
|
|
||||
|
import android.util.Log |
||||
|
import com.aiagent.device_plugin_interface.DeviceCallTranslationPort |
||||
|
import com.aiagent.device_plugin_interface.DeviceCapability |
||||
|
import com.aiagent.device_plugin_interface.DeviceConnectionState |
||||
|
import com.aiagent.device_plugin_interface.DeviceErrorCode |
||||
|
import com.aiagent.device_plugin_interface.DeviceException |
||||
|
import com.aiagent.device_plugin_interface.DeviceFeatureEvent |
||||
|
import com.aiagent.device_plugin_interface.DeviceInfo |
||||
|
import com.aiagent.device_plugin_interface.DeviceSessionEvent |
||||
|
import com.aiagent.device_plugin_interface.DeviceSessionEventType |
||||
|
import com.aiagent.device_plugin_interface.NativeDeviceSession |
||||
|
import com.jielihome.jielihome.core.JieliHomeServer |
||||
|
import com.jielihome.jielihome.feature.translation.TranslationStreams |
||||
|
import kotlinx.coroutines.channels.BufferOverflow |
||||
|
import kotlinx.coroutines.flow.Flow |
||||
|
import kotlinx.coroutines.flow.MutableSharedFlow |
||||
|
import kotlinx.coroutines.flow.asSharedFlow |
||||
|
|
||||
|
/** |
||||
|
* 杰理 [NativeDeviceSession] 实现 —— 包装 [JieliHomeServer] 的连接快照与事件。 |
||||
|
* |
||||
|
* 状态机由 [JieliNativeDevicePlugin] 在 RCSP 事件回调里推动: |
||||
|
* - ConnectionStateEvent.state == CONNECTING(2) → connecting |
||||
|
* - state == OK(1) + RcspInit 未到 → linkConnected |
||||
|
* - RcspInit(success=true) → ready |
||||
|
* - state == DISCONNECT(0) → disconnected |
||||
|
*/ |
||||
|
class JieliNativeDeviceSession internal constructor( |
||||
|
private val server: JieliHomeServer, |
||||
|
override val deviceId: String, |
||||
|
initialName: String, |
||||
|
override val capabilities: Set<DeviceCapability>, |
||||
|
) : NativeDeviceSession { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "JieliNativeSession" |
||||
|
} |
||||
|
|
||||
|
override val vendor: String = "jieli" |
||||
|
|
||||
|
@Volatile private var _state = DeviceConnectionState.CONNECTING |
||||
|
@Volatile private var _info = DeviceInfo( |
||||
|
id = deviceId, name = initialName, vendor = vendor, |
||||
|
) |
||||
|
@Volatile private var _disposed = false |
||||
|
|
||||
|
override val state: DeviceConnectionState get() = _state |
||||
|
override val info: DeviceInfo get() = _info |
||||
|
|
||||
|
private val _events = MutableSharedFlow<DeviceSessionEvent>( |
||||
|
replay = 0, |
||||
|
extraBufferCapacity = 32, |
||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST, |
||||
|
) |
||||
|
|
||||
|
override val eventStream: Flow<DeviceSessionEvent> = _events.asSharedFlow() |
||||
|
|
||||
|
// ─── 由 plugin 在事件回调中调用 ───────────────────────────────────────── |
||||
|
|
||||
|
internal fun setState(s: DeviceConnectionState) { |
||||
|
if (_state == s) return |
||||
|
_state = s |
||||
|
emit(DeviceSessionEvent( |
||||
|
type = DeviceSessionEventType.CONNECTION_STATE_CHANGED, |
||||
|
deviceId = deviceId, |
||||
|
connectionState = s, |
||||
|
)) |
||||
|
if (s == DeviceConnectionState.DISCONNECTED) { |
||||
|
_disposed = true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal fun emitFeature(key: String, data: Map<String, Any?>) { |
||||
|
emit(DeviceSessionEvent( |
||||
|
type = DeviceSessionEventType.FEATURE, |
||||
|
deviceId = deviceId, |
||||
|
feature = DeviceFeatureEvent(key = key, data = data), |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
internal fun emitError(code: String, message: String?) { |
||||
|
emit(DeviceSessionEvent( |
||||
|
type = DeviceSessionEventType.ERROR, |
||||
|
deviceId = deviceId, |
||||
|
errorCode = code, |
||||
|
errorMessage = message, |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
private fun emit(e: DeviceSessionEvent) { |
||||
|
if (!_events.tryEmit(e)) { |
||||
|
Log.w(TAG, "session event buffer full; dropped ${e.type}") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun applySnapshot(snapshot: Map<String, Any?>) { |
||||
|
_info = _info.copyWith( |
||||
|
name = (snapshot["name"] as? String).takeIf { !it.isNullOrEmpty() } |
||||
|
?: _info.name, |
||||
|
firmwareVersion = (snapshot["versionName"] as? String) |
||||
|
?: _info.firmwareVersion, |
||||
|
batteryPercent = (snapshot["battery"] as? Number)?.toInt(), |
||||
|
metadata = snapshot, |
||||
|
) |
||||
|
emit(DeviceSessionEvent( |
||||
|
type = DeviceSessionEventType.DEVICE_INFO_UPDATED, |
||||
|
deviceId = deviceId, |
||||
|
deviceInfo = _info, |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
// ─── NativeDeviceSession 接口 ────────────────────────────────────────── |
||||
|
|
||||
|
override fun readRssi(): Int { |
||||
|
requireReady() |
||||
|
throw DeviceException( |
||||
|
DeviceErrorCode.NOT_SUPPORTED, |
||||
|
"jieli native bridge does not expose RSSI yet", |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
override fun readBattery(): Int? { |
||||
|
requireReady() |
||||
|
return server.deviceInfoFeature.snapshot(deviceId) |
||||
|
?.get("battery") as? Int |
||||
|
} |
||||
|
|
||||
|
override fun refreshInfo(): DeviceInfo { |
||||
|
requireReady() |
||||
|
val snap = server.deviceInfoFeature.snapshot(deviceId) ?: return _info |
||||
|
applySnapshot(snap) |
||||
|
return _info |
||||
|
} |
||||
|
|
||||
|
override fun invokeFeature( |
||||
|
featureKey: String, |
||||
|
args: Map<String, Any?>, |
||||
|
): Map<String, Any?> { |
||||
|
requireReady() |
||||
|
return when (featureKey) { |
||||
|
"jieli.translation.support" -> mapOf( |
||||
|
"supportCallStereo" to server.translationFeature |
||||
|
.isSupportCallTranslationWithStereo(deviceId), |
||||
|
) |
||||
|
|
||||
|
"jieli.translation.start" -> { |
||||
|
val modeId = (args["modeId"] as? Number)?.toInt() |
||||
|
?: throw DeviceException( |
||||
|
DeviceErrorCode.INVALID_ARGUMENT, |
||||
|
"modeId(int) required", |
||||
|
) |
||||
|
@Suppress("UNCHECKED_CAST") |
||||
|
val extra = (args["args"] as? Map<String, Any?>) ?: emptyMap() |
||||
|
server.translationFeature.start(modeId, extra).getOrElse { |
||||
|
throw DeviceException( |
||||
|
"device.feature_failed", |
||||
|
"translation.start failed: ${it.message}", |
||||
|
it, |
||||
|
) |
||||
|
} |
||||
|
mapOf("ok" to true) |
||||
|
} |
||||
|
|
||||
|
"jieli.translation.stop" -> { |
||||
|
server.translationFeature.stop() |
||||
|
mapOf("ok" to true) |
||||
|
} |
||||
|
|
||||
|
"jieli.translation.feedTranslatedAudio" -> { |
||||
|
val pcm = args["pcm"] as? ByteArray |
||||
|
?: (args["pcm"] as? List<*>) |
||||
|
?.let { it.map { v -> (v as Number).toByte() }.toByteArray() } |
||||
|
?: throw DeviceException( |
||||
|
DeviceErrorCode.INVALID_ARGUMENT, |
||||
|
"pcm(ByteArray|List<int>) required", |
||||
|
) |
||||
|
val streamId = args["streamId"] as? String |
||||
|
?: throw DeviceException( |
||||
|
DeviceErrorCode.INVALID_ARGUMENT, |
||||
|
"streamId required (e.g. ${TranslationStreams.OUT_UPLINK})", |
||||
|
) |
||||
|
val ok = server.translationFeature.feedTranslatedAudio( |
||||
|
outputStreamId = streamId, |
||||
|
pcm = pcm, |
||||
|
format = com.jielihome.jielihome.feature.translation.AudioFormat( |
||||
|
sampleRate = (args["sampleRate"] as? Number)?.toInt() ?: 16000, |
||||
|
channels = (args["channels"] as? Number)?.toInt() ?: 1, |
||||
|
bitsPerSample = (args["bitsPerSample"] as? Number)?.toInt() ?: 16, |
||||
|
), |
||||
|
isFinal = (args["final"] as? Boolean) == true, |
||||
|
) |
||||
|
mapOf("ok" to ok) |
||||
|
} |
||||
|
|
||||
|
"jieli.translation.feedTranslationResult" -> { |
||||
|
server.translationFeature.feedTranslationResult( |
||||
|
srcLang = args["srcLang"] as? String, |
||||
|
srcText = args["srcText"] as? String, |
||||
|
destLang = args["destLang"] as? String, |
||||
|
destText = args["destText"] as? String, |
||||
|
requestId = args["requestId"] as? String, |
||||
|
) |
||||
|
mapOf("ok" to true) |
||||
|
} |
||||
|
|
||||
|
"jieli.cmd.send" -> { |
||||
|
val opCode = (args["opCode"] as? Number)?.toInt() |
||||
|
?: throw DeviceException( |
||||
|
DeviceErrorCode.INVALID_ARGUMENT, |
||||
|
"opCode required", |
||||
|
) |
||||
|
@Suppress("UNCHECKED_CAST") |
||||
|
val payload = (args["payload"] as? List<Number>) |
||||
|
?.map { it.toByte() }?.toByteArray() |
||||
|
?: byteArrayOf() |
||||
|
// 同步等待(5s 超时)—— RCSP 自定义命令通常 < 500ms 返回。 |
||||
|
val deferred = java.util.concurrent.CompletableFuture<ByteArray?>() |
||||
|
server.customCmdFeature.send( |
||||
|
address = deviceId, |
||||
|
opCode = opCode, |
||||
|
payload = payload, |
||||
|
onSuccess = { data -> deferred.complete(data) }, |
||||
|
onError = { code, msg -> |
||||
|
deferred.completeExceptionally( |
||||
|
DeviceException("device.feature_failed", |
||||
|
"cmd $opCode failed: code=$code msg=${msg ?: ""}") |
||||
|
) |
||||
|
}, |
||||
|
) |
||||
|
val resp = try { |
||||
|
deferred.get(5, java.util.concurrent.TimeUnit.SECONDS) |
||||
|
} catch (e: java.util.concurrent.TimeoutException) { |
||||
|
throw DeviceException( |
||||
|
"device.feature_failed", |
||||
|
"cmd $opCode timeout", |
||||
|
) |
||||
|
} catch (e: java.util.concurrent.ExecutionException) { |
||||
|
throw e.cause ?: e |
||||
|
} |
||||
|
mapOf("response" to resp) |
||||
|
} |
||||
|
|
||||
|
else -> throw DeviceException( |
||||
|
DeviceErrorCode.NOT_SUPPORTED, |
||||
|
"unknown feature \"$featureKey\"", |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun callTranslationPort(): DeviceCallTranslationPort = server.callTranslationPort |
||||
|
|
||||
|
override fun disconnect() { |
||||
|
if (_disposed) return |
||||
|
setState(DeviceConnectionState.DISCONNECTING) |
||||
|
runCatching { server.connectFeature.disconnect(deviceId) } |
||||
|
// 真正的 disconnected 状态由 ConnectionStateEvent 推过来 |
||||
|
} |
||||
|
|
||||
|
private fun requireReady() { |
||||
|
if (_disposed || _state != DeviceConnectionState.READY) { |
||||
|
throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
package com.jielihome.jielihome |
||||
|
|
||||
|
import io.flutter.plugin.common.MethodCall |
||||
|
import io.flutter.plugin.common.MethodChannel |
||||
|
import kotlin.test.Test |
||||
|
import org.mockito.Mockito |
||||
|
|
||||
|
/* |
||||
|
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. |
||||
|
* |
||||
|
* Once you have built the plugin's example app, you can run these tests from the command |
||||
|
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or |
||||
|
* you can run them directly from IDEs that support JUnit such as Android Studio. |
||||
|
*/ |
||||
|
|
||||
|
internal class JielihomePluginTest { |
||||
|
@Test |
||||
|
fun onMethodCall_getPlatformVersion_returnsExpectedValue() { |
||||
|
val plugin = JielihomePlugin() |
||||
|
|
||||
|
val call = MethodCall("getPlatformVersion", null) |
||||
|
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) |
||||
|
plugin.onMethodCall(call, mockResult) |
||||
|
|
||||
|
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
# Miscellaneous |
||||
|
*.class |
||||
|
*.log |
||||
|
*.pyc |
||||
|
*.swp |
||||
|
.DS_Store |
||||
|
.atom/ |
||||
|
.build/ |
||||
|
.buildlog/ |
||||
|
.history |
||||
|
.svn/ |
||||
|
.swiftpm/ |
||||
|
migrate_working_dir/ |
||||
|
|
||||
|
# IntelliJ related |
||||
|
*.iml |
||||
|
*.ipr |
||||
|
*.iws |
||||
|
.idea/ |
||||
|
|
||||
|
# The .vscode folder contains launch configuration and tasks you configure in |
||||
|
# VS Code which you may wish to be included in version control, so this line |
||||
|
# is commented out by default. |
||||
|
#.vscode/ |
||||
|
|
||||
|
# Flutter/Dart/Pub related |
||||
|
**/doc/api/ |
||||
|
**/ios/Flutter/.last_build_id |
||||
|
.dart_tool/ |
||||
|
.flutter-plugins |
||||
|
.flutter-plugins-dependencies |
||||
|
.pub-cache/ |
||||
|
.pub/ |
||||
|
/build/ |
||||
|
|
||||
|
# Symbolication related |
||||
|
app.*.symbols |
||||
|
|
||||
|
# Obfuscation related |
||||
|
app.*.map.json |
||||
|
|
||||
|
# Android Studio will place build artifacts here |
||||
|
/android/app/debug |
||||
|
/android/app/profile |
||||
|
/android/app/release |
||||
@ -0,0 +1,16 @@ |
|||||
|
# jielihome_example |
||||
|
|
||||
|
Demonstrates how to use the jielihome plugin. |
||||
|
|
||||
|
## Getting Started |
||||
|
|
||||
|
This project is a starting point for a Flutter application. |
||||
|
|
||||
|
A few resources to get you started if this is your first Flutter project: |
||||
|
|
||||
|
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) |
||||
|
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) |
||||
|
|
||||
|
For help getting started with Flutter development, view the |
||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials, |
||||
|
samples, guidance on mobile development, and a full API reference. |
||||
@ -0,0 +1,28 @@ |
|||||
|
# This file configures the analyzer, which statically analyzes Dart code to |
||||
|
# check for errors, warnings, and lints. |
||||
|
# |
||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled |
||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be |
||||
|
# invoked from the command line by running `flutter analyze`. |
||||
|
|
||||
|
# The following line activates a set of recommended lints for Flutter apps, |
||||
|
# packages, and plugins designed to encourage good coding practices. |
||||
|
include: package:flutter_lints/flutter.yaml |
||||
|
|
||||
|
linter: |
||||
|
# The lint rules applied to this project can be customized in the |
||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml` |
||||
|
# included above or to enable additional rules. A list of all available lints |
||||
|
# and their documentation is published at https://dart.dev/lints. |
||||
|
# |
||||
|
# Instead of disabling a lint rule for the entire project in the |
||||
|
# section below, it can also be suppressed for a single line of code |
||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and |
||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file |
||||
|
# producing the lint. |
||||
|
rules: |
||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule |
||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule |
||||
|
|
||||
|
# Additional information about this file can be found at |
||||
|
# https://dart.dev/guides/language/analysis-options |
||||
@ -0,0 +1,14 @@ |
|||||
|
gradle-wrapper.jar |
||||
|
/.gradle |
||||
|
/captures/ |
||||
|
/gradlew |
||||
|
/gradlew.bat |
||||
|
/local.properties |
||||
|
GeneratedPluginRegistrant.java |
||||
|
.cxx/ |
||||
|
|
||||
|
# Remember to never publicly share your keystore. |
||||
|
# See https://flutter.dev/to/reference-keystore |
||||
|
key.properties |
||||
|
**/*.keystore |
||||
|
**/*.jks |
||||
@ -0,0 +1,45 @@ |
|||||
|
plugins { |
||||
|
id("com.android.application") |
||||
|
id("kotlin-android") |
||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. |
||||
|
id("dev.flutter.flutter-gradle-plugin") |
||||
|
} |
||||
|
|
||||
|
android { |
||||
|
namespace = "com.jielihome.jielihome_example" |
||||
|
compileSdk = 36 |
||||
|
ndkVersion = "27.0.12077973" |
||||
|
|
||||
|
compileOptions { |
||||
|
sourceCompatibility = JavaVersion.VERSION_17 |
||||
|
targetCompatibility = JavaVersion.VERSION_17 |
||||
|
} |
||||
|
|
||||
|
kotlinOptions { |
||||
|
jvmTarget = JavaVersion.VERSION_17.toString() |
||||
|
} |
||||
|
|
||||
|
defaultConfig { |
||||
|
applicationId = "com.jielihome.jielihome_example" |
||||
|
minSdk = 21 |
||||
|
targetSdk = flutter.targetSdkVersion |
||||
|
versionCode = flutter.versionCode |
||||
|
versionName = flutter.versionName |
||||
|
} |
||||
|
|
||||
|
buildTypes { |
||||
|
release { |
||||
|
// TODO: Add your own signing config for the release build. |
||||
|
// Signing with the debug keys for now, so `flutter run --release` works. |
||||
|
signingConfig = signingConfigs.getByName("debug") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
flutter { |
||||
|
source = "../.." |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) |
||||
|
} |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,7 @@ |
|||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
<!-- The INTERNET permission is required for development. Specifically, |
||||
|
the Flutter tool needs it to communicate with the running application |
||||
|
to allow setting breakpoints, to provide hot reload, etc. |
||||
|
--> |
||||
|
<uses-permission android:name="android.permission.INTERNET"/> |
||||
|
</manifest> |
||||
@ -0,0 +1,55 @@ |
|||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
<uses-permission android:name="android.permission.INTERNET" /> |
||||
|
<uses-permission android:name="android.permission.BLUETOOTH" |
||||
|
android:maxSdkVersion="30" /> |
||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
||||
|
android:maxSdkVersion="30" /> |
||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> |
||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> |
||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
||||
|
<application |
||||
|
android:label="jielihome_example" |
||||
|
android:name="${applicationName}" |
||||
|
android:icon="@mipmap/ic_launcher"> |
||||
|
<activity |
||||
|
android:name=".MainActivity" |
||||
|
android:exported="true" |
||||
|
android:launchMode="singleTop" |
||||
|
android:taskAffinity="" |
||||
|
android:theme="@style/LaunchTheme" |
||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" |
||||
|
android:hardwareAccelerated="true" |
||||
|
android:windowSoftInputMode="adjustResize"> |
||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as |
||||
|
the Android process has started. This theme is visible to the user |
||||
|
while the Flutter UI initializes. After that, this theme continues |
||||
|
to determine the Window background behind the Flutter UI. --> |
||||
|
<meta-data |
||||
|
android:name="io.flutter.embedding.android.NormalTheme" |
||||
|
android:resource="@style/NormalTheme" |
||||
|
/> |
||||
|
<intent-filter> |
||||
|
<action android:name="android.intent.action.MAIN"/> |
||||
|
<category android:name="android.intent.category.LAUNCHER"/> |
||||
|
</intent-filter> |
||||
|
</activity> |
||||
|
<!-- Don't delete the meta-data below. |
||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> |
||||
|
<meta-data |
||||
|
android:name="flutterEmbedding" |
||||
|
android:value="2" /> |
||||
|
</application> |
||||
|
<!-- Required to query activities that can process text, see: |
||||
|
https://developer.android.com/training/package-visibility and |
||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. |
||||
|
|
||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> |
||||
|
<queries> |
||||
|
<intent> |
||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/> |
||||
|
<data android:mimeType="text/plain"/> |
||||
|
</intent> |
||||
|
</queries> |
||||
|
</manifest> |
||||
@ -0,0 +1,5 @@ |
|||||
|
package com.jielihome.jielihome_example |
||||
|
|
||||
|
import io.flutter.embedding.android.FlutterActivity |
||||
|
|
||||
|
class MainActivity : FlutterActivity() |
||||
@ -0,0 +1,12 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<!-- Modify this file to customize your launch splash screen --> |
||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
<item android:drawable="?android:colorBackground" /> |
||||
|
|
||||
|
<!-- You can insert your own image assets here --> |
||||
|
<!-- <item> |
||||
|
<bitmap |
||||
|
android:gravity="center" |
||||
|
android:src="@mipmap/launch_image" /> |
||||
|
</item> --> |
||||
|
</layer-list> |
||||
@ -0,0 +1,12 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<!-- Modify this file to customize your launch splash screen --> |
||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
<item android:drawable="@android:color/white" /> |
||||
|
|
||||
|
<!-- You can insert your own image assets here --> |
||||
|
<!-- <item> |
||||
|
<bitmap |
||||
|
android:gravity="center" |
||||
|
android:src="@mipmap/launch_image" /> |
||||
|
</item> --> |
||||
|
</layer-list> |
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,18 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<resources> |
||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> |
||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> |
||||
|
<!-- Show a splash screen on the activity. Automatically removed when |
||||
|
the Flutter engine draws its first frame --> |
||||
|
<item name="android:windowBackground">@drawable/launch_background</item> |
||||
|
</style> |
||||
|
<!-- Theme applied to the Android Window as soon as the process has started. |
||||
|
This theme determines the color of the Android Window while your |
||||
|
Flutter UI initializes, as well as behind your Flutter UI while its |
||||
|
running. |
||||
|
|
||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. --> |
||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> |
||||
|
<item name="android:windowBackground">?android:colorBackground</item> |
||||
|
</style> |
||||
|
</resources> |
||||
@ -0,0 +1,18 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<resources> |
||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> |
||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> |
||||
|
<!-- Show a splash screen on the activity. Automatically removed when |
||||
|
the Flutter engine draws its first frame --> |
||||
|
<item name="android:windowBackground">@drawable/launch_background</item> |
||||
|
</style> |
||||
|
<!-- Theme applied to the Android Window as soon as the process has started. |
||||
|
This theme determines the color of the Android Window while your |
||||
|
Flutter UI initializes, as well as behind your Flutter UI while its |
||||
|
running. |
||||
|
|
||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. --> |
||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> |
||||
|
<item name="android:windowBackground">?android:colorBackground</item> |
||||
|
</style> |
||||
|
</resources> |
||||
@ -0,0 +1,7 @@ |
|||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
<!-- The INTERNET permission is required for development. Specifically, |
||||
|
the Flutter tool needs it to communicate with the running application |
||||
|
to allow setting breakpoints, to provide hot reload, etc. |
||||
|
--> |
||||
|
<uses-permission android:name="android.permission.INTERNET"/> |
||||
|
</manifest> |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue