diff --git a/lib/core/utils/permission_util.dart b/lib/core/utils/permission_util.dart new file mode 100644 index 000000000..e51aafb3f --- /dev/null +++ b/lib/core/utils/permission_util.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:app_settings/app_settings.dart'; +import 'dart:developer'; +import 'logger.dart'; + +// class Logger { +// static void e(String tag, String message) { +// debugPrint('[$tag] ERROR: $message'); +// } +// } + +class PermissionUtil { + static final PermissionUtil instance = PermissionUtil._(); + PermissionUtil._(); + + OverlayEntry? _explanationOverlayEntry; + + Future requestPermission({ + required BuildContext context, + required Permission permissionType, + required String permissionName, + required String explanationText, + required String permanentDenialText, + String settingsButtonText = '前往设置', + String cancelButtonText = '取消', + }) async { + try { + final PermissionStatus status = await permissionType.status; + log('初始权限状态 for $permissionName: $status'); + + // 处理已授权状态(包括授予状态和临时通知权限) + if (status == PermissionStatus.granted || + status == PermissionStatus.provisional) { + log('$permissionName 权限已授予或临时授权。'); + return true; + } + + // 处理永久拒绝状态 + if (status == PermissionStatus.permanentlyDenied) { + log('$permissionName 权限被永久拒绝,显示引导对话框。'); + await _showPermanentDenialDialog( + context: context, + permissionName: permissionName, + permanentDenialText: permanentDenialText, + settingsButtonText: settingsButtonText, + cancelButtonText: cancelButtonText, + ); + return false; + } + + // 处理初始拒绝/未决定状态 + if (status == PermissionStatus.denied) { + log('$permissionName 权限被拒绝或未决定,显示说明横幅并请求。'); + _showExplanationOverlay( + context: context, + permissionName: permissionName, + explanationText: explanationText, + ); + + final PermissionStatus newStatus = await permissionType.request(); + log('请求后 $permissionName 的新权限状态: $newStatus'); + + _hideExplanationOverlay(); + + // 处理请求后的状态 + if (newStatus == PermissionStatus.granted || + newStatus == PermissionStatus.provisional) { + log('$permissionName 权限在请求后被授予或临时授权。'); + return true; + } else if (newStatus == PermissionStatus.permanentlyDenied) { + log('$permissionName 权限在请求后被永久拒绝,再次显示引导对话框。'); + await _showPermanentDenialDialog( + context: context, + permissionName: permissionName, + permanentDenialText: permanentDenialText, + settingsButtonText: settingsButtonText, + cancelButtonText: cancelButtonText, + ); + } + return false; + } + + // 处理受限状态 + if (status == PermissionStatus.restricted || + status == PermissionStatus.limited) { + log('$permissionName 权限受限或受限访问: $status'); + await _showRestrictedPermissionDialog(context, permissionName); + return false; + } + + // 其他未知状态处理 + log('$permissionName 处于未知状态: $status'); + return false; + } catch (e) { + Logger.e('PermissionUtil', '请求 $permissionName 权限失败: $e'); + _hideExplanationOverlay(); + return false; + } + } + + void _showExplanationOverlay({ + required BuildContext context, + required String permissionName, + required String explanationText, + }) { + _hideExplanationOverlay(); // 确保移除旧叠加层 + + _explanationOverlayEntry = OverlayEntry( + builder: (context) => Positioned( + top: MediaQuery.of(context).padding.top + 10, + left: 16, + right: 16, + child: Material( + borderRadius: BorderRadius.circular(12), + elevation: 4, + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '需要$permissionName权限', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Text( + explanationText, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ), + ); + + Future.delayed(Duration.zero, () { + if (_explanationOverlayEntry != null) { + Overlay.of(context).insert(_explanationOverlayEntry!); + log('权限说明叠加层已显示。'); + } + }); + } + + void _hideExplanationOverlay() { + _explanationOverlayEntry?.remove(); + _explanationOverlayEntry = null; + } + + Future _showPermanentDenialDialog({ + required BuildContext context, + required String permissionName, + required String permanentDenialText, + required String settingsButtonText, + required String cancelButtonText, + }) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text('需要$permissionName权限'), + content: Text(permanentDenialText), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(cancelButtonText), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + AppSettings.openAppSettings(); + }, + child: Text(settingsButtonText), + ), + ], + ), + ); + } + + Future _showRestrictedPermissionDialog( + BuildContext context, String permissionName) async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('$permissionName权限受限'), + content: const Text('此功能在当前设备上不可用或受限,请检查设备设置或系统限制'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('确定'), + ), + ], + ), + ); + } +} diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 75c2457c6..9cc9a2f0e 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -29,6 +29,12 @@ abstract class AsrService { /// 检查连续识别是否活跃 bool isContinuousRecognitionActive(); + /// 开启录音 + Future enableRecord(); + + /// 停止录音 + Future stopRecord(); + /// 释放资源 Future dispose(); } diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index ca7cbd4e3..4fd99d91e 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -179,6 +179,19 @@ class BleManager extends GetxService { state == BleConnectionState.error) { Logger.info('BLE设备已断开或发生错误'); _isConnected.value = false; + // 更新设备信息 + _updateDeviceInfoIfNeeded( + batteryLevel: 0, + leftEarBattery: 0, + rightEarBattery: 0, + caseBattery: 0, + leftEarCharging: false, + rightEarCharging: false, + caseCharging: false, + firmwareVersion: '', + firmwareProductId: '', + firmwareProductColor: '', + ); } } diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index fe0122717..b44d2aa2b 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -342,4 +342,28 @@ class AzureAsrService extends GetxService implements AsrService { return false; } } -} \ No newline at end of file + + @override + Future enableRecord() async { + try { + final bool result = await _channel.invokeMethod('enableRecord'); + + return result; + } catch (e) { + Logger.error('开始录音: ${e.toString()}'); + rethrow; + } + } + + @override + Future stopRecord() async { + try { + final bool result = await _channel.invokeMethod('stopRecord'); + + return result; + } catch (e) { + Logger.error('停止录音: ${e.toString()}'); + rethrow; + } + } +} diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index ed93f2a99..1a80b8679 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -16,6 +16,7 @@ import 'package:app_settings/app_settings.dart'; import 'package:deep_voice/core/utils/logger.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_view/photo_view.dart'; +import '../../../core/utils/permission_util.dart'; class Message { final bool isUser; //标识信息是否来自用户还是AI @@ -697,7 +698,16 @@ class AgentController extends GetxController { // 如果要切换到语音模式,需要先检查麦克风权限 if (isTextInputMode.value) { - final hasPermission = await _checkMicrophonePermission(); + // 使用 PermissionUtil 检查麦克风权限 + final hasPermission = await PermissionUtil.instance.requestPermission( + context: Get.context!, + permissionType: Permission.microphone, + permissionName: '麦克风', + explanationText: '语音识别与翻译功能需要访问麦克风。', + permanentDenialText: '您已永久拒绝麦克风权限,请前往系统设置开启权限。', + settingsButtonText: '去设置', + cancelButtonText: '取消', + ); if (!hasPermission) { return; // 权限未获取,保持文本模式 } @@ -715,54 +725,54 @@ class AgentController extends GetxController { } } - // 检查麦克风权限 +// 检查麦克风权限,返回true表示已授权,false表示未授权 Future _checkMicrophonePermission() async { try { - // iOS平台直接返回true,不处理权限问题 - if (Platform.isIOS) { + // 1.先获取当前麦克风权限状态 + final micStatus = await Permission.microphone.status; + + // 2.如果麦克风权限已授予,返回true + if (micStatus.isGranted) { return true; } - // 创建待请求权限列表 + // 3.创建待请求权限列表(这里只请求麦克风权限) List permissionsToRequest = []; - - // Android 需要考虑SDK版本不同的权限要求 permissionsToRequest.add(Permission.microphone); - // 批量请求所有需要的权限 + // 4.弹出权限说明对话框(首次拒绝/还没授权时展示) + if (micStatus.isPermanentlyDenied) { + _showPermissionDialog(true); + } else { + _showPermissionDialog(false); + } + + // 5.请求权限,并获取返回的权限状态集合 final statuses = await permissionsToRequest.request(); - // 检查是否所有权限都已授予 - bool allGranted = true; + // 6.用于记录被拒绝的权限(便于后续判断) Permission? deniedPermission; + // 7.遍历权限状态集合,检查是否有任何权限被拒绝 statuses.forEach((permission, status) { if (!status.isGranted) { - allGranted = false; deniedPermission = permission; Logger.e(TAG, '权限 ${permission.toString()} 未被授予: $status'); } }); - // 单独检查麦克风权限状态,因为这是主要需求 - final micStatus = await Permission.microphone.status; + // // 8. 如果是“永久拒绝”(用户勾选了“不再询问”) + // if (micStatus.isPermanentlyDenied) { + // _showPermissionDialog(true); + // return false; + // } - // 如果所有权限都已授予,或至少麦克风权限已授予,返回true - if (allGranted || micStatus.isGranted) { - return true; - } - - // 处理特定权限被永久拒绝的情况 - if (micStatus.isPermanentlyDenied) { - _showPermissionDialog(true); - return false; - } - - // 普通拒绝情况 - if (deniedPermission != null) { - _showPermissionDialog(false); - } + // // 9. 普通的拒绝情况,再次弹出说明对话框 + // if (deniedPermission != null) { + // _showPermissionDialog(false); + // } + // 10. 如果权限未被授予,返回 false return false; } catch (e) { Logger.e(TAG, '检查麦克风权限失败: $e'); @@ -770,6 +780,87 @@ class AgentController extends GetxController { } } + // 显示权限提示对话框 + void _showPermissionDialog(bool isPermanent) { + Get.generalDialog( + barrierDismissible: false, + barrierLabel: '权限提示', + transitionBuilder: (context, anim1, anim2, child) { + return SlideTransition( + position: Tween( + begin: Offset(0, -1), // 从上方滑入 + end: Offset(0, 0), + ).animate(CurvedAnimation(parent: anim1, curve: Curves.easeOut)), + child: child, + ); + }, + pageBuilder: (context, anim1, anim2) { + return SafeArea( + child: Align( + alignment: Alignment.topCenter, // 关键:设置为顶部对齐 + child: Padding( + padding: const EdgeInsets.only(top: 80, left: 24, right: 24), + child: Material( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('需要麦克风权限', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Text(isPermanent + ? '语音功能需要麦克风权限。请前往设置页面手动开启权限。' + : '语音功能需要麦克风权限。请允许使用麦克风权限以继续使用语音功能。'), + const SizedBox(height: 16), + const Text('权限使用说明:', + style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('• 麦克风权限:用于录制您的语音,实现语音翻译、语音助手对话等核心功能'), + const SizedBox(height: 8), + const Text('• 我们仅在您主动使用语音功能时访问麦克风'), + const SizedBox(height: 8), + const Text('• 您可以随时在系统设置中关闭权限'), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('取消'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () { + Get.back(); + if (isPermanent) { + AppSettings.openAppSettings(); + } else { + Permission.microphone.request(); + } + }, + child: Text(isPermanent ? '前往设置' : '授权'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + // 获取Android SDK版本 Future _getAndroidSdkVersion() async { if (Platform.isAndroid) { @@ -784,41 +875,20 @@ class AgentController extends GetxController { return 0; } - // 显示权限提示对话框 - void _showPermissionDialog(bool isPermanent) { - Get.dialog( - AlertDialog( - title: const Text('需要麦克风权限'), - content: Text(isPermanent - ? '语音功能需要麦克风权限。请前往设置页面手动开启权限。' - : '语音功能需要麦克风权限。请允许使用麦克风权限以继续使用语音功能。'), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: const Text('取消'), - ), - TextButton( - onPressed: () { - Get.back(); - if (isPermanent) { - AppSettings.openAppSettings(); - } else { - Permission.microphone.request(); - } - }, - child: Text(isPermanent ? '前往设置' : '授权'), - ), - ], - ), - ); - } - // 开始语音输入 Future startVoiceInput() async { if (isListening.value) return; // 检查麦克风权限 - final hasPermission = await _checkMicrophonePermission(); + final hasPermission = await PermissionUtil.instance.requestPermission( + context: Get.context!, + permissionType: Permission.microphone, + permissionName: '麦克风', + explanationText: '语音识别与翻译功能需要访问麦克风。', + permanentDenialText: '您已永久拒绝麦克风权限,请前往系统设置开启权限。', + settingsButtonText: '去设置', + cancelButtonText: '取消', + ); if (!hasPermission) { isTextInputMode.value = true; // 如果没有权限,切回文本模式 return; diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index b4a7a0ed4..1035bc83d 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -46,8 +46,6 @@ class MessageBubble extends GetView { isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - //if (!isUser) _buildAvatar(isUser, context), - //const SizedBox(width: 8), Flexible( child: Column( crossAxisAlignment: @@ -90,21 +88,41 @@ class MessageBubble extends GetView { const SizedBox(height: 4), // 识别中的消息不显示时间 if (!isRecognizing) - Text( - timeFormat.format(timestamp), - style: TextStyle( - fontSize: 10, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.grey[400] - : Colors.grey[500], - fontWeight: FontWeight.w400, - ), + Row( + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.spaceBetween, + children: [ + // 时间戳(左对齐或右对齐) + Text( + timeFormat.format(timestamp), + style: TextStyle( + fontSize: 10, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[400] + : Colors.grey[500], + fontWeight: FontWeight.w400, + ), + ), + // AI标识(仅在AI消息时显示) + if (!isUser) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + "由AI生成".tr, + style: TextStyle( + fontSize: 8, + color: Colors.blue[600], + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic, + ), + ), + ), + ], ), ], ), ), - //const SizedBox(width: 8), - //if (isUser) _buildAvatar(isUser, context), ], ), ); @@ -125,65 +143,6 @@ class MessageBubble extends GetView { } } - /// 构建头像 - // Widget _buildAvatar(bool isUser, BuildContext context) { - // if (isUser) { - // final storage = GetStorage(); - // final localAvatarPath = storage.read('local_avatar') ?? ''; - - // return Container( - // width: 36, - // height: 36, - // decoration: BoxDecoration( - // color: localAvatarPath.isEmpty ? Colors.grey[300] : null, - // borderRadius: BorderRadius.circular(18), - // boxShadow: [ - // BoxShadow( - // color: Colors.black.withOpacity(0.05), - // blurRadius: 2, - // offset: const Offset(0, 1), - // ), - // ], - // ), - // child: localAvatarPath.isNotEmpty - // ? ClipRRect( - // borderRadius: BorderRadius.circular(18), - // child: Image.file( - // File(localAvatarPath), - // fit: BoxFit.cover, - // ), - // ) - // : Icon( - // Icons.person, - // color: Colors.grey[700], - // size: 20, - // ), - // ); - // } else { - // // AI头像保持不变 - // return Container( - // width: 36, - // height: 36, - // decoration: BoxDecoration( - // color: Theme.of(context).primaryColor, - // borderRadius: BorderRadius.circular(18), - // boxShadow: [ - // BoxShadow( - // color: Colors.black.withOpacity(0.05), - // blurRadius: 2, - // offset: const Offset(0, 1), - // ), - // ], - // ), - // child: Icon( - // Icons.smart_toy_outlined, - // color: Colors.white, - // size: 20, - // ), - // ); - // } - // } - /// 构建消息内容 Widget _buildMessageContent(BuildContext context) { // 如果是识别中的消息,添加特殊样式 diff --git a/lib/modules/explore/controllers/explore_controller.dart b/lib/modules/explore/controllers/explore_controller.dart index 3d0af7ee9..1cb448ecd 100644 --- a/lib/modules/explore/controllers/explore_controller.dart +++ b/lib/modules/explore/controllers/explore_controller.dart @@ -75,28 +75,28 @@ class ExploreController extends GetxController { agentId: 'simultaneous_interpreter', targetRoute: Routes.translation, ), - AIFeature( - title: 'aiGirlfriend'.tr, // 亲子陪伴 - description: 'aiGirlfriendInfo'.tr, // 智能陪伴,随时聊天 - icon: Icons.favorite, - startColor: const Color(0xFFE91E63), - endColor: const Color(0xFFFF5252), - iconBgColor: const Color(0xFFE91E63).withOpacity(0.2), - iconColor: const Color(0xFFE91E63), - agentId: 'cyber_girlfriend', - targetRoute: Routes.chat, - ), - AIFeature( - title: 'healthConsultant'.tr, // 健康顾问 - description: 'healthConsultantInfo'.tr, // 提供科学健康建议 - icon: Icons.healing, - startColor: const Color(0xFFFFC107), - endColor: const Color(0xFFFF9800), - iconBgColor: const Color(0xFFFFC107).withOpacity(0.2), - iconColor: const Color(0xFFFFC107), - agentId: 'health_advisor', - targetRoute: Routes.chat, - ), + // AIFeature( + // title: 'aiGirlfriend'.tr, // 亲子陪伴 + // description: 'aiGirlfriendInfo'.tr, // 智能陪伴,随时聊天 + // icon: Icons.favorite, + // startColor: const Color(0xFFE91E63), + // endColor: const Color(0xFFFF5252), + // iconBgColor: const Color(0xFFE91E63).withOpacity(0.2), + // iconColor: const Color(0xFFE91E63), + // agentId: 'cyber_girlfriend', + // targetRoute: Routes.chat, + // ), + // AIFeature( + // title: 'healthConsultant'.tr, // 健康顾问 + // description: 'healthConsultantInfo'.tr, // 提供科学健康建议 + // icon: Icons.healing, + // startColor: const Color(0xFFFFC107), + // endColor: const Color(0xFFFF9800), + // iconBgColor: const Color(0xFFFFC107).withOpacity(0.2), + // iconColor: const Color(0xFFFFC107), + // agentId: 'health_advisor', + // targetRoute: Routes.chat, + // ), ]; // 热门功能场景列表 @@ -105,9 +105,9 @@ class ExploreController extends GetxController { title: 'conferenceAssistant'.tr, // 商务会议助手 description: 'conferenceAssistantInfo'.tr, // 实时记录会议纪要,多语言翻译 icon: Icons.people, - startColor: const Color(0xFF3F51B5), - endColor: const Color(0xFF673AB7), - iconColor: const Color(0xFF3F51B5), + startColor: const Color(0xFFab47bc), + endColor: const Color(0xFFec407a), + iconColor: const Color(0xFFd81b60), targetRoute: Routes.meeting, ), FunctionScene( diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 0c63e4138..be4cef42d 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -226,7 +226,6 @@ class HomeView extends GetView { // 设备信息 SizedBox(height: 12.h), Obx(() { - // 如果连接失败且有错误信息,显示错误提示 if (controller.connectionStatus == ConnectionStatus.failed && controller.connectionErrorMsg.isNotEmpty) { @@ -861,23 +860,26 @@ class HomeView extends GetView { SizedBox(height: 5.h), // 减少间距 - // 右下卡片 - AI女友 + // 右下卡片 - + // 右下卡片 - 会议助手 _buildFeatureCard( - title: 'aiCompanion'.tr, // 亲子陪伴 - description: 'aiCompanionInfo'.tr, // 懂你心意,温暖陪伴 - icon: Icons.favorite, + title: 'conferenceAssistant'.tr, // 商务会议助手 + description: 'conferenceAssistantInfo'.tr, // 实时记录会议纪要,多语言翻译 + icon: Icons.people, gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - Color(0xFFE91E63).withOpacity(0.3), - Color(0xFFFF5252).withOpacity(0.3), + Color(0xFFab47bc).withOpacity(0.3), // 薰衣草紫 + Color(0xFFec407a).withOpacity(0.3), // 品红 ], ), - onTap: () {}, + onTap: () { + Get.toNamed(Routes.meeting); + }, height: 85.h, // 增加高度 isDarkMode: isDarkMode, - iconColor: const Color(0xFFE91E63), + iconColor: const Color(0xFFd81b60), // 品红 ), ], ), diff --git a/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart b/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart index 6a41a2b8b..dbbc4fa1a 100644 --- a/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart +++ b/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart @@ -40,7 +40,7 @@ class OtaUpgradeController extends GetxController { final isUpdateAvailable = true.obs; // 当前升级进度,范围为 0.0 到 1.0 - final RxDouble progress = 0.0.obs; + // final RxDouble progress = 0.0.obs; // OTA 单例实例,用于处理升级相关逻辑 final jlota = Ota.instance; @@ -52,7 +52,12 @@ class OtaUpgradeController extends GetxController { final _bleManager = Get.find(); final maxRetryAttempts = 3; // 最大重试次数 int currentRetryCount = 0; + final RxBool isFetchingDeviceInfo = true.obs; + + final RxDouble progress = 0.0.obs; // 控制器自己的可观察属性 + final RxInt type = OtaEvent.NOT_STARTED.obs; + final RxBool isUpgrade = false.obs; @override void onInit() { super.onInit(); @@ -62,6 +67,7 @@ class OtaUpgradeController extends GetxController { @override void onClose() { jlota.onOtaClose(); + isUpgrade.value = false; super.onClose(); } @@ -109,6 +115,7 @@ class OtaUpgradeController extends GetxController { } Future _getCurrentVersion() async { + _listenToEvents(); try { isFetchingDeviceInfo.value = true; errorMessage.value = ''; @@ -142,8 +149,20 @@ class OtaUpgradeController extends GetxController { deviceInfo.firmwareProductId.isEmpty) { throw "获取设备信息失败,请检查连接"; } + // 拆分 firmwareVersion 并判断两边是否一致 + final firmwareParts = deviceInfo.firmwareVersion.split('.'); + + // 从第三个 `.` 开始分割 + final firstPart = firmwareParts.sublist(0, 3).join('.'); + final secondPart = firmwareParts.sublist(3).join('.'); + print("firstPart${firstPart},secondPart=${secondPart}"); +// 判断两部分是否一致 + if (firstPart != secondPart) { + throw "固件版本格式错误:${deviceInfo.firmwareVersion}"; + } + // 正常流程 - currentVersion.value = deviceInfo.firmwareVersion; + currentVersion.value = firstPart; print('getequipmentinfo,id: ${deviceInfo.firmwareProductId}'); // API请求 final response = await Api.getequipmentinfo({ @@ -256,7 +275,7 @@ class OtaUpgradeController extends GetxController { headers: {'Accept-Encoding': 'gzip'}, )); - jlota.type.value = DOWNLOADING_UP_FILES; + type.value = DOWNLOADING_UP_FILES; try { final Directory? downloadsDir = await getDownloadsDirectory(); if (downloadsDir == null) { @@ -352,4 +371,45 @@ class OtaUpgradeController extends GetxController { return; // isUpgrade.value = false; } + + // 停止升级 + void stopUpgrading() async { + jlota.onOtaClose(); + return; + // isUpgrade.value = false; + } + + void _listenToEvents() { + Ota.instance.eventStream.listen((event) { + if (event.event == 'onProgress' && + event.type != null && + event.progress != null) { + progress.value = event.progress!; + type.value = event.type!; + isUpgrade.value = true; // 开始升级 + } + if (event.event == 'onStartOTA') { + type.value = OtaEvent.START_UPGRADING; + isUpgrade.value = true; // 开始升级 + } + if (event.event == 'onStopOTA') { + type.value = OtaEvent.UPGRADE_SUCCESS; + isUpgrade.value = false; + Get.snackbar('升级成功', '请退出重新连接设备'); + _getCurrentVersion(); + } + if (event.event == 'onError') { + type.value = OtaEvent.UPGRADE_FAILED; + isUpgrade.value = false; + } + if (event.event == 'NOCONNECTION') { + type.value = OtaEvent.CONNECTION_DISCONNECTION; + isUpgrade.value = false; + } + if (event.event == 'CONNECTION_SUCCESS') { + type.value = OtaEvent.CONNECTION_SUCCESS; + isUpgrade.value = true; // 开始升级 + } + }); + } } diff --git a/lib/modules/ota_upgrade/views/ota_upgrade_view.dart b/lib/modules/ota_upgrade/views/ota_upgrade_view.dart index fe85296fc..1bb1629bc 100644 --- a/lib/modules/ota_upgrade/views/ota_upgrade_view.dart +++ b/lib/modules/ota_upgrade/views/ota_upgrade_view.dart @@ -10,70 +10,125 @@ class OtaUpgradeView extends GetView { Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.grey[100], - appBar: AppBar( - automaticallyImplyLeading: false, - centerTitle: true, - elevation: 0, - backgroundColor: isDarkMode ? Colors.black : Colors.white, - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios, - size: 20.sp, - color: isDarkMode ? Colors.white : Colors.black, + return WillPopScope( + onWillPop: () async { + if (controller.isUpgrade.value) { + // 显示确认弹窗 + await Get.dialog( + AlertDialog( + title: Text('提示'), + content: Text('当前正在升级,退出将中断升级进程,确定要退出吗?'), + actions: [ + TextButton( + onPressed: () => Get.back(), // 关闭弹窗 + child: Text('取消'), + ), + TextButton( + onPressed: () { + controller.stopUpgrading(); + Get.back(); // 关闭弹窗 + Get.back(); // 返回上一页 + }, + child: Text('确定'), + ), + ], + ), + ); + return false; // 阻止默认返回 + } + return true; // 允许返回 + }, + child: Scaffold( + backgroundColor: isDarkMode ? Colors.black : Colors.grey[100], + appBar: AppBar( + automaticallyImplyLeading: false, + centerTitle: true, + elevation: 0, + backgroundColor: isDarkMode ? Colors.black : Colors.white, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios, + size: 20.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), + onPressed: () { + if (controller.isUpgrade.value) { + // 显示确认弹窗 + Get.dialog( + AlertDialog( + title: Text('提示'), + content: Text('当前正在升级,退出将中断升级进程,确定要退出吗?'), + actions: [ + TextButton( + onPressed: () => Get.back(), // 关闭弹窗 + child: Text('取消'), + ), + TextButton( + onPressed: () { + controller.stopUpgrading(); + Get.back(); // 关闭弹窗 + Get.back(); // 返回上一页 + }, + child: Text('确定'), + ), + ], + ), + ); + } else { + Get.back(); // 正常返回 + } + }, ), - onPressed: () => Get.back(), - ), - title: Text( - '固件升级'.tr, - style: TextStyle( - fontSize: 20.sp, - color: isDarkMode ? Colors.white : Colors.black, + title: Text( + '固件升级'.tr, + style: TextStyle( + fontSize: 20.sp, + color: isDarkMode ? Colors.white : Colors.black, + ), ), ), - ), - body: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 设备信息卡片 - Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - color: isDarkMode ? Colors.grey[900] : Colors.white, - child: Padding( - padding: EdgeInsets.all(20), - child: Column( - children: [ - _buildDeviceIcon(isDarkMode), - SizedBox(height: 20), - _buildVersionInfo(isDarkMode), - ], + body: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 设备信息卡片 + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: isDarkMode ? Colors.grey[900] : Colors.white, + child: Padding( + padding: EdgeInsets.all(20), + child: Column( + children: [ + _buildDeviceIcon(isDarkMode), + SizedBox(height: 20), + _buildVersionInfo(isDarkMode), + ], + ), ), ), - ), - SizedBox(height: 20), + SizedBox(height: 20), - // 更新说明 - _buildUpdateNotes(isDarkMode), - SizedBox(height: 20), + // 更新说明 + _buildUpdateNotes(isDarkMode), + SizedBox(height: 20), - // 状态显示区域 - Obx(() { - if (controller.isFetchingDeviceInfo.value) { - return _buildLoadingIndicator(isDarkMode); - } - if (controller.errorMessage.value.isNotEmpty) { - return _buildErrorInfo(isDarkMode); - } - return _buildUpdateButton(isDarkMode); - }), - ], + // 状态显示区域 + Obx(() { + if (controller.isFetchingDeviceInfo.value) { + return _buildLoadingIndicator(isDarkMode); + } + if (controller.errorMessage.value.isNotEmpty) { + return _buildErrorInfo(isDarkMode); + } + return _buildUpdateButton(isDarkMode); + }), + ], + ), ), ), ), @@ -227,7 +282,7 @@ class OtaUpgradeView extends GetView { ); Widget _buildUpdateButton(bool isDarkMode) { - return controller.jlota.isUpgrade.value + return controller.isUpgrade.value ? _buildProgressIndicator(isDarkMode) : Container( width: double.infinity, @@ -276,8 +331,8 @@ class OtaUpgradeView extends GetView { Widget _buildProgressIndicator(bool isDarkMode) { return Obx(() { - final progress = controller.jlota.progress.value; - final type = controller.jlota.type.value; + final progress = controller.progress.value; + final type = controller.type.value; // // 处理升级失败状态:弹出 snackbar 并显示错误提示 // if (type == OtaUpgradeController.UPGRADE_FAILED || diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index eaccfe86a..4f32dfd4f 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -7,6 +7,7 @@ import '../../../core/utils/logger.dart'; import 'dart:io'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/ble_manager.dart'; +import '../../../core/utils/permission_util.dart'; /// 配对状态枚举 enum PairingState { @@ -178,13 +179,24 @@ class PairingController extends GetxController { Logger.i(_tag, '开始执行初始化流程'); isLoading.value = true; - statusDescription.value = '正在检查权限...'; + statusDescription.value = '正在初始化...'; try { + // 初始视觉反馈延迟 + await Future.delayed(const Duration(milliseconds: 800)); + + // 更新状态让用户知道正在进行权限检查 + statusDescription.value = '正在检查所需权限...'; + // iOS平台跳过权限检查 if (!Platform.isIOS) { + // 添加额外视觉反馈延迟 + await Future.delayed(const Duration(milliseconds: 600)); + final hasPermission = await _checkAndRequestPermissions(); if (!hasPermission) { + statusDescription.value = '缺少必要的权限'; + await Future.delayed(const Duration(milliseconds: 500)); _transition(PairingState.exitNoPermission); return; } @@ -193,6 +205,10 @@ class PairingController extends GetxController { // 设置蓝牙状态监听 _setupBluetoothListener(); + // 更新状态让用户知道正在进行蓝牙检查 + statusDescription.value = '检查蓝牙状态...'; + await Future.delayed(const Duration(milliseconds: 400)); + // 检查蓝牙状态 final btState = BluetoothManager.to.bluetoothState; Logger.i(_tag, '当前蓝牙状态: $btState'); @@ -200,10 +216,14 @@ class PairingController extends GetxController { if (btState == 'on' || btState == 'unknown') { _transition(PairingState.btReady); } else { + statusDescription.value = '蓝牙未开启'; + await Future.delayed(const Duration(milliseconds: 500)); _transition(PairingState.exitBtOff); } } catch (e) { Logger.e(_tag, 'Init failed: $e'); + statusDescription.value = '初始化出错'; + await Future.delayed(const Duration(milliseconds: 500)); _transition(PairingState.exitNoPermission); } finally { isLoading.value = false; @@ -359,7 +379,7 @@ class PairingController extends GetxController { if (success) { Logger.i(_tag, 'BLE配对启动成功,立即注册后台扫描'); - + // 配对启动成功后立即注册后台扫描 try { // Android平台注册后台扫描 @@ -372,7 +392,7 @@ class PairingController extends GetxController { Logger.e(_tag, '注册后台扫描失败: $e'); // 注册失败不影响配对流程继续 } - + Logger.i(_tag, '等待BLE连接完成...'); // 配对结果将通过BLE状态监听回调处理 } else { @@ -508,23 +528,75 @@ class PairingController extends GetxController { return true; } - // Android权限检查 - final permissions = []; - permissions.add(Permission.locationWhenInUse); + // Android权限检查 - 使用 PermissionUtil + final context = Get.context; + if (context == null) { + Logger.e(_tag, '无法获取有效上下文,跳过权限请求'); + return false; + } + + // 根据 Android 版本确定需要的权限 + List requiredPermissions = [ + Permission.locationWhenInUse, + ]; if (Platform.isAndroid && await _getAndroidSdkVersion() >= 31) { - permissions.add(Permission.bluetoothScan); - permissions.add(Permission.bluetoothConnect); + requiredPermissions.add(Permission.bluetoothScan); + requiredPermissions.add(Permission.bluetoothConnect); + } + + // 逐个请求权限 + for (var permission in requiredPermissions) { + final permissionName = _getPermissionName(permission); + Logger.d(_tag, '正在请求 $permissionName 权限'); + + final granted = await PermissionUtil.instance.requestPermission( + context: context, + permissionType: permission, + permissionName: permissionName, + explanationText: _getExplanationText(permission), + permanentDenialText: '您已永久拒绝$permissionName权限,请前往应用设置开启', + settingsButtonText: '去设置', + cancelButtonText: '取消', + ); + + if (!granted) { + Logger.w(_tag, '$permissionName 权限未授予'); + return false; + } } - final statuses = await permissions.request(); - return statuses.values.every((status) => status.isGranted); + return true; } catch (e) { Logger.e(_tag, '权限检查失败: $e'); return false; } } + // 获取权限的用户友好名称 + String _getPermissionName(Permission permission) { + if (permission == Permission.location) return '定位'; + if (permission == Permission.locationWhenInUse) return '定位'; + if (permission == Permission.bluetoothScan) return '蓝牙扫描'; + if (permission == Permission.bluetoothConnect) return '蓝牙连接'; + return '未知权限'; + } + + // 获取权限说明文本 + String _getExplanationText(Permission permission) { + if (permission == Permission.location || + permission == Permission.locationWhenInUse) { + return '用于发现和连接蓝牙设备。我们不会在应用后台使用定位功能。'; + } + + if (permission == Permission.bluetoothScan || + permission == Permission.bluetoothConnect) { + return '用于扫描和连接蓝牙设备,这是与耳机通信所必需的权限。'; + } + + return '此权限是应用正常运行所必需的。'; + } + /// 设置蓝牙状态监听 void _setupBluetoothListener() { _bluetoothStateSubscription?.cancel(); diff --git a/lib/modules/settings/views/permissions_view.dart b/lib/modules/settings/views/permissions_view.dart index 55c01331b..ccc94bfb1 100644 --- a/lib/modules/settings/views/permissions_view.dart +++ b/lib/modules/settings/views/permissions_view.dart @@ -4,6 +4,7 @@ import 'package:permission_handler/permission_handler.dart'; import '../controllers/settings_controller.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'dart:ui'; +import 'package:deep_voice/core/utils/permission_util.dart'; // 引入PermissionUtil class PermissionsView extends GetView { const PermissionsView({Key? key}) : super(key: key); @@ -11,7 +12,7 @@ class PermissionsView extends GetView { @override Widget build(BuildContext context) { final isDarkMode = Theme.of(context).brightness == Brightness.dark; - + return Scaffold( backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], appBar: AppBar( @@ -41,45 +42,53 @@ class PermissionsView extends GetView { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } - + if (snapshot.hasError) { return Center(child: Text('获取权限状态失败: ${snapshot.error}'.tr)); } - + final permissionStatuses = snapshot.data ?? {}; - + return ListView( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), children: [ // 通讯权限组 - _buildPermissionSection('通讯权限', Icons.phone_android, [ - Permission.phone, - Permission.sms - ], permissionStatuses, isDarkMode), - + _buildPermissionSection( + '通讯权限', + Icons.phone_android, + [Permission.phone, Permission.sms], + permissionStatuses, + isDarkMode), + SizedBox(height: 20.h), - + // 日历权限组 - _buildPermissionSection('日历权限', Icons.calendar_today, [ - Permission.calendarFullAccess - ], permissionStatuses, isDarkMode), - + _buildPermissionSection( + '日历权限', + Icons.calendar_today, + [Permission.calendarFullAccess], + permissionStatuses, + isDarkMode), + SizedBox(height: 20.h), - + // 位置权限组 - _buildPermissionSection('位置权限', Icons.location_on, [ - Permission.location - ], permissionStatuses, isDarkMode), + _buildPermissionSection('位置权限', Icons.location_on, + [Permission.location], permissionStatuses, isDarkMode), ], ); }, ), ); } - + // 构建权限组区域 - Widget _buildPermissionSection(String title, IconData icon, List permissions, - Map statuses, bool isDarkMode) { + Widget _buildPermissionSection( + String title, + IconData icon, + List permissions, + Map statuses, + bool isDarkMode) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -96,7 +105,7 @@ class PermissionsView extends GetView { ), ), ), - + // 权限项容器 Container( decoration: BoxDecoration( @@ -119,7 +128,8 @@ class PermissionsView extends GetView { ? ImageFilter.blur(sigmaX: 5, sigmaY: 5) : ImageFilter.blur(sigmaX: 0, sigmaY: 0), child: Column( - children: _buildPermissionItems(permissions, statuses, isDarkMode), + children: + _buildPermissionItems(permissions, statuses, isDarkMode), ), ), ), @@ -127,52 +137,52 @@ class PermissionsView extends GetView { ], ); } - + // 构建权限项列表 - List _buildPermissionItems(List permissions, + List _buildPermissionItems(List permissions, Map statuses, bool isDarkMode) { final List items = []; - - // 获取描述文本 - String getDescription(Permission permission) { + + // 获取名称 + String getName(Permission permission) { switch (permission) { case Permission.phone: - return '用于拨打电话功能'; + return '电话'; case Permission.sms: - return '用于发送短信功能'; + return '短信'; case Permission.calendarWriteOnly: - return '用于创建日历事件和提醒'; + return '日历写入'; case Permission.calendarFullAccess: - return '用于读取和管理日历事件及提醒'; + return '日历完整访问'; case Permission.location: - return '用于获取当前位置信息'; + return '位置信息'; default: - return '应用功能需要'; + return permission.toString(); } } - - // 获取名称 - String getName(Permission permission) { + + // 获取描述文本 + String getDescription(Permission permission) { switch (permission) { case Permission.phone: - return '电话'; + return '用于拨打电话功能'; case Permission.sms: - return '短信'; + return '用于发送短信功能'; case Permission.calendarWriteOnly: - return '日历写入'; + return '用于创建日历事件和提醒'; case Permission.calendarFullAccess: - return '日历完整访问'; + return '用于读取和管理日历事件及提醒'; case Permission.location: - return '位置信息'; + return '用于获取当前位置信息'; default: - return permission.toString(); + return '应用功能需要'; } } - + for (int i = 0; i < permissions.length; i++) { final permission = permissions[i]; final status = statuses[permission] ?? PermissionStatus.denied; - + items.add( _buildPermissionItem( name: getName(permission), @@ -182,7 +192,7 @@ class PermissionsView extends GetView { isDarkMode: isDarkMode, ), ); - + // 添加分隔线,最后一个不加 if (i < permissions.length - 1) { items.add(Divider( @@ -191,10 +201,10 @@ class PermissionsView extends GetView { )); } } - + return items; } - + // 构建单个权限项 Widget _buildPermissionItem({ required String name, @@ -248,12 +258,12 @@ class PermissionsView extends GetView { ), ); } - + // 状态文本 Widget _buildStatusText(PermissionStatus status, bool isDarkMode) { String text; Color color; - + if (status.isGranted) { text = '已授权'.tr; color = Colors.green; @@ -262,7 +272,7 @@ class PermissionsView extends GetView { color = Colors.red; } else if (status.isPermanentlyDenied) { text = '需要在系统设置中开启'.tr; - color = Colors.grey; + color = Colors.orange; } else if (status.isLimited) { text = '部分授权'.tr; color = Colors.orange; @@ -270,7 +280,7 @@ class PermissionsView extends GetView { text = '未知状态'.tr; color = Colors.grey; } - + return Text( text, style: TextStyle( @@ -280,45 +290,84 @@ class PermissionsView extends GetView { ), ); } - + // 处理权限请求 - Future _handlePermissionAction(Permission permission, PermissionStatus status) async { - if (status.isPermanentlyDenied) { - // 打开应用设置 - controller.openSettings(); - return; - } - - if (!status.isGranted) { - // 请求权限 - final result = await controller.requestPermission(permission); - - if (result.isGranted) { - Get.snackbar('成功'.tr, '权限已授予'.tr, snackPosition: SnackPosition.BOTTOM); - } else if (result.isPermanentlyDenied) { - Get.dialog( - AlertDialog( - title: Text('需要权限'.tr), - content: Text('此权限需要在设置中手动开启'.tr), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text('取消'.tr), - ), - TextButton( - onPressed: () { - Get.back(); - controller.openSettings(); - }, - child: Text('去设置'.tr), - ), - ], - ), + Future _handlePermissionAction( + Permission permission, PermissionStatus status) async { + try { + final context = Get.context; + if (context == null) return; + + final permissionName = _getPermissionName(permission); + final explanationText = _getExplanationText(permission); + + // 使用 PermissionUtil 请求权限 + final hasPermission = await PermissionUtil.instance.requestPermission( + context: context, + permissionType: permission, + permissionName: permissionName, + explanationText: explanationText, + permanentDenialText: '您已永久拒绝$permissionName权限,请前往应用设置开启', + settingsButtonText: '去设置', + cancelButtonText: '取消', + ); + + if (hasPermission) { + Get.snackbar( + '成功'.tr, + '$permissionName权限已授予'.tr, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, ); } - - // 刷新界面 + + // 刷新界面,重新获取权限状态 Get.forceAppUpdate(); + } catch (e) { + Get.snackbar( + '错误'.tr, + '请求权限时出错: $e'.tr, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + // 获取权限的用户友好名称 + String _getPermissionName(Permission permission) { + switch (permission) { + case Permission.phone: + return '电话'; + case Permission.sms: + return '短信'; + case Permission.calendarWriteOnly: + return '日历写入'; + case Permission.calendarFullAccess: + return '日历完整访问'; + case Permission.location: + return '位置信息'; + default: + return permission.toString(); + } + } + + // 获取权限说明文本 + String _getExplanationText(Permission permission) { + switch (permission) { + case Permission.phone: + return '拨打电话功能需要电话权限'; + case Permission.sms: + return '发送短信功能需要短信权限'; + case Permission.calendarWriteOnly: + return '创建日历事件需要日历写入权限'; + case Permission.calendarFullAccess: + return '管理日历事件需要完整日历权限'; + case Permission.location: + return '获取位置信息需要位置权限'; + default: + return '应用需要此权限以提供完整功能'; } } -} \ No newline at end of file +} diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 0196b44ff..52503548f 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -33,7 +33,7 @@ class TranslationController extends GetxController { final isRecognizing = false.obs; final isTranslating = false.obs; final isTtsEnabled = true.obs; - + final isRecording = false.obs; // 语言相关 final sourceLanguage = '中文(简体)'.obs; final targetLanguage = '英语'.obs; @@ -193,6 +193,16 @@ class TranslationController extends GetxController { // 切换TTS功能 void toggleTts() => isTtsEnabled.toggle(); + // 切换录音功能 + Future toggleisRecord() async { + isRecording.toggle(); + if (isRecording.value) { + await _asrService.enableRecord(); + } else { + await _asrService.stopRecord(); + } + } + // 开始语音识别 Future startRecognition() async { Logger.info('开始语音识别'); @@ -233,15 +243,18 @@ class TranslationController extends GetxController { _bleManager.openDecoder(); // 发送ble音乐或者通话远端声音 _audioSourceType = true; + isTtsEnabled.value = false; // 禁用TTS以避免干扰 Logger.info('发送ble音乐或者通话远端声音'); } else if (currentMode.value == "call") { // 发送ble系统mic和dac(音乐或者通话远端)声音 _bleManager.openA2DPDecoder(); _audioSourceType = true; + isTtsEnabled.value = true; Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); } else { // 开始连续语音识别 _audioSourceType = false; + isTtsEnabled.value = true; } final recognitionStream = await _asrService.startContinuousRecognition(_audioSourceType); @@ -476,6 +489,11 @@ class TranslationController extends GetxController { // 停止所有操作 Future stopAll() async { try { + if (currentMode.value == "audioVideo" || currentMode.value == "call") { + _bleManager.closeCodec(); + // 发送ble音乐或者通话远端声音 + Logger.info('发送ble音乐或者通话远端声音'); + } if (isRecognizing.value) { await stopRecognition(); } diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 257e32a63..85cb3bbd6 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -87,13 +87,13 @@ class TranslationView extends GetView { // TTS toggle button Obx(() => IconButton( icon: Icon( - controller.isTtsEnabled.value ? Icons.mic : Icons.mic_off, + controller.isRecording.value ? Icons.mic : Icons.mic_off, color: isDarkMode ? Colors.white : Colors.black, size: 24.sp, ), // onPressed: controller.toggleTts,默认播放 - onPressed: controller.toggleTts, - tooltip: '自动播放翻译结果', + onPressed: controller.toggleisRecord, + tooltip: '是否开启录音', )), // 历史记录按钮 diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index 714d3b7d3..36e34df05 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -1,8 +1,6 @@ package com.yunqiinnovation.agent_service import android.content.Context -import com.yunqiinnovation.agent_service.AgentService -import com.yunqiinnovation.agent_service.AgentServiceListener import android.util.Log import com.yunqiinnovation.ble_service.BleService import com.yunqiinnovation.ble_service.BleConst diff --git a/local_plugins/azure_speech/android/build.gradle.kts b/local_plugins/azure_speech/android/build.gradle.kts index 8db97c1d7..4d6a58288 100644 --- a/local_plugins/azure_speech/android/build.gradle.kts +++ b/local_plugins/azure_speech/android/build.gradle.kts @@ -54,5 +54,6 @@ dependencies { // 添加Microsoft语音SDK implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.43.0") implementation(project(":speech")) + add("compileOnly", project(":ble_service")) } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 52a86ce10..a5db177bc 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -28,51 +28,56 @@ import java.util.concurrent.TimeUnit import java.io.File import java.io.FileOutputStream import java.io.RandomAccessFile + /** * Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 - * + * * 针对回音消除的场景,使用拉流模式 (PullAudioInputStreamCallback) 实现,避免复杂的协程处理 * 通过检测扬声器和耳机状态,自动决定是否启用回音消除 */ class AzureAsrHelper(private val context: Context) { private val tag = "AzureAsrHelper" - + // 核心组件 private var speechConfig: SpeechConfig? = null private var recognizer: SpeechRecognizer? = null private var audioConfig: AudioConfig? = null - + // 状态管理 private var isContinuousRecognitionActive = false - + // 配置参数 private var currentLanguage = "zh-CN" private var supportedLanguages = arrayOf("zh-CN") private var isAutoDetectLanguage = false private var subscriptionKey = "" private var region = "" - + // 音频源配置 - private var audioSourceType = AudioSourceType.MICROPHONE - + var audioSourceType = AudioSourceType.MICROPHONE + // 音频处理 private var microphoneStream: MicrophoneStream? = null private var externalAudioStream: ExternalAudioPullStream? = null - + + // 录音文件处理 + private var recordfile: RecordingFile? = null + private var isRecording = false + /** * 音频来源类型 */ enum class AudioSourceType { /** 使用设备麦克风 */ MICROPHONE, - + /** 使用外部提供的音频数据 */ EXTERNAL } - + /** * 初始化Azure语音服务 - * + * * @param subscriptionKey Azure 订阅密钥 * @param region Azure 区域 * @param supportedLanguages 支持的语言数组 @@ -91,28 +96,28 @@ class AzureAsrHelper(private val context: Context) { Log.e(tag, "Azure 配置信息不完整") return false } - + // 释放之前的资源 dispose() - + // 保存配置 this.subscriptionKey = subscriptionKey this.region = region this.audioSourceType = audioSourceType - + // 设置语言 if (supportedLanguages.isNotEmpty()) { this.supportedLanguages = supportedLanguages } - + // 根据支持的语言数量决定是否启用自动语言检测 this.isAutoDetectLanguage = supportedLanguages.size >= 2 - + // 如果只有一种语言,设置为当前语言 if (!isAutoDetectLanguage && supportedLanguages.isNotEmpty()) { this.currentLanguage = supportedLanguages[0] } - + // 创建语音配置 speechConfig = SpeechConfig.fromSubscription(subscriptionKey, region).apply { if (isAutoDetectLanguage) { @@ -135,7 +140,8 @@ class AzureAsrHelper(private val context: Context) { // 设置分段策略为时间模式 setProperty("Speech_SegmentationStrategy", "Time") } - + // 录音文件类 + recordfile = RecordingFile() // 创建识别器 val setupSuccess = setupRecognizer() if (setupSuccess) { @@ -148,7 +154,7 @@ class AzureAsrHelper(private val context: Context) { return false } } - + /** * 设置识别器 */ @@ -157,17 +163,18 @@ class AzureAsrHelper(private val context: Context) { // 清理旧的识别器 recognizer?.close() recognizer = null - + // 设置音频配置 when (audioSourceType) { AudioSourceType.MICROPHONE -> { // 判断是否使用手机扬声器输出和耳机连接状态 - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val audioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val isSpeakerphoneOn = audioManager.isSpeakerphoneOn - + // 检查是否连接了有线耳机 val isWiredHeadsetOn = audioManager.isWiredHeadsetOn - + // 检查是否连接了蓝牙设备(需要注意兼容性) val isBluetoothConnected = try { val method = AudioManager::class.java.getMethod("isBluetoothA2dpOn") @@ -175,12 +182,12 @@ class AzureAsrHelper(private val context: Context) { } catch (e: Exception) { false } - + val isHeadsetConnected = isWiredHeadsetOn || isBluetoothConnected - + // 如果使用手机扬声器输出且没有连接耳机,可能会产生回音,需启动回音消除功能 val needEchoCancellation = !isHeadsetConnected - + if (needEchoCancellation) { // 使用拉流方式进行回音消除 setupMicrophoneStream() @@ -188,20 +195,22 @@ class AzureAsrHelper(private val context: Context) { audioConfig = AudioConfig.fromDefaultMicrophoneInput() } } + AudioSourceType.EXTERNAL -> { // 改用拉流方式处理外部音频 setupExternalAudioStream() } } - + // 创建识别器 recognizer = if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + val autoDetectConfig = + AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) } else { SpeechRecognizer(speechConfig, audioConfig) } - + return true } catch (e: Exception) { Log.e(tag, "创建识别器失败: ${e.message}") @@ -209,7 +218,7 @@ class AzureAsrHelper(private val context: Context) { return false } } - + /** * 设置麦克风流 - 使用拉流方式 */ @@ -217,7 +226,7 @@ class AzureAsrHelper(private val context: Context) { try { // 创建麦克风流 microphoneStream = MicrophoneStream() - + // 创建音频配置 - 正确使用fromStreamInput方法,只传入回调 audioConfig = AudioConfig.fromStreamInput(microphoneStream) } catch (e: Exception) { @@ -263,7 +272,7 @@ class AzureAsrHelper(private val context: Context) { try { // 创建外部音频拉流对象 externalAudioStream = ExternalAudioPullStream() - + // 创建音频配置 audioConfig = AudioConfig.fromStreamInput(externalAudioStream) } catch (e: Exception) { @@ -271,56 +280,61 @@ class AzureAsrHelper(private val context: Context) { externalAudioStream = null } } - + /** * 向音频流写入音频数据 * 仅当音频源设置为EXTERNAL时有效 - * + * * @param data 音频数据字节数组 */ fun pushAudioData(data: ByteArray) { if (audioSourceType != AudioSourceType.EXTERNAL) { return } - + // 使用拉流模式,将数据推入队列 externalAudioStream?.pushAudio(data) + } - + /** * 执行一次性语音识别 - * + * * @param callback 识别结果回调 */ - fun recognizeOnce(callback: RecognizeCallback, audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE) { + fun recognizeOnce( + callback: RecognizeCallback, + audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE + ) { if (speechConfig == null) { callback.onError("语音服务未初始化") return } - this.audioSourceType = audioSourceType + this.audioSourceType = audioSourceType // 确保不在连续识别中 if (isContinuousRecognitionActive) { stopContinuousRecognition() } - + // 重置识别器 if (!setupRecognizer()) { callback.onError("重置识别器失败") return } - + try { // 启动音频处理 startAudioProcessing() - + // 执行同步识别 val result = recognizer?.recognizeOnceAsync()?.get() - + // 停止音频处理 stopAudioProcessing() - + if (result != null && result.reason == ResultReason.RecognizedSpeech) { - val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(result)?.language ?: supportedLanguages[0] + val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(result)?.language + ?: supportedLanguages[0] callback.onResult(result.text, detectedLanguage) } else { callback.onError("未能识别语音") @@ -330,19 +344,22 @@ class AzureAsrHelper(private val context: Context) { callback.onError("识别异常: ${e.message}") } } - + /** * 开始连续语音识别 - * + * * @param callback 连续识别结果回调 * @return 是否成功开始识别 */ - fun startContinuousRecognition(callback: ContinuousRecognizeCallback, audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE): Boolean { + fun startContinuousRecognition( + callback: ContinuousRecognizeCallback, + audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE + ): Boolean { if (speechConfig == null) { callback.onError("语音服务未初始化") return false } - + if (isContinuousRecognitionActive) { return true } @@ -352,18 +369,21 @@ class AzureAsrHelper(private val context: Context) { callback.onError("重置识别器失败") return false } - + try { // 设置各种事件监听 setupEventListeners(callback) - + // 启动音频处理 startAudioProcessing() - + // 开始连续识别 recognizer?.startContinuousRecognitionAsync() isContinuousRecognitionActive = true - + Log.e(tag, "开始连续语音识别 是否录音${isRecording}") + if (isRecording) { + enableRecord() + } return true } catch (e: Exception) { stopAudioProcessing() @@ -372,7 +392,7 @@ class AzureAsrHelper(private val context: Context) { return false } } - + /** * 设置事件监听器 */ @@ -434,7 +454,7 @@ class AzureAsrHelper(private val context: Context) { ) } - + /** * 停止连续语音识别 * @@ -445,11 +465,11 @@ class AzureAsrHelper(private val context: Context) { Log.e(tag, "语音服务未初始化") return false } - + if (!isContinuousRecognitionActive) { return true } - + try { if (recognizer == null) { Log.w(tag, "识别器为空,重置状态") @@ -459,23 +479,24 @@ class AzureAsrHelper(private val context: Context) { if (audioSourceType == AudioSourceType.EXTERNAL) { pushAudioData(ByteArray(0)) } - + // 直接停止连续识别(SDK内部已是异步操作) recognizer?.stopContinuousRecognitionAsync()?.get(1000, TimeUnit.MILLISECONDS) - + // 停止音频处理 stopAudioProcessing() - + // 停止录音 + recordfile!!.closeFile() // 会话结束事件会设置isContinuousRecognitionActive = false return true } catch (e: Exception) { // 强制重置状态 isContinuousRecognitionActive = false Log.e(tag, "停止连续识别失败: ${e.message}") - + // 停止音频处理 stopAudioProcessing() - + recordfile!!.closeFile() // 尝试强制关闭识别器 try { recognizer?.close() @@ -483,16 +504,16 @@ class AzureAsrHelper(private val context: Context) { } catch (ex: Exception) { Log.e(tag, "关闭识别器失败: ${ex.message}") } - + return false } } - + /** * 检查连续识别是否活跃 */ fun isContinuousRecognitionActive(): Boolean = isContinuousRecognitionActive - + /** * 释放所有资源 */ @@ -504,22 +525,23 @@ class AzureAsrHelper(private val context: Context) { recognizer?.stopContinuousRecognitionAsync() isContinuousRecognitionActive = false } - + // 停止音频处理 stopAudioProcessing() - + // 停止录音 + recordfile!!.closeFile() // 释放recognizer recognizer?.close() recognizer = null - + // 释放speechConfig speechConfig?.close() speechConfig = null - + // 释放音频配置 audioConfig?.close() audioConfig = null - + // 确保状态被重置 isContinuousRecognitionActive = false microphoneStream = null @@ -537,9 +559,9 @@ class AzureAsrHelper(private val context: Context) { speechConfig = null } } - + // 音频处理相关方法 - + /** * 启动音频处理 */ @@ -549,12 +571,13 @@ class AzureAsrHelper(private val context: Context) { // 拉流模式不需要额外启动,SDK会自动拉取数据 // 无需执行任何操作 } + AudioSourceType.EXTERNAL -> { // 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData } } } - + /** * 停止音频处理 */ @@ -568,7 +591,7 @@ class AzureAsrHelper(private val context: Context) { e.printStackTrace() } } - + externalAudioStream?.let { try { it.close() @@ -579,7 +602,31 @@ class AzureAsrHelper(private val context: Context) { } } } - + + /** + * 开启录音 + */ + fun enableRecord() { + Log.i(tag, "开启录音:") + isRecording = true + recordfile!!.closeFile() + + + if (isContinuousRecognitionActive) { + recordfile!!.creatingFiles() + } + + } + + /** + * 停止录音 + */ + fun stopRecord() { + Log.i(tag, "停止连续录音:") + isRecording = false + recordfile!!.closeFile() + } + /** * 麦克风流 - 拉流模式 * 实现PullAudioInputStreamCallback,为Azure SDK提供音频数据 @@ -599,7 +646,7 @@ class AzureAsrHelper(private val context: Context) { init { initMicrophone() } - + /** * 初始化麦克风 */ @@ -612,7 +659,7 @@ class AzureAsrHelper(private val context: Context) { .setEncoding(audioFormat) .setChannelMask(channelConfig) .build() - + audioRecord = AudioRecord.Builder() .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) .setAudioFormat(format) @@ -627,18 +674,18 @@ class AzureAsrHelper(private val context: Context) { bufferSize ) } - + // 检查初始化状态 if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { throw IllegalStateException("AudioRecord初始化失败") } - + // 应用音频效果 applyAudioEffects() - + // 开始录音 audioRecord?.startRecording() - + // 检查录音状态 if (audioRecord?.recordingState != AudioRecord.RECORDSTATE_RECORDING) { throw IllegalStateException("录音启动失败") @@ -647,7 +694,7 @@ class AzureAsrHelper(private val context: Context) { e.printStackTrace() } } - + /** * 优化:条件性应用音频效果 */ @@ -664,12 +711,13 @@ class AzureAsrHelper(private val context: Context) { Log.d(tag, "跳过回音消除(使用耳机)") } } - + /** * 获取音频格式 */ - fun getFormat(): AudioStreamFormat = AudioStreamFormat.getWaveFormatPCM(16000.toLong(), 16.toShort(), 1.toShort()) - + fun getFormat(): AudioStreamFormat = + AudioStreamFormat.getWaveFormatPCM(16000.toLong(), 16.toShort(), 1.toShort()) + /** * Azure SDK 调用此方法获取音频数据 * 这是拉流模式的核心方法,由SDK调用以获取音频数据 @@ -697,7 +745,7 @@ class AzureAsrHelper(private val context: Context) { override fun close() { releaseAudioResources() } - + /** * 释放音频资源 */ @@ -707,11 +755,11 @@ class AzureAsrHelper(private val context: Context) { if (audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { audioRecord?.stop() } - + // 释放回音消除器 echoCanceler?.release() echoCanceler = null - + // 释放录音实例 audioRecord?.release() audioRecord = null @@ -720,7 +768,7 @@ class AzureAsrHelper(private val context: Context) { } } } - + /** * 外部音频拉流 * 实现PullAudioInputStreamCallback,将外部推送的音频数据转换为SDK可拉取的形式 @@ -728,17 +776,19 @@ class AzureAsrHelper(private val context: Context) { private inner class ExternalAudioPullStream : PullAudioInputStreamCallback() { private val queue: BlockingQueue = LinkedBlockingQueue() private var closed = false - + /** * 外部调用:推送音频数据到队列 * @param data 音频数据 */ fun pushAudio(data: ByteArray) { + // 将音频数据保存成wav格式的音频文件 + recordfile?.saveAudioDataToWav(data) if (!closed) { queue.offer(data) } } - + /** * 修复:外部音频流恢复阻塞读取 * @param buffer SDK提供的缓冲区 @@ -756,6 +806,7 @@ class AzureAsrHelper(private val context: Context) { val toCopy = minOf(chunk.size, buffer.size) System.arraycopy(chunk, 0, buffer, 0, toCopy) + Log.i(tag, "外部音频:") return toCopy } catch (e: InterruptedException) { Thread.currentThread().interrupt() @@ -765,7 +816,7 @@ class AzureAsrHelper(private val context: Context) { return 0 } } - + /** * SDK调用:关闭流 */ @@ -774,70 +825,177 @@ class AzureAsrHelper(private val context: Context) { queue.clear() } } - + /** * 一次性识别回调接口 */ interface RecognizeCallback { /** * 返回识别结果 - * + * * @param text 识别的文本 * @param detectedLanguage 检测到的语言 */ fun onResult(text: String, detectedLanguage: String) - + /** * 识别错误时调用 - * + * * @param error 错误信息 */ fun onError(error: String) } - + /** * 连续识别回调接口 */ interface ContinuousRecognizeCallback { /** * 返回识别结果 - * + * * @param text 识别的文本 * @param detectedLanguage 检测到的语言 */ fun onResult(text: String, detectedLanguage: String) - + /** * 识别进行中调用 - * + * * @param recognizing 正在识别的文本 * @param detectedLanguage 检测到的语言 */ fun onRecognizing(recognizing: String, detectedLanguage: String) - + /** * 会话开始时调用 */ fun onSessionStarted() - + /** * 会话结束时调用 */ fun onSessionStopped() - + /** * 识别取消时调用 - * + * * @param reason 取消原因 * @param errorDetails 错误详情 */ fun onCanceled(reason: String, errorDetails: String) - + /** * 识别出错时调用 - * + * * @param error 错误信息 */ fun onError(error: String) } + + + private inner class RecordingFile { + private var currentAudioFile: File? = null + private var fos: FileOutputStream? = null + + // 用于存储音频数据的缓冲区 + private val dataBuffer = mutableListOf() + private var totalBytesWritten = 0 + + // 音频配置 + private val sampleRate = 16000 + + + internal fun creatingFiles() { + if (fos != null || currentAudioFile != null) { + return + } + // 创建新的音频文件 + val timestamp = System.currentTimeMillis() + val filePath = + context.getExternalFilesDir(null)?.absolutePath + "/recorded_audio_$timestamp.wav" + currentAudioFile = File(filePath) + currentAudioFile?.createNewFile() + + // 仅写入初始文件头 + fos = FileOutputStream(currentAudioFile).apply { + write(generateWavHeader(0)) // 初始0长度 + close() + } + + // 追加音频数据到文件 + fos = FileOutputStream(currentAudioFile, true) + } + + /** + * 保存音频数据到 WAV 文件 + */ + internal fun saveAudioDataToWav(buffer: ByteArray) { + if (fos == null || currentAudioFile == null) { + return + } + // 复制数据到新数组 + val copy = ByteArray(buffer.size) + System.arraycopy(buffer, 0, copy, 0, buffer.size) + + // 添加到缓冲区 + dataBuffer.add(copy) + totalBytesWritten += buffer.size + + // 写入文件 + try { + fos?.write(copy, 0, buffer.size) + } catch (e: Exception) { + Log.e(tag, "写入音频数据失败: ${e.message}") + } + } + + // 更新文件头生成(修正RIFF长度计算) + private fun generateWavHeader(dataLength: Int): ByteArray { + val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据 + val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数 + + return byteArrayOf( + 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), + (totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(), + ((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(), + 'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(), + 'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), + 16, 0, 0, 0, // PCM头长度 + 1, 0, // PCM格式 + 1, 0, // 单声道 + (sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率 + (byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率 + 2, 0, // 块对齐 (通道数 * 样本位数/8) + 16, 0, // 样本位数 + 'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), + (dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(), + ((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte() + ) + } + + internal fun closeFile() { + try { + if (fos == null || currentAudioFile == null) { + return + } + // 关闭文件流 + fos?.close() + + // 更新WAV文件头 + currentAudioFile?.let { file -> + RandomAccessFile(file, "rw").use { raf -> + raf.seek(0) + raf.write(generateWavHeader(totalBytesWritten)) + } + Log.d(tag, "音频文件保存完成: ${file.absolutePath}") + } + + } catch (e: Exception) { + Log.e(tag, "更新WAV文件头失败: ${e.message}") + } finally { + fos = null + currentAudioFile = null + } + } + } } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index d4b83d07b..830a92b0b 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -15,384 +15,499 @@ import com.microsoft.cognitiveservices.speech.audio.* import com.deep_voice.speech.tts.TtsEvent import com.deep_voice.speech.tts.TtsEventListener import com.deep_voice.speech.tts.TtsEventType +import com.yunqiinnovation.ble_service.BleService /** AzureSpeechPlugin */ -class AzureSpeechPlugin: FlutterPlugin { - private val tag = "AzureSpeechPlugin" - private lateinit var context: Context - private val mainHandler = Handler(Looper.getMainLooper()) - - // ASR相关 - private lateinit var asrChannel : MethodChannel - private lateinit var asrEventChannel: EventChannel - private var asrEventSink: EventChannel.EventSink? = null - private lateinit var azureAsrHelper: AzureAsrHelper - - // TTS相关 - private lateinit var ttsChannel : MethodChannel - private lateinit var ttsEventChannel: EventChannel - private var ttsEventSink: EventChannel.EventSink? = null - private lateinit var azureTtsHelper: AzureTtsHelper - // 是否已添加TTS事件监听器 - private var isTtsListenerAdded = false - - // ASR 事件发送方法 - private fun sendAsrEvent(event: Map) { - if (asrEventSink == null) { - FileLogger.w(tag, "无法发送ASR事件:事件通道未准备好") - return - } +class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { + private val tag = "AzureSpeechPlugin" + private lateinit var context: Context + private val mainHandler = Handler(Looper.getMainLooper()) - mainHandler.post { - try { - asrEventSink?.success(event) - } catch (e: Exception) { - FileLogger.e(tag, "发送ASR事件失败: ${e.message}") - } - } - } - - // TTS 事件发送方法 - private fun sendTtsEvent(event: Map) { - if (ttsEventSink == null) { - FileLogger.w(tag, "无法发送TTS事件:事件通道未准备好") - return - } - - // 确保在主线程上发送事件 - mainHandler.post { - try { - ttsEventSink?.success(event) - } catch (e: Exception) { - FileLogger.e(tag, "发送TTS事件失败: ${e.message}") - } - } - } - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - - // 初始化ASR通道 - asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/asr") - asrChannel.setMethodCallHandler(AsrMethodHandler()) - - // 初始化TTS通道 - ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/tts") - ttsChannel.setMethodCallHandler(TtsMethodHandler()) - - // 初始化ASR事件通道 - asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/asr_events") - asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - asrEventSink = events - } - - override fun onCancel(arguments: Any?) { - asrEventSink = null - } - }) - - // 初始化TTS事件通道 - ttsEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/tts_events") - ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - ttsEventSink = events - setupTtsEventListener() // 在事件通道准备好时设置TTS事件监听器 - } - - override fun onCancel(arguments: Any?) { - ttsEventSink = null - } - }) - - // 初始化Azure语音服务 - azureTtsHelper = AzureTtsHelper(context) - azureAsrHelper = AzureAsrHelper(context) - } - - /** - * 设置TTS事件监听器 - */ - private fun setupTtsEventListener() { - if (!isTtsListenerAdded) { - // 添加TTS事件监听器 - azureTtsHelper.addListener(object : TtsEventListener { - override fun onEvent(event: TtsEvent) { - // 将TTS事件转换为Flutter可理解的格式 - val eventMap = when (event.type) { - TtsEventType.SYNTHESIS_STARTED -> mapOf( - "type" to "synthesis_started" - ) - TtsEventType.SYNTHESIS_COMPLETED -> mapOf( - "type" to "synthesis_completed" - ) - TtsEventType.SYNTHESIS_CANCELED -> { - val params = event.params - mapOf( - "type" to "synthesis_canceled", - "reason" to (params["reason"] ?: ""), - "errorDetails" to (params["errorDetails"] ?: "") - ) - } - TtsEventType.ERROR -> { - val params = event.params - mapOf( - "type" to "error", - "errorCode" to (params["errorCode"] ?: "UNKNOWN_ERROR"), - "errorMessage" to (params["errorMessage"] ?: "未知错误") - ) + // ASR相关 + private lateinit var asrChannel: MethodChannel + private lateinit var asrEventChannel: EventChannel + private var asrEventSink: EventChannel.EventSink? = null + private lateinit var azureAsrHelper: AzureAsrHelper + + // TTS相关 + private lateinit var ttsChannel: MethodChannel + private lateinit var ttsEventChannel: EventChannel + private var ttsEventSink: EventChannel.EventSink? = null + private lateinit var azureTtsHelper: AzureTtsHelper + + // 是否已添加TTS事件监听器 + private var isTtsListenerAdded = false + + // 是否已注册BLE回调 + private var isRegisteredToBle = false + + // ASR 事件发送方法 + private fun sendAsrEvent(event: Map) { + if (asrEventSink == null) { + FileLogger.w(tag, "无法发送ASR事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + asrEventSink?.success(event) + } catch (e: Exception) { + FileLogger.e(tag, "发送ASR事件失败: ${e.message}") } - else -> mapOf( - "type" to event.type.toString().lowercase(), - "params" to event.params - ) - } - - // 发送事件 - sendTtsEvent(eventMap) } - }) - - isTtsListenerAdded = true } - } - - // ASR方法处理器 - inner class AsrMethodHandler : MethodCallHandler { - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - "initialize" -> { - val subscriptionKey = call.argument("subscriptionKey") ?: "" - val region = call.argument("region") ?: "" - val supportedLanguages = call.argument>("supportedLanguages") ?: listOf("zh-CN") - val useExternalAudio = call.argument("useExternalAudio") ?: false - - try { - FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // - // 选择音频源类型 - val audioSourceType = if (useExternalAudio) { - AzureAsrHelper.AudioSourceType.EXTERNAL - } else { - AzureAsrHelper.AudioSourceType.MICROPHONE - } - - val success = azureAsrHelper.initialize( - subscriptionKey = subscriptionKey, - region = region, - supportedLanguages = supportedLanguages.toTypedArray(), - audioSourceType = audioSourceType, - ) - - result.success(success) - } catch (e: Exception) { - result.error("INITIALIZATION_ERROR", e.message, null) - } + + // TTS 事件发送方法 + private fun sendTtsEvent(event: Map) { + if (ttsEventSink == null) { + FileLogger.w(tag, "无法发送TTS事件:事件通道未准备好") + return } - "recognizeOnce" -> { - val useExternalAudio = call.argument("useExternalAudio") ?: false - try { - FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // - // 选择音频源类型 - val audioSourceType = if (useExternalAudio) { - AzureAsrHelper.AudioSourceType.EXTERNAL - } else { - AzureAsrHelper.AudioSourceType.MICROPHONE + + // 确保在主线程上发送事件 + mainHandler.post { + try { + ttsEventSink?.success(event) + } catch (e: Exception) { + FileLogger.e(tag, "发送TTS事件失败: ${e.message}") } - // 直接使用同步方式进行一次性识别 - azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) { - // 直接返回结果,不发送事件 - result.success(mapOf( - "text" to text, - "detectedLanguage" to detectedLanguage - )) - } - - override fun onError(error: String) { - result.error("RECOGNITION_ERROR", error, null) - } - },audioSourceType) - } catch (e: Exception) { - result.error("RECOGNITION_ERROR", "识别过程异常: ${e.message}", null) - } } - "startContinuousRecognition" -> { - val useExternalAudio = call.argument("audioSourceType") ?: false - // 确保事件通道已准备好 - if (asrEventSink == null) { - result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始连续识别", null) - return - } - - try { - FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // - // 选择音频源类型 - val audioSourceType = if (useExternalAudio) { - AzureAsrHelper.AudioSourceType.EXTERNAL + } + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + + // 初始化ASR通道 + asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/asr") + asrChannel.setMethodCallHandler(AsrMethodHandler()) + + // 初始化TTS通道 + ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "azure_speech/tts") + ttsChannel.setMethodCallHandler(TtsMethodHandler()) + + // 初始化ASR事件通道 + asrEventChannel = + EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/asr_events") + asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + asrEventSink = events + } + + override fun onCancel(arguments: Any?) { + asrEventSink = null + } + }) + + // 初始化TTS事件通道 + ttsEventChannel = + EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/tts_events") + ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ttsEventSink = events + setupTtsEventListener() // 在事件通道准备好时设置TTS事件监听器 + } + + override fun onCancel(arguments: Any?) { + ttsEventSink = null + } + }) + + // 初始化Azure语音服务 + azureTtsHelper = AzureTtsHelper(context) + azureAsrHelper = AzureAsrHelper(context) + + // 2. 初始化BleService并注册回调 + if (BleService.initialize(context)) { + + if (BleService.addCallback(this)) { + isRegisteredToBle = true + FileLogger.d(tag, "已注册BLE回调") } else { - AzureAsrHelper.AudioSourceType.MICROPHONE + FileLogger.w(tag, "BLE回调注册失败") } - - val success = azureAsrHelper.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { - override fun onResult(text: String, detectedLanguage: String) { - sendAsrEvent(mapOf( - "type" to "result", - "text" to text, - "detectedLanguage" to detectedLanguage - )) - } - - override fun onRecognizing(recognizing: String, detectedLanguage: String) { - sendAsrEvent(mapOf( - "type" to "recognizing", - "text" to recognizing, - "detectedLanguage" to detectedLanguage - )) - } - - override fun onSessionStarted() { - sendAsrEvent(mapOf("type" to "sessionStarted")) - } - - override fun onSessionStopped() { - sendAsrEvent(mapOf("type" to "sessionStopped")) - } - - override fun onCanceled(reason: String, errorDetails: String) { - sendAsrEvent(mapOf( - "type" to "canceled", - "reason" to reason, - "errorDetails" to errorDetails - )) - } - - override fun onError(error: String) { - sendAsrEvent(mapOf( - "type" to "error", - "message" to error - )) - } - },audioSourceType) - result.success(success) - } catch (e: Exception) { - result.error("START_RECOGNITION_ERROR", e.message, null) - } - } - "stopContinuousRecognition" -> { - try { - val success = azureAsrHelper.stopContinuousRecognition() - result.success(success) - } catch (e: Exception) { - result.error("STOP_ERROR", e.message, null) - } - } - "isContinuousRecognitionActive" -> { - result.success(azureAsrHelper.isContinuousRecognitionActive()) - } - "dispose" -> { - azureAsrHelper.dispose() - result.success(true) - } - "pushAudioData" -> { - val data = call.argument("data") ?: return result.error("INVALID_ARGUMENTS", "音频数据不能为空", null) - azureAsrHelper.pushAudioData(data) - result.success(true) + + } else { + FileLogger.e(tag, "BLE服务初始化失败") + } - else -> { - result.notImplemented() + } + + /** + * 设置TTS事件监听器 + */ + private fun setupTtsEventListener() { + if (!isTtsListenerAdded) { + // 添加TTS事件监听器 + azureTtsHelper.addListener(object : TtsEventListener { + override fun onEvent(event: TtsEvent) { + // 将TTS事件转换为Flutter可理解的格式 + val eventMap = when (event.type) { + TtsEventType.SYNTHESIS_STARTED -> mapOf( + "type" to "synthesis_started" + ) + + TtsEventType.SYNTHESIS_COMPLETED -> mapOf( + "type" to "synthesis_completed" + ) + + TtsEventType.SYNTHESIS_CANCELED -> { + val params = event.params + mapOf( + "type" to "synthesis_canceled", + "reason" to (params["reason"] ?: ""), + "errorDetails" to (params["errorDetails"] ?: "") + ) + } + + TtsEventType.ERROR -> { + val params = event.params + mapOf( + "type" to "error", + "errorCode" to (params["errorCode"] ?: "UNKNOWN_ERROR"), + "errorMessage" to (params["errorMessage"] ?: "未知错误") + ) + } + + else -> mapOf( + "type" to event.type.toString().lowercase(), + "params" to event.params + ) + } + + // 发送事件 + sendTtsEvent(eventMap) + } + }) + + isTtsListenerAdded = true } - } } - } - - // TTS方法处理器 - inner class TtsMethodHandler : MethodCallHandler { - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - "initialize" -> { - val subscriptionKey = call.argument("subscriptionKey") ?: "" - val region = call.argument("region") ?: "" - val useCustomAudioOutput = call.argument("useCustomAudioOutput") ?: false - - try { - // 初始化TTS引擎(使用新的接口方法) - val success = azureTtsHelper.initialize( - ttsAppId = "", // Azure TTS不需要appId - ttsAppToken = subscriptionKey, - ttsResource = region, - language = "zh-CN" - ) - - // 如果初始化成功且需要使用自定义音频输出,则设置自定义音频播放器 - if (success && useCustomAudioOutput) { - // 创建SimpleAudioPlayer实例 - val customPlayer = SimpleAudioPlayer(context) - - // 获取并设置音频输出流 - val outputStream = customPlayer.getAudioOutputStream() - if (outputStream != null) { - azureTtsHelper.setCustomAudioOutputStream(outputStream) - } + + // ASR方法处理器 + inner class AsrMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "initialize" -> { + val subscriptionKey = call.argument("subscriptionKey") ?: "" + val region = call.argument("region") ?: "" + val supportedLanguages = + call.argument>("supportedLanguages") ?: listOf("zh-CN") + val useExternalAudio = call.argument("useExternalAudio") ?: false + + try { + FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // + // 选择音频源类型 + val audioSourceType = if (useExternalAudio) { + AzureAsrHelper.AudioSourceType.EXTERNAL + } else { + AzureAsrHelper.AudioSourceType.MICROPHONE + } + + val success = azureAsrHelper.initialize( + subscriptionKey = subscriptionKey, + region = region, + supportedLanguages = supportedLanguages.toTypedArray(), + audioSourceType = audioSourceType, + ) + + result.success(success) + } catch (e: Exception) { + result.error("INITIALIZATION_ERROR", e.message, null) + } + } + + "recognizeOnce" -> { + val useExternalAudio = call.argument("useExternalAudio") ?: false + try { + FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // + // 选择音频源类型 + val audioSourceType = if (useExternalAudio) { + AzureAsrHelper.AudioSourceType.EXTERNAL + } else { + AzureAsrHelper.AudioSourceType.MICROPHONE + } + // 直接使用同步方式进行一次性识别 + azureAsrHelper.recognizeOnce(object : AzureAsrHelper.RecognizeCallback { + override fun onResult(text: String, detectedLanguage: String) { + // 直接返回结果,不发送事件 + result.success( + mapOf( + "text" to text, + "detectedLanguage" to detectedLanguage + ) + ) + } + + override fun onError(error: String) { + result.error("RECOGNITION_ERROR", error, null) + } + }, audioSourceType) + } catch (e: Exception) { + result.error("RECOGNITION_ERROR", "识别过程异常: ${e.message}", null) + } + } + + "startContinuousRecognition" -> { + val useExternalAudio = call.argument("audioSourceType") ?: false + // 确保事件通道已准备好 + if (asrEventSink == null) { + result.error( + "EVENT_CHANNEL_NOT_READY", + "事件通道未准备好,无法开始连续识别", + null + ) + return + } + + try { + FileLogger.d(tag, "选择音频源类型: ${useExternalAudio}") // + // 选择音频源类型 + val audioSourceType = if (useExternalAudio) { + AzureAsrHelper.AudioSourceType.EXTERNAL + } else { + AzureAsrHelper.AudioSourceType.MICROPHONE + } + + val success = azureAsrHelper.startContinuousRecognition(object : + AzureAsrHelper.ContinuousRecognizeCallback { + override fun onResult(text: String, detectedLanguage: String) { + sendAsrEvent( + mapOf( + "type" to "result", + "text" to text, + "detectedLanguage" to detectedLanguage + ) + ) + } + + override fun onRecognizing( + recognizing: String, + detectedLanguage: String + ) { + sendAsrEvent( + mapOf( + "type" to "recognizing", + "text" to recognizing, + "detectedLanguage" to detectedLanguage + ) + ) + } + + override fun onSessionStarted() { + sendAsrEvent(mapOf("type" to "sessionStarted")) + } + + override fun onSessionStopped() { + sendAsrEvent(mapOf("type" to "sessionStopped")) + } + + override fun onCanceled(reason: String, errorDetails: String) { + sendAsrEvent( + mapOf( + "type" to "canceled", + "reason" to reason, + "errorDetails" to errorDetails + ) + ) + } + + override fun onError(error: String) { + sendAsrEvent( + mapOf( + "type" to "error", + "message" to error + ) + ) + } + }, audioSourceType) + result.success(success) + } catch (e: Exception) { + result.error("START_RECOGNITION_ERROR", e.message, null) + } + } + + "stopContinuousRecognition" -> { + try { + val success = azureAsrHelper.stopContinuousRecognition() + result.success(success) + } catch (e: Exception) { + result.error("STOP_ERROR", e.message, null) + } + } + + "isContinuousRecognitionActive" -> { + result.success(azureAsrHelper.isContinuousRecognitionActive()) + } + + "dispose" -> { + azureAsrHelper.dispose() + result.success(true) + } + + "pushAudioData" -> { + val data = call.argument("data") ?: return result.error( + "INVALID_ARGUMENTS", + "音频数据不能为空", + null + ) + azureAsrHelper.pushAudioData(data) + result.success(true) + } + + "enableRecord" -> { + azureAsrHelper.enableRecord() + result.success(true) + } + + "stopRecord" -> { + azureAsrHelper.stopRecord() + result.success(true) + } + + else -> { + result.notImplemented() + } } - - // 确保设置了TTS事件监听器 - setupTtsEventListener() - - result.success(success) - } catch (e: Exception) { - result.error("INITIALIZATION_ERROR", "TTS初始化失败: ${e.message}", null) - } - } - "setVoice" -> { - val voiceName = call.argument("voiceName") ?: return result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) - result.success(azureTtsHelper.setVoice(voiceName)) - } - "speakText" -> { - val text = call.argument("text") ?: return result.error("INVALID_ARGUMENTS", "文本不能为空", null) - - // 直接调用speakOnce方法 - try { - val success = azureTtsHelper.speakOnce(text) - result.success(success) - } catch (e: Exception) { - result.error("SPEAK_ERROR", "语音合成失败: ${e.message}", null) - } - } - "stopSpeaking" -> { - // 直接调用stop方法 - try { - val success = azureTtsHelper.stop() - result.success(success) - } catch (e: Exception) { - result.error("STOP_ERROR", "停止语音合成失败: ${e.message}", null) - } } - "isSpeaking" -> { - result.success(azureTtsHelper.isSpeaking()) + } + + // TTS方法处理器 + inner class TtsMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "initialize" -> { + val subscriptionKey = call.argument("subscriptionKey") ?: "" + val region = call.argument("region") ?: "" + val useCustomAudioOutput = + call.argument("useCustomAudioOutput") ?: false + + try { + // 初始化TTS引擎(使用新的接口方法) + val success = azureTtsHelper.initialize( + ttsAppId = "", // Azure TTS不需要appId + ttsAppToken = subscriptionKey, + ttsResource = region, + language = "zh-CN" + ) + + // 如果初始化成功且需要使用自定义音频输出,则设置自定义音频播放器 + if (success && useCustomAudioOutput) { + // 创建SimpleAudioPlayer实例 + val customPlayer = SimpleAudioPlayer(context) + + // 获取并设置音频输出流 + val outputStream = customPlayer.getAudioOutputStream() + if (outputStream != null) { + azureTtsHelper.setCustomAudioOutputStream(outputStream) + } + } + + // 确保设置了TTS事件监听器 + setupTtsEventListener() + + result.success(success) + } catch (e: Exception) { + result.error("INITIALIZATION_ERROR", "TTS初始化失败: ${e.message}", null) + } + } + + "setVoice" -> { + val voiceName = call.argument("voiceName") + ?: return result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) + result.success(azureTtsHelper.setVoice(voiceName)) + } + + "speakText" -> { + val text = call.argument("text") ?: return result.error( + "INVALID_ARGUMENTS", + "文本不能为空", + null + ) + + // 直接调用speakOnce方法 + try { + val success = azureTtsHelper.speakOnce(text) + result.success(success) + } catch (e: Exception) { + result.error("SPEAK_ERROR", "语音合成失败: ${e.message}", null) + } + } + + "stopSpeaking" -> { + // 直接调用stop方法 + try { + val success = azureTtsHelper.stop() + result.success(success) + } catch (e: Exception) { + result.error("STOP_ERROR", "停止语音合成失败: ${e.message}", null) + } + } + + "isSpeaking" -> { + result.success(azureTtsHelper.isSpeaking()) + } + + "dispose" -> { + azureTtsHelper.release() + result.success(true) + } + + else -> { + result.notImplemented() + } + } } - "dispose" -> { - azureTtsHelper.release() - result.success(true) + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + asrChannel.setMethodCallHandler(null) + ttsChannel.setMethodCallHandler(null) + asrEventChannel.setStreamHandler(null) + ttsEventChannel.setStreamHandler(null) + + try { + if (isRegisteredToBle) { + if (BleService.removeCallback(this)) { + isRegisteredToBle = false + FileLogger.d(tag, "已取消BLE回调") + } else { + FileLogger.w(tag, "BLE回调取消失败") + } + } + // 释放资源 + azureTtsHelper.release() + azureAsrHelper.dispose() + + } catch (e: Exception) { + FileLogger.e(tag, "释放资源失败: ${e.message}") } - else -> { - result.notImplemented() + } + + + // BleService.Callback 接口实现 + override fun onScanResult(devices: List>) { + // 不处理扫描结果 + } + + override fun onConnectionStateChanged(state: Int) { + } + + override fun onAudioDataReceived(data: ByteArray) { + if (azureAsrHelper.audioSourceType == AzureAsrHelper.AudioSourceType.EXTERNAL) { + azureAsrHelper.pushAudioData(data) } - } + // 可选:处理音频数据 + } + + /** + * 处理唤醒信号 + * 在收到唤醒信号时启动语音识别 + */ + override fun onWakeupSignalReceived() { + } - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - asrChannel.setMethodCallHandler(null) - ttsChannel.setMethodCallHandler(null) - asrEventChannel.setStreamHandler(null) - ttsEventChannel.setStreamHandler(null) - - try { - // 释放资源 - azureTtsHelper.release() - azureAsrHelper.dispose() - } catch (e: Exception) { - FileLogger.e(tag, "释放资源失败: ${e.message}") + + override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { + // 不处理设备信息 } - } } \ No newline at end of file diff --git a/local_plugins/azure_speech/pubspec.yaml b/local_plugins/azure_speech/pubspec.yaml index 3f7c44fbf..b8a313fbe 100644 --- a/local_plugins/azure_speech/pubspec.yaml +++ b/local_plugins/azure_speech/pubspec.yaml @@ -10,11 +10,14 @@ environment: dependencies: flutter: sdk: flutter - + ble_service: + path: ../ble_service + dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index c000e1faa..84cfc4e05 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -1,38 +1,18 @@ package com.yunqiinnovation.ble_service -import android.Manifest -import android.app.PendingIntent import android.bluetooth.* import android.bluetooth.le.* import android.content.Context -import android.content.Intent import android.os.* import androidx.lifecycle.MutableLiveData import android.util.Log -import java.io.File -import java.io.FileOutputStream -import java.text.SimpleDateFormat import java.util.* -import android.os.ParcelUuid -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.IntentFilter -import android.os.SystemClock -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat -import java.util.concurrent.ConcurrentHashMap -import android.content.pm.ServiceInfo import java.util.concurrent.CopyOnWriteArrayList // Import Jieli Opus SDK classes import com.jieli.jl_audio_decode.opus.model.OpusOption import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback -import com.jieli.jl_audio_decode.callback.OnStateCallback import com.jieli.jl_audio_decode.opus.OpusManager import com.jieli.jl_audio_decode.exceptions.OpusException -import com.yunqiinnovation.ble_service.BleCompanionManager -import com.jieli.jl_bt_ota.model.BluetoothOTAConfigure; -import com.jieli.jl_bt_ota.constant.ErrorCode; -import com.jieli.jl_bt_ota.constant.StateCode; /** * BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能 @@ -1386,4 +1366,4 @@ object BleService { } } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock deleted file mode 100644 index 8ed41b458..000000000 Binary files a/local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock and /dev/null differ diff --git a/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties b/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties deleted file mode 100644 index c231039ee..000000000 --- a/local_plugins/opus/.gradle/buildOutputCleanup/cache.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Wed May 21 01:03:35 CST 2025 -gradle.version=8.9 diff --git a/local_plugins/ota/android/src/main/kotlin/com/example/ota/DeviceConnection.kt b/local_plugins/ota/android/src/main/kotlin/com/example/ota/DeviceConnection.kt deleted file mode 100644 index 0a9abdb19..000000000 --- a/local_plugins/ota/android/src/main/kotlin/com/example/ota/DeviceConnection.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.jieli.otasdk.model - -import android.bluetooth.BluetoothDevice - -/** - * @author zqjasonZhong - * @since 2022/9/9 - * @email zhongzhuocheng@zh-jieli.com - * @desc 设备连接状态 - */ -class DeviceConnection(val device: BluetoothDevice?, var state: Int) { - - override fun hashCode(): Int { - return device.hashCode() - } - - override fun equals(other: Any?): Boolean { - if (other !is DeviceConnection) return false - return device == other.device - } - - override fun toString(): String { - return "DeviceConnection(device=$device, state=$state)" - } - -} \ No newline at end of file diff --git a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt index f082e3dad..65bf3ba88 100644 --- a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt +++ b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt @@ -9,29 +9,18 @@ import io.flutter.plugin.common.MethodChannel.Result import android.bluetooth.BluetoothDevice import android.content.Context import com.jieli.otasdk.tool.ota.OTAManager -import com.jieli.jl_bt_ota.constant.ErrorCode import com.jieli.jl_bt_ota.constant.StateCode import com.jieli.jl_bt_ota.interfaces.BtEventCallback import com.jieli.jl_bt_ota.interfaces.IUpgradeCallback import com.jieli.jl_bt_ota.model.base.BaseError -import com.jieli.jl_bt_ota.model.response.TargetInfoResponse -import com.jieli.jl_bt_ota.util.BluetoothUtil -import com.jieli.jl_bt_ota.util.CHexConver -import com.jieli.jl_bt_ota.util.JL_Log import com.jieli.otasdk.tool.bluetooth.BluetoothHelper -import androidx.lifecycle.MutableLiveData -import com.jieli.otasdk.model.DeviceConnection import android.util.Log -import androidx.annotation.NonNull import com.jieli.otasdk.tool.config.ConfigHelper import com.yunqiinnovation.ble_service.BleService import com.yunqiinnovation.ble_service.BleConst import android.os.Handler import android.os.Looper -import java.io.File -import java.io.FileOutputStream -import java.io.RandomAccessFile import io.flutter.plugin.common.EventChannel class CustomUpdateCallback( @@ -130,7 +119,10 @@ class CustomUpdateCallback( Log.d(TAG, "升级失败: ${error?.message ?: "未知错误"}") if (streamSink != null) { if (Looper.myLooper() == Looper.getMainLooper()) { - Log.d(TAG, "升级失败:error?.message ${error?.message}未知错误 error?.code${error?.code}") + Log.d( + TAG, + "升级失败:error?.message ${error?.message}未知错误 error?.code${error?.code}" + ) streamSink.success( mapOf( "event" to "onError", @@ -282,8 +274,20 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { bluetoothHelper = BluetoothHelper(context) // 如果需要额外的初始化逻辑,请在这里添加 otaManager.registerBluetoothCallback(bluetoothCallback) - BleService.addCallback(this) - isRegisteredToBle = true + // 初始化BleService + // 2. 初始化BleService并注册回调 + if (BleService.initialize(context)) { + if (BleService.addCallback(this)) { + isRegisteredToBle = true + Log.d(TAG, "已注册BLE回调") + } else { + Log.w(TAG, "BLE回调注册失败") + } + } else { + Log.e(TAG, "BLE服务初始化失败") + + } + Log.i("TAG", "onAttachedToEngine") } @@ -309,6 +313,9 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { otaDevice = bluetoothGatt.device BleService.disconnect() + } else if (otaDevice != null) { + isDisconnect = true + BleService.disconnect() } else { Log.e("TAG", "BluetoothGatt 或 device 为 null,无法执行操作") sendEventOnMainThread( @@ -321,9 +328,11 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { } - } else if (call.method == "onOtaClose") { + } else if (call.method == "onOtaClose") { BleService.isEnterOta = false isDisconnect = false + otaManager.bluetoothHelper.stopScan() + otaManager.bluetoothHelper.disconnectDevice(otaDevice) } else { result.notImplemented() } @@ -345,6 +354,7 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { } + // BleService.Callback 接口实现 override fun onScanResult(devices: List>) { // 不处理扫描结果 } diff --git a/local_plugins/ota/android/src/main/kotlin/com/example/ota/tool/ota/ble/BleManager.java b/local_plugins/ota/android/src/main/kotlin/com/example/ota/tool/ota/ble/BleManager.java index 04f53d8e9..aa5708974 100644 --- a/local_plugins/ota/android/src/main/kotlin/com/example/ota/tool/ota/ble/BleManager.java +++ b/local_plugins/ota/android/src/main/kotlin/com/example/ota/tool/ota/ble/BleManager.java @@ -646,9 +646,10 @@ public class BleManager { } else { Log.e(TAG, "连接设备失败: ${device.name} (${device.address})"); } - } + mDiscoveredBleDevices.add(device); handleDiscoveryBle(device, new BleScanInfo().setRawData(scanRecord).setRssi(rssi).setEnableConnect(isBleEnableConnect)); + } } } diff --git a/local_plugins/ota/lib/ota.dart b/local_plugins/ota/lib/ota.dart index 871fa4198..e21eb0a8d 100644 --- a/local_plugins/ota/lib/ota.dart +++ b/local_plugins/ota/lib/ota.dart @@ -2,7 +2,6 @@ import 'ota_platform_interface.dart'; import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/services.dart'; -import 'package:get/get.dart'; class OtaEvent { static final int NOT_STARTED = -1; // 未开始 @@ -57,7 +56,7 @@ class Ota { // 私有构造函数 Ota._internal() { // 初始化时设置事件监听 - _setupEventListener(); + // _setupEventListener(); } // 公共访问点 @@ -66,13 +65,13 @@ class Ota { static const EventChannel _eventChannel = EventChannel( 'com.example.ota/events', ); - final RxDouble progress = 0.0.obs; - final RxInt type = OtaEvent.NOT_STARTED.obs; - final RxBool isUpgrade = false.obs; + // double progress = 0.0; + // late int type = OtaEvent.NOT_STARTED; + // bool isUpgrade = false; /// 事件流 Stream? _eventStream; - bool _isEventListenerSet = false; // 防止重复监听 + // bool _isEventListenerSet = false; // 防止重复监听 /// 获取事件流 Stream get eventStream { @@ -90,53 +89,48 @@ class Ota { onOtaClose(); // 停止事件监听 _eventStream = null; - _isEventListenerSet = false; - - // 清理 Rx 对象 - progress.close(); - type.close(); - isUpgrade.value = false; + //_isEventListenerSet = false; print("Ota 对象资源已释放"); } - /// 内部设置事件监听(只执行一次) - void _setupEventListener() { - if (_isEventListenerSet) return; - - eventStream.listen((event) { - if (event.event == 'onProgress' && - event.type != null && - event.progress != null) { - print("flutter 收到数据${event.progress}type=${type.value}"); - progress.value = event.progress!; - type.value = event.type!; - } - if (event.event == 'onStartOTA') { - type.value = OtaEvent.START_UPGRADING; - isUpgrade.value = true; // 开始升级 - } - if (event.event == 'onStopOTA') { - type.value = OtaEvent.UPGRADE_SUCCESS; - isUpgrade.value = false; - } - if (event.event == 'onError' && - event.code != null && - event.message != null) { - print("flutter onError${event.code}type=${event.message}"); - type.value = OtaEvent.UPGRADE_FAILED; - isUpgrade.value = false; - } - if (event.event == 'NOCONNECTION') { - type.value = OtaEvent.CONNECTION_DISCONNECTION; - isUpgrade.value = false; - } - if (event.event == 'CONNECTION_SUCCESS') { - type.value = OtaEvent.CONNECTION_SUCCESS; - } - }); - - _isEventListenerSet = true; - } + // /// 内部设置事件监听(只执行一次) + // void _setupEventListener() { + // if (_isEventListenerSet) return; + + // eventStream.listen((event) { + // if (event.event == 'onProgress' && + // event.type != null && + // event.progress != null) { + // print("flutter 收到数据${event.progress}type=${type}"); + // progress = event.progress!; + // type = event.type!; + // } + // if (event.event == 'onStartOTA') { + // type = OtaEvent.START_UPGRADING; + // isUpgrade = true; // 开始升级 + // } + // if (event.event == 'onStopOTA') { + // type = OtaEvent.UPGRADE_SUCCESS; + // isUpgrade = false; + // } + // if (event.event == 'onError' && + // event.code != null && + // event.message != null) { + // print("flutter onError${event.code}type=${event.message}"); + // type = OtaEvent.UPGRADE_FAILED; + // isUpgrade = false; + // } + // if (event.event == 'NOCONNECTION') { + // type = OtaEvent.CONNECTION_DISCONNECTION; + // isUpgrade = false; + // } + // if (event.event == 'CONNECTION_SUCCESS') { + // type = OtaEvent.CONNECTION_SUCCESS; + // } + // }); + + // _isEventListenerSet = true; + // } Future startUpgrading(String? filePath) { return OtaPlatform.instance.startUpgrading(filePath); diff --git a/local_plugins/ota/pubspec.yaml b/local_plugins/ota/pubspec.yaml index 6361a0944..7db9dc8a6 100644 --- a/local_plugins/ota/pubspec.yaml +++ b/local_plugins/ota/pubspec.yaml @@ -11,7 +11,6 @@ dependencies: flutter: sdk: flutter plugin_platform_interface: ^2.0.2 - get: ^4.6.5 dev_dependencies: flutter_test: sdk: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 976d65ab9..58beb5f36 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,7 @@ dependencies: audio_session: ^0.1.18 flutter_markdown: ^0.7.7+1 markdown: ^7.1.1 - permission_handler: ^11.4.0 + permission_handler: ^12.0.0+1 web_socket_channel: ^3.0.2 flutter_dotenv: ^5.1.0 synchronized: ^3.1.0