From 2fa050a6bd21ad699601870d3f3ce119def88147 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 13 Aug 2025 14:35:58 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix(=E7=99=BB=E5=BD=95):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E9=AA=8C=E8=AF=81=E7=A0=81=E8=8E=B7=E5=8F=96=E5=92=8C?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E7=9A=84=E8=BE=93=E5=85=A5=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加对邮箱和手机号格式的严格验证 移除未使用的用户渠道枚举代码 修复邮箱验证方法命名错误(isEmialContactController -> isEmailContactController) 根据国家代码添加手机号格式验证 --- .../login/controllers/login_controller.dart | 122 ++++++++---------- 1 file changed, 57 insertions(+), 65 deletions(-) diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 3b0c7f590..b605931ca 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -26,22 +26,6 @@ enum LoginViewState { verification // 验证码页面 } -// 用户渠道枚举 -// enum UserChannel { -// UNKNOWN(0), // 未知渠道 -// CHINA_HUAWEI(1), // 中国-华为 -// CHINA_XIAOMI(2), // 中国-小米 -// CHINA_OPPO(3), // 中国-OPPO -// CHINA_VIVO(4), // 中国-VIVO -// CHINA_HONOR(5), // 中国-荣耀 -// OFFICIAL(100), // 官方渠道 -// GLOBAL_APPLE(101), // 全球-苹果 -// GLOBAL_GOOGLE(102); // 全球-谷歌 - -// const UserChannel(this.value); -// final int value; -// } - class LoginController extends GetxController { static LoginController get to => Get.find(); final GetStorage _storage = GetStorage(); @@ -354,9 +338,32 @@ class LoginController extends GetxController { // 获取验证码 void getVerificationCode() { if (contactController.text.isEmpty) { + Get.snackbar( + 'tip'.tr, //提示 + 'pleaseEnterContactInfo'.tr, //请输入手机号或邮箱 + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } + + // 根据登录类型验证格式 + if (loginType == 0 && !isEmailContactController()) { Get.snackbar( 'tip'.tr, - 'pleaseEnterContactInfo'.tr, + 'invalidEmailFormat'.tr, + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red.withOpacity(0.1), + colorText: Colors.red, + ); + return; + } + + if (loginType == 1 && !isPhoneContactController()) { + Get.snackbar( + 'tip'.tr, + 'invalidPhoneFormat'.tr, snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -371,40 +378,6 @@ class LoginController extends GetxController { // 使用验证码直接登录 void loginWithVerificationCode() { - //测试期间暂时关闭验证码登录 - // if (contactController.text.isEmpty) { - // Get.snackbar( - // '提示', - // '请输入手机号码或邮箱', - // snackPosition: SnackPosition.TOP, - // backgroundColor: Colors.red.withOpacity(0.1), - // colorText: Colors.red, - // ); - // return; - // } - - // if (verificationCodeController.text.isEmpty) { - // Get.snackbar( - // '提示', - // '请输入验证码', - // snackPosition: SnackPosition.TOP, - // backgroundColor: Colors.red.withOpacity(0.1), - // colorText: Colors.red, - // ); - // return; - // } - - // if (verificationCodeController.text.length < 6) { - // Get.snackbar( - // '提示', - // '请输入完整的验证码', - // snackPosition: SnackPosition.TOP, - // backgroundColor: Colors.red.withOpacity(0.1), - // colorText: Colors.red, - // ); - // return; - // } - _isLoading.value = true; // 模拟验证过程 @@ -481,9 +454,8 @@ class LoginController extends GetxController { // 发送验证码 Future _sendVerificationCode() async { - // 这里应该是实际发送验证码的逻辑 - // 为了演示,我们只是模拟发送 - if (isEmialContactController() || isPhoneContactController()) { + // 实际发送验证码的逻辑 + if (isEmailContactController() || isPhoneContactController()) { _startCountdown(); Logger().d(contactController.text); var response = await Api.getCode({ @@ -497,8 +469,8 @@ class LoginController extends GetxController { } } else { Get.snackbar( - 'tip'.tr, - 'pleaseEnterContactInfo'.tr, + 'tip'.tr, //提示 + 'pleaseEnterContactInfo'.tr, //请输入手机号或邮箱 snackPosition: SnackPosition.TOP, backgroundColor: Colors.red.withOpacity(0.1), colorText: Colors.red, @@ -530,19 +502,39 @@ class LoginController extends GetxController { } // 检查邮箱格式 - bool isEmialContactController() { + bool isEmailContactController() { // 检查每个元素是否为单个字母或数字字符 - final text = contactController.text; - return (text.length > 5 && - RegExp(r'^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') - .hasMatch(text)); + final text = contactController.text.trim(); + if (text.length < 6) return false; + + // 更严格的邮箱验证 + final emailRegex = + RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'); + return emailRegex.hasMatch(text); } -// 检查手机号码格式 + // 检查手机号码格式 bool isPhoneContactController() { // 检查每个元素是否为单个字母或数字字符 - final text = contactController.text; - return RegExp(r'^[0-9]+$').hasMatch(text); + final text = contactController.text.trim(); + if (text.isEmpty) return false; + // 根据国家代码验证手机号长度 + // 长度检查 + if (text.length < 7 || text.length > 15) return false; + + // 必须是纯数字 + if (!RegExp(r'^[0-9]+$').hasMatch(text)) return false; + // 特定国家的特殊验证 + switch (_selectedCountryCode.value) { + case '+86': // 中国 + return text.length == 11 && text.startsWith('1'); + case '+1': // 美国/加拿大 + return text.length == 10; + case '+81': // 日本 + return text.length == 10 || text.length == 11; + default: + return true; // 其他国家只做基本验证 + } } // 检查验证码是否完整 @@ -570,7 +562,7 @@ class LoginController extends GetxController { } // 验证邮箱格式 - if (loginType == 0 && !isEmialContactController()) { + if (loginType == 0 && !isEmailContactController()) { Get.snackbar( 'error'.tr, 'invalidEmailFormat'.tr, From 2e9a94ab6956cf24e026ba96e220a235d009f4d1 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Wed, 13 Aug 2025 15:56:07 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=B0=E6=89=8B?= =?UTF-8?q?=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/intro_overlay_builder.dart | 3 +- lib/data/services/novice_guide_service.dart | 2 +- lib/modules/agent/views/agent_view.dart | 153 +++++++++++++------- 3 files changed, 100 insertions(+), 58 deletions(-) diff --git a/lib/core/widgets/intro_overlay_builder.dart b/lib/core/widgets/intro_overlay_builder.dart index 920737ae2..87101729b 100644 --- a/lib/core/widgets/intro_overlay_builder.dart +++ b/lib/core/widgets/intro_overlay_builder.dart @@ -7,6 +7,7 @@ class IntroOverlayBuilder { static Widget homeBuilder( StepWidgetParams params, bool isWakeup, { + String title = '对着麦克风说:“小言小言”', Function? onNext, }) { return Column( @@ -21,7 +22,7 @@ class IntroOverlayBuilder { Padding( padding: EdgeInsets.only(bottom: 20.w), child: Text( - isWakeup ? '我在呢~' : '对着麦克风说:“小言小言”', + isWakeup ? '我在呢~' : title, style: TextStyle( fontSize: 15.sp, color: Colors.white, diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index d32be2368..4fa2c12c7 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -12,7 +12,7 @@ class NoviceGuideService extends GetxService { StreamSubscription? _statusSubscription; bool isHomeGuide = true; - bool isAIGuide = true; + bool isAIGuide = false; RxBool isWakeup = false.obs; diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 849baa4cc..9ce31cb95 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -193,62 +193,104 @@ class _AgentViewState extends State with WidgetsBindingObserver { ), body: Stack( children: [ + IntroStepBuilder( + group: 'ai', + order: 3, + getOverlayPosition: ({ + required Offset offset, + required Size screenSize, + required Size size, + }) { + return OverlayPosition( + top: 200.h, + width: screenSize.width, + crossAxisAlignment: CrossAxisAlignment.end, + ); + }, + overlayBuilder: (params) { + return IntroOverlayBuilder.homeBuilder( + params, + false, + title: '小言正在思考中~', + onNext: () {}, + ); + }, + builder: (BuildContext context, GlobalKey key) => SizedBox( + key: key, + ), + ), // 主要聊天界面 Column( children: [ // 聊天内容 Expanded( - child: Stack( - children: [ - IntroStepBuilder( - group: 'ai', - order: 3, - overlayBuilder: (params) { - return IntroOverlayBuilder.aiBuilder( - params, - controller.noviceGuideService.aiStepsList[ - controller.noviceGuideService.aiStep]['end'], - onFinish: () async { - if (controller.noviceGuideService.aiStep < 5) { - await Future.delayed( - const Duration(milliseconds: 300)); - controller.noviceGuideService.aiStep++; - Intro.of(Get.context!) - .start(group: 'ai', reset: true); - } - }, - ); - }, - builder: (BuildContext context, GlobalKey key) => Align( - alignment: Alignment.bottomCenter, - child: SizedBox( - key: key, - width: double.infinity, - height: 430.w, - ), - ), - ), - Obx( - () => Align( - alignment: Alignment.topCenter, - child: ListView.builder( - controller: controller.scrollController, - shrinkWrap: true, - reverse: true, - padding: EdgeInsets.symmetric( - vertical: 16.h, horizontal: 16.w), - itemCount: controller.messages.length + - (controller.hasMoreMessages.value ? 1 : 0), - itemBuilder: (context, index) { - // 如果是最后一项且有更多消息,显示加载指示器 - if (index == controller.messages.length && - controller.hasMoreMessages.value) { - return _buildLoadMoreIndicator(); - } + child: Obx(() { + return Align( + alignment: Alignment.topCenter, + child: ListView.builder( + controller: controller.scrollController, + shrinkWrap: true, + reverse: true, + padding: EdgeInsets.symmetric( + vertical: 16.h, horizontal: 16.w), + itemCount: controller.messages.length + + (controller.hasMoreMessages.value ? 1 : 0), + itemBuilder: (context, index) { + // 如果是最后一项且有更多消息,显示加载指示器 + if (index == controller.messages.length && + controller.hasMoreMessages.value) { + return _buildLoadMoreIndicator(); + } - final message = controller.messages[ - controller.messages.length - 1 - index]; - return Padding( + final message = controller + .messages[controller.messages.length - 1 - index]; + return index == 0 + ? IntroStepBuilder( + group: 'ai', + order: 4, + overlayBuilder: (params) { + return IntroOverlayBuilder.aiBuilder( + params, + controller.noviceGuideService.aiStepsList[ + controller + .noviceGuideService.aiStep]['end'], + onFinish: () async { + if (controller.noviceGuideService.aiStep < + 5) { + await Future.delayed( + const Duration(milliseconds: 300)); + controller.noviceGuideService.aiStep++; + Intro.of(Get.context!) + .start(group: 'ai', reset: true); + } + }, + ); + }, + builder: + (BuildContext context, GlobalKey key) => + Padding( + key: key, + padding: EdgeInsets.only(bottom: 8.h), + child: GestureDetector( + onLongPressStart: + (LongPressStartDetails details) { + messageActionController + .onLongPressMessage(context, + isDarkMode, details, message); + }, + child: MessageBubble( + isUser: message.isUser, + message: message.text, + timestamp: message.timestamp, + isRecognizing: message.isRecognizing, + hasImage: message.hasImage, + imagePath: message.imagePath, + meta: message.meta, + ), + ), + ), + ) + : Padding( padding: EdgeInsets.only(bottom: 8.h), child: GestureDetector( onLongPressStart: @@ -265,13 +307,12 @@ class _AgentViewState extends State with WidgetsBindingObserver { imagePath: message.imagePath, meta: message.meta, ), - )); - }, - ), - ), + ), + ); + }, ), - ], - ), + ); + }), ), // 输入区域 From 0d2f0f1ef89e66ceac99561b6544a049f393bfe8 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Wed, 13 Aug 2025 16:54:30 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=B0=E6=89=8B?= =?UTF-8?q?=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/agent/controllers/agent_controller.dart | 3 +++ lib/modules/agent/views/agent_view.dart | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 82e7696e1..4ab856798 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -123,6 +123,7 @@ class AgentController extends GetxController { Timer? _progressTimer; // 用于跟踪AI回复状态 bool _isNewAssistantResponse = true; + RxBool newAIReplyCompleted = true.obs; String _currentResponseId = ''; final TAG = 'AgentController'; @@ -635,6 +636,7 @@ class AgentController extends GetxController { isImageProcessing.value = false; // 标记当前回复完成 _isNewAssistantResponse = true; + newAIReplyCompleted.value = true; break; case AgentServiceEventType.error: @@ -892,6 +894,7 @@ class AgentController extends GetxController { isProcessing.value = true; // 标记为新的AI回复 _isNewAssistantResponse = true; + newAIReplyCompleted.value = false; // 根据TTS状态决定是否朗读 await AgentService.processTextInput(text, speakResponse: isTtsEnabled.value); diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 9ce31cb95..1eaee3469 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -208,11 +208,13 @@ class _AgentViewState extends State with WidgetsBindingObserver { ); }, overlayBuilder: (params) { - return IntroOverlayBuilder.homeBuilder( - params, - false, - title: '小言正在思考中~', - onNext: () {}, + return Obx( + () => IntroOverlayBuilder.homeBuilder( + params, + false, + title: '小言正在思考中~', + onNext: controller.newAIReplyCompleted.value ? () {} : null, + ), ); }, builder: (BuildContext context, GlobalKey key) => SizedBox( From 1b88059dd808508c2174e149b0c87285824fa6c9 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 13 Aug 2025 17:52:26 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=9F=A5=E7=9C=8B=E5=99=A8=E8=B7=AF=E5=BE=84=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BA=A4=E4=BA=92=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 处理图片路径拼接逻辑,支持相对路径和完整路径 - 移除AppBar改为点击图片退出查看器 - 优化图片查看器的交互方式 --- lib/data/services/novice_guide_service.dart | 2 +- .../agent/controllers/agent_controller.dart | 49 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index 4fa2c12c7..d32be2368 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -12,7 +12,7 @@ class NoviceGuideService extends GetxService { StreamSubscription? _statusSubscription; bool isHomeGuide = true; - bool isAIGuide = false; + bool isAIGuide = true; RxBool isWakeup = false.obs; diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 4ab856798..89eed9292 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -1759,21 +1759,52 @@ class AgentController extends GetxController { // 打开图片查看器 void openImageViewer(BuildContext context, String imagePath) { + // 构建完整的图片路径 + String fullImagePath; + if (imagePath.startsWith('/')) { + // 如果是相对路径(以/开头),需要拼接AppDir.value + fullImagePath = '${AppDir.value}$imagePath'; + } else { + // 如果已经是完整路径,直接使用 + fullImagePath = imagePath; + } + Navigator.push( context, MaterialPageRoute( builder: (context) => Scaffold( - appBar: AppBar( - backgroundColor: Colors.black, - iconTheme: IconThemeData(color: Colors.white), - ), + // appBar: AppBar( + // backgroundColor: Colors.black, + // leading: IconButton( + // icon: Icon(Icons.arrow_back_ios, color: Colors.white), + // onPressed: () => Navigator.of(context).pop(), + // ), + // iconTheme: IconThemeData(color: Colors.white), + // //title: Text('图片预览', style: TextStyle(color: Colors.white)), + // ), body: Container( color: Colors.black, - child: PhotoView( - imageProvider: FileImage(File(imagePath)), - minScale: PhotoViewComputedScale.contained, - maxScale: PhotoViewComputedScale.covered * 2, - backgroundDecoration: BoxDecoration(color: Colors.black), + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), // 点击退出 + + child: PhotoView( + imageProvider: FileImage(File(fullImagePath)), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 2, + backgroundDecoration: BoxDecoration(color: Colors.black), + // errorBuilder: (context, error, stackTrace) { + // return const Center( + // child: Column( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Icon(Icons.error, color: Colors.white, size: 64), + // SizedBox(height: 16), + // Text('图片加载失败', style: TextStyle(color: Colors.white)), + // ], + // ), + // ); + // }, + ), ), ), ), From 9a5fa24a3fa4c5627bb057e3ad48c5365a0f0f02 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Wed, 13 Aug 2025 17:56:10 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=BE=85=E7=94=A8?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/services/novice_guide_service.dart | 18 ++++++++++++++++++ .../agent/controllers/agent_controller.dart | 2 ++ lib/modules/agent/views/agent_view.dart | 6 +++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index 4fa2c12c7..dd048f1a8 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -24,36 +24,54 @@ class NoviceGuideService extends GetxService { 'input': '今天天气怎么样', 'send': '小言会为你实时为你播报天气', 'end': '小言能实时为你播报天气,试试其他指令吧', + 'method': () async { + print('-----1------'); + }, }, { 'start': '对小言说:“请播放一组摇滚乐”', 'input': '请播放一组摇滚乐', 'send': '小言会为你搜寻到一组音乐', 'end': '小言已成功为您搜寻到一组音乐,试试其他指令吧', + 'method': () async { + print('-----2------'); + }, }, { 'start': '对小言说:“导航到深圳机场”', 'input': '导航到深圳机场', 'send': '小言会为你规划路线', 'end': '小言已成功为您导航到深圳机场,试试其他指令吧', + 'method': () async { + print('-----3------'); + }, }, { 'start': '对小言说:“明天下午3点创建会议提醒”', 'input': '明天下午3点创建会议提醒', 'send': '小言会为你创建会议提醒', 'end': '小言已成功为您创建会议提醒,试试其他指令吧', + 'method': () async { + print('-----4------'); + }, }, { 'start': '对小言说:“拨打妈妈的电话”', 'input': '拨打妈妈的电话', 'send': '小言会为你拨打电话', 'end': '小言已成功为您拨打电话,试试其他指令吧', + 'method': () async { + print('-----5------'); + }, }, { 'start': '对小言说:“给爸爸发短信说晚上回家吃饭’”', 'input': '给爸爸发短信说晚上回家吃饭’', 'send': '小言会为你发送短信', 'end': '短信已发送,语音操控更便捷', + 'method': () async { + print('-----6------'); + }, }, ]; diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 4ab856798..bffeefbcb 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'package:amap_service/amap_service.dart'; +import 'package:flutter_intro/flutter_intro.dart'; import '../../../data/services/ble_manager.dart'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/novice_guide_service.dart'; @@ -208,6 +209,7 @@ class AgentController extends GetxController { _eventSubscription?.cancel(); _amapeventSubscription?.cancel(); _bleEventSubscription?.cancel(); + Intro.of(Get.context!).dispose(); super.onClose(); } diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 1eaee3469..2d7af6881 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -728,7 +728,11 @@ class _AgentViewState extends State with WidgetsBindingObserver { return IntroStepBuilder( group: 'ai', order: 2, - onHighlightWidgetTap: () { + onHighlightWidgetTap: () async { + await controller + .noviceGuideService.aiStepsList[ + controller.noviceGuideService + .aiStep]['method'](); widgetParams!.onNext!(); controller.sendMessage(); }, From c9c9e4abd6c12e6b57bdbb5c02e7b9a3ad7f5add Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 13 Aug 2025 18:52:20 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat(=E6=9D=83=E9=99=90):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=81=94=E7=B3=BB=E4=BA=BA=E6=9D=83=E9=99=90=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E5=B9=B6=E5=AE=9E=E7=8E=B0=E6=96=B0=E6=89=8B=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E4=B8=AD=E7=9A=84=E6=9D=83=E9=99=90=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为电话和短信功能添加联系人权限的国际化描述 在新手引导服务中实现各功能所需的权限请求逻辑 --- lib/core/translations/language/en_us.dart | 3 + lib/core/translations/language/zh_cn.dart | 2 + lib/data/services/novice_guide_service.dart | 68 ++++++++++++++++++--- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/lib/core/translations/language/en_us.dart b/lib/core/translations/language/en_us.dart index b4c776ab6..45071ebdd 100644 --- a/lib/core/translations/language/en_us.dart +++ b/lib/core/translations/language/en_us.dart @@ -518,6 +518,9 @@ const Map enUS = { 'smsPermissionDetailDesc': 'SMS functionality requires SMS permission', 'alarmPermissionDetailDesc': 'This permission is required to set exact alarms and ensure reminders trigger on time', + 'contactsPermissionDesc': 'Used for accessing contact information', + 'contactsPermissionDetailDesc': + 'Phone call and SMS functionality requires contacts permission to select contacts', 'calendarWritePermissionDetailDesc': 'Creating calendar events requires calendar write permission', 'calendarFullAccessPermissionDetailDesc': diff --git a/lib/core/translations/language/zh_cn.dart b/lib/core/translations/language/zh_cn.dart index e0757379e..986e8cd14 100644 --- a/lib/core/translations/language/zh_cn.dart +++ b/lib/core/translations/language/zh_cn.dart @@ -484,6 +484,8 @@ const Map zhCN = { 'calendarWritePermissionDetailDesc': '创建日历事件需要日历写入权限', 'calendarFullAccessPermissionDetailDesc': '管理日历事件需要完整日历权限', 'locationPermissionDetailDesc': '获取位置信息需要位置权限', + 'contactsPermissionDesc': '用于访问联系人信息', + 'contactsPermissionDetailDesc': '拨打电话和发送短信功能需要联系人权限以选择联系人', 'appNeedsPermissionForFullFunction': '应用需要此权限以提供完整功能', 'permanentDenialText': '您已永久拒绝{permission}权限,请前往应用设置开启', 'goToSettings': '去设置', diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index 44ab00590..0f908bb69 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -4,6 +4,8 @@ import 'package:ble_service/ble_service.dart'; import 'package:flutter_intro/flutter_intro.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; +import 'package:permission_handler/permission_handler.dart'; +import '../../core/utils/permission_util.dart'; class NoviceGuideService extends GetxService { final GetStorage _storage = GetStorage(); @@ -12,7 +14,7 @@ class NoviceGuideService extends GetxService { StreamSubscription? _statusSubscription; bool isHomeGuide = true; - bool isAIGuide = true; + bool isAIGuide = false; RxBool isWakeup = false.obs; @@ -25,7 +27,15 @@ class NoviceGuideService extends GetxService { 'send': '小言会为你实时为你播报天气', 'end': '小言能实时为你播报天气,试试其他指令吧', 'method': () async { - print('-----1------'); + // 天气查询需要位置权限 + + await PermissionUtil.instance.requestPermission( + permissionType: Permission.location, + permissionName: 'locationInfo'.tr, // 位置信息 + explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息 + permanentDenialText: + 'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限 + ); }, }, { @@ -33,9 +43,7 @@ class NoviceGuideService extends GetxService { 'input': '请播放一组摇滚乐', 'send': '小言会为你搜寻到一组音乐', 'end': '小言已成功为您搜寻到一组音乐,试试其他指令吧', - 'method': () async { - print('-----2------'); - }, + 'method': () async {}, }, { 'start': '对小言说:“导航到深圳机场”', @@ -43,7 +51,14 @@ class NoviceGuideService extends GetxService { 'send': '小言会为你规划路线', 'end': '小言已成功为您导航到深圳机场,试试其他指令吧', 'method': () async { - print('-----3------'); + // 导航功能需要位置权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.location, + permissionName: 'locationInfo'.tr, // 位置信息 + explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息 + permanentDenialText: + 'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限 + ); }, }, { @@ -52,7 +67,14 @@ class NoviceGuideService extends GetxService { 'send': '小言会为你创建会议提醒', 'end': '小言已成功为您创建会议提醒,试试其他指令吧', 'method': () async { - print('-----4------'); + //创建会议提醒需要日历权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.calendarWriteOnly, + permissionName: 'calendarWrite'.tr, // 日历写入 + explanationText: 'calendarWritePermissionDesc'.tr, // 用于创建日历事件和提醒 + permanentDenialText: + 'calendarWritePermissionDetailDesc'.tr, // 创建日历事件需要日历写入权限 + ); }, }, { @@ -61,7 +83,21 @@ class NoviceGuideService extends GetxService { 'send': '小言会为你拨打电话', 'end': '小言已成功为您拨打电话,试试其他指令吧', 'method': () async { - print('-----5------'); + // 拨打电话需要电话权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.phone, + permissionName: 'phone'.tr, // 电话 + explanationText: 'phonePermissionDesc'.tr, // 用于拨打电话功能 + permanentDenialText: 'phonePermissionDetailDesc'.tr, // 拨打电话功能需要电话权限 + ); + //拨打电话还需要联系人权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.contacts, + permissionName: 'contacts'.tr, // 联系人 + explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息 + permanentDenialText: + 'contactsPermissionDetailDesc'.tr, // 拨打电话功能需要联系人权限 + ); }, }, { @@ -70,7 +106,21 @@ class NoviceGuideService extends GetxService { 'send': '小言会为你发送短信', 'end': '短信已发送,语音操控更便捷', 'method': () async { - print('-----6------'); + // 发送短信需要短信权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.sms, + permissionName: 'sms'.tr, // 短信 + explanationText: 'smsPermissionDesc'.tr, // 用于发送短信功能 + permanentDenialText: 'smsPermissionDetailDesc'.tr, // 发送短信功能需要短信权限 + ); + //发送短信还需要联系人权限 + await PermissionUtil.instance.requestPermission( + permissionType: Permission.contacts, + permissionName: 'contacts'.tr, // 联系人 + explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息 + permanentDenialText: + 'contactsPermissionDetailDesc'.tr, // 发送短信功能需要联系人权限 + ); }, }, ]; From 1830801677bfd06005b29244c95e6d40ab253ec0 Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Wed, 13 Aug 2025 20:05:06 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/intro_overlay_builder.dart | 65 +++++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/lib/core/widgets/intro_overlay_builder.dart b/lib/core/widgets/intro_overlay_builder.dart index 87101729b..a4a6c70b0 100644 --- a/lib/core/widgets/intro_overlay_builder.dart +++ b/lib/core/widgets/intro_overlay_builder.dart @@ -31,12 +31,27 @@ class IntroOverlayBuilder { ), ), if (onNext != null) - _button( - '下一步', - () { - params.onNext!(); - onNext(); - }, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.only(right: 50.w), + child: _button( + '下一步', + () { + params.onNext!(); + onNext(); + }, + ), + ), + _button( + '跳过', + () { + params.onFinish(); + }, + isSkip: true, + ), + ], ), ], ); @@ -83,17 +98,43 @@ class IntroOverlayBuilder { onFinish(); }, ), + const Spacer(), + _button( + '跳过', + () { + params.onFinish(); + }, + isSkip: true, + ), ], ), ], ); } - static Widget _button(String text, Function() onTap) { - return _AnimatedBorderButton( - text: text, - onTap: onTap, - ); + static Widget _button(String text, Function() onTap, {bool isSkip = false}) { + return isSkip + ? GestureDetector( + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 5.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.r), + border: Border.all(color: Colors.white), + ), + child: Text( + text, + style: TextStyle( + fontSize: 14.sp, + color: Colors.white, + ), + ), + ), + ) + : _AnimatedBorderButton( + text: text, + onTap: onTap, + ); } } @@ -208,7 +249,7 @@ class _AnimatedBorderButtonState extends State<_AnimatedBorderButton> borderRadius: 20.r, ), child: Container( - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.w), + padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 5.w), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20.r), border: Border.all(color: Colors.transparent), From 1e90ec365c2d4d9def4340418ce08308c07bfedc Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Wed, 13 Aug 2025 20:46:11 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=B0=E6=89=8B?= =?UTF-8?q?=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/intro_overlay_builder.dart | 2 +- lib/data/services/novice_guide_service.dart | 7 +++---- lib/modules/agent/views/agent_view.dart | 4 +++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/core/widgets/intro_overlay_builder.dart b/lib/core/widgets/intro_overlay_builder.dart index a4a6c70b0..fec06693e 100644 --- a/lib/core/widgets/intro_overlay_builder.dart +++ b/lib/core/widgets/intro_overlay_builder.dart @@ -98,7 +98,7 @@ class IntroOverlayBuilder { onFinish(); }, ), - const Spacer(), + if (onNext != null || onFinish != null) const Spacer(), _button( '跳过', () { diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index 0f908bb69..c58b1a312 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -14,7 +14,7 @@ class NoviceGuideService extends GetxService { StreamSubscription? _statusSubscription; bool isHomeGuide = true; - bool isAIGuide = false; + bool isAIGuide = true; RxBool isWakeup = false.obs; @@ -28,7 +28,6 @@ class NoviceGuideService extends GetxService { 'end': '小言能实时为你播报天气,试试其他指令吧', 'method': () async { // 天气查询需要位置权限 - await PermissionUtil.instance.requestPermission( permissionType: Permission.location, permissionName: 'locationInfo'.tr, // 位置信息 @@ -128,8 +127,8 @@ class NoviceGuideService extends GetxService { @override void onInit() { super.onInit(); - // isHomeGuide = _storage.read('home_guide') ?? false; - // isAIGuide = _storage.read('ai_guide') ?? false; + isHomeGuide = _storage.read('home_guide') ?? false; + isAIGuide = _storage.read('ai_guide') ?? false; } @override diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 2d7af6881..c78eb62d3 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -212,7 +212,9 @@ class _AgentViewState extends State with WidgetsBindingObserver { () => IntroOverlayBuilder.homeBuilder( params, false, - title: '小言正在思考中~', + title: controller.newAIReplyCompleted.value + ? '小言已经完成了回答!' + : '小言正在思考中~', onNext: controller.newAIReplyCompleted.value ? () {} : null, ), ); From 96ebf38e9c84c2da8b47d7690122d0deb1303e43 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 13 Aug 2025 20:56:20 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat(=E8=AF=AD=E9=9F=B3=E8=AF=86=E5=88=AB?= =?UTF-8?q?):=20=E6=B7=BB=E5=8A=A0=E5=A4=9A=E6=A8=A1=E5=BC=8F=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E8=AF=86=E5=88=AB=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为语音识别服务添加多种模式支持,包括普通模式、BLE唤醒模式、通话模式和按住说话模式。根据不同的模式调整空闲检测策略,优化用户体验。 修改涉及跨平台实现,包括Dart、Kotlin和Swift代码,确保各平台行为一致。主要变更包括: - 在startConversation方法中添加mode参数 - 根据不同模式调整空闲检测逻辑 - 在自动停止事件中添加模式信息 - 修复BLE唤醒模式下的识别问题 --- lib/data/services/novice_guide_service.dart | 2 +- .../agent/controllers/agent_controller.dart | 7 +- .../agent_service/AgentService.kt | 76 ++++++++++++++-- .../agent_service/AgentServicePlugin.kt | 3 +- .../yunqiinnovation/agent_service/BleAgent.kt | 8 +- .../agent_service/AgentServiceImpl.swift | 86 +++++++++++++++---- .../agent_service/AgentServicePlugin.swift | 3 +- .../agent_service/lib/agent_service.dart | 7 +- 8 files changed, 154 insertions(+), 38 deletions(-) diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index 0f908bb69..d7fc7c099 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -14,7 +14,7 @@ class NoviceGuideService extends GetxService { StreamSubscription? _statusSubscription; bool isHomeGuide = true; - bool isAIGuide = false; + bool isAIGuide = true; RxBool isWakeup = false.obs; diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 14f938331..6ca07abcc 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -160,6 +160,7 @@ class AgentController extends GetxController { _setupScrollListener(); //设置滚动监听器 _loadChatHistory(); //加载聊天历史记录 _subscribeToEvents(); //订阅代理服务事件 + _subscribeToBleEvents(); //订阅BLE事件 _initMusicStatus(); //初始化音乐状态 _setupMediaSessionCallbacks(); //设置媒体会话回调 _setupTextInputListener(); //设置文本输入监听器 @@ -1341,7 +1342,7 @@ class AgentController extends GetxController { // 打电话模式 isTextInputMode.value = false; Logger.i(TAG, '打电话模式已启动,开始语音输入'); - await AgentService.startConversation(); + await AgentService.startConversation(mode: 'phone_call'); } // 结束打电话模式 @@ -1393,7 +1394,7 @@ class AgentController extends GetxController { } else { // 当前未录音,开始录音 Logger.i(TAG, '开始打电话模式录音'); - final result = await AgentService.startConversation(); + final result = await AgentService.startConversation(mode: 'phone_call'); // 手动更新状态,确保UI同步 if (result) { @@ -1497,7 +1498,7 @@ class AgentController extends GetxController { isPushToTalkActive.value = true; //await AgentService.recognizeCallback(); // 开始语音识别 - await AgentService.startConversation(); + await AgentService.startConversation(mode: 'push_to_talk'); // await startVoiceInput(); } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 0acee79b3..d74db166f 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -117,7 +117,10 @@ object AgentService : CoroutineScope { // 空闲检测相关 private var idleCheckJob: Job? = null - private val maxIdleSeconds = 10 // 最大空闲秒数 + private val maxIdleSeconds = 10 // 最大空闲秒数(默认值) + + // 语音识别模式 + private var currentRecognitionMode = "normal" // 当前识别模式:normal, ble_wakeup, phone_call, push_to_talk // 打断防抖相关 private var lastInterruptTime = 0L @@ -409,22 +412,63 @@ object AgentService : CoroutineScope { listeners.clear() } + /** + * 根据模式启动空闲检测 + */ + private fun startIdleCheckForMode(mode: String) { + stopIdleCheck() // 先停止现有的检查 + if (!isRecognitionActive) return + + // 根据模式决定空闲检测策略 + when (mode) { + "phone_call" -> { + // 通话模式:禁用空闲检测,保持持续激活 + Log.d(TAG, "通话模式:禁用空闲检测") + return + } + "ble_wakeup" -> { + // BLE唤醒模式:使用默认的空闲检测 + startIdleCheck(maxIdleSeconds) + } + "push_to_talk" -> { + // 按住说话模式:使用较长的空闲检测时间 + startIdleCheck(maxIdleSeconds) + } + "normal" -> { + // 普通模式:使用默认的空闲检测 + startIdleCheck(maxIdleSeconds) + } + else -> { + // 未知模式:使用默认的空闲检测 + Log.w(TAG, "未知识别模式: $mode,使用默认空闲检测") + startIdleCheck(maxIdleSeconds) + } + } + } + /** * 启动空闲检测 */ - private fun startIdleCheck() { + private fun startIdleCheck(idleSeconds: Int = maxIdleSeconds) { stopIdleCheck() // 先停止现有的检查 if (!isRecognitionActive) return + Log.d(TAG, "启动空闲检测,超时时间: ${idleSeconds}秒,模式: $currentRecognitionMode") + idleCheckJob = launch { try { - delay(maxIdleSeconds * 1000L) + delay(idleSeconds * 1000L) if (isActive && isRecognitionActive && !isTtsSpeaking && !isAiStreaming) { + Log.d(TAG, "空闲检测触发自动停止,模式: $currentRecognitionMode") stopRecognition() sendEvent( "auto_stop", - mapOf("reason" to "idle_timeout", "seconds" to maxIdleSeconds) + mapOf( + "reason" to "idle_timeout", + "seconds" to idleSeconds, + "mode" to currentRecognitionMode + ) ) } } catch (e: CancellationException) { @@ -449,14 +493,16 @@ object AgentService : CoroutineScope { */ private fun restartIdleCheck() { if (isRecognitionActive) { - startIdleCheck() + startIdleCheckForMode(currentRecognitionMode) } } /** * 开始语音识别 + * @param isExternalActive 是否使用外部音频源(蓝牙) + * @param mode 识别模式:normal, ble_wakeup, phone_call, push_to_talk */ - fun startRecognition(isExternalActive: Boolean): Boolean { + fun startRecognition(isExternalActive: Boolean, mode: String = "normal"): Boolean { if (!isInitialized) { Log.d(TAG, "服务未初始化") return false @@ -488,6 +534,10 @@ object AgentService : CoroutineScope { return false } + // 设置当前识别模式 + currentRecognitionMode = mode + Log.d(TAG, "设置语音识别模式: $mode") + _isRecognitionActive.set(true) _hasSpeechDetected.set(false) @@ -500,6 +550,9 @@ object AgentService : CoroutineScope { Log.d("TAG", "azureAsrHelper?.startContinuousRecognition") azureAsrHelper?.startContinuousRecognition(audioSourceType) + // 根据模式启动相应的空闲检测 + startIdleCheckForMode(mode) + return true } catch (e: Exception) { _isRecognitionActive.set(false) @@ -586,7 +639,8 @@ object AgentService : CoroutineScope { override fun onSessionStarted() { sendEvent("recognition_started", mapOf("status" to "started")) - startIdleCheck() + // 使用模式感知的空闲检测启动方法 + startIdleCheckForMode(currentRecognitionMode) audioPlayer?.playAudio(R.raw.start) } @@ -644,16 +698,24 @@ object AgentService : CoroutineScope { fun stopRecognition() { if (!isRecognitionActive) return + Log.d(TAG, "停止语音识别,当前模式: $currentRecognitionMode") + try { azureAsrHelper?.stopContinuousRecognition() BleService.closeCodec() _isRecognitionActive.set(false) stopIdleCheck() + + // 重置识别模式 + currentRecognitionMode = "normal" return } catch (e: Exception) { Log.e(TAG, "停止语音识别异常: ${e.message}") _isRecognitionActive.set(false) stopIdleCheck() + + // 重置识别模式 + currentRecognitionMode = "normal" return } } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index 60b919843..2c22546f3 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt @@ -115,7 +115,8 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream "startConversation" -> { try { - val isStarted = AgentService.startRecognition(false) + val mode = call.argument("mode") ?: "normal" + val isStarted = AgentService.startRecognition(false, mode) result.success(isStarted) } catch (e: Exception) { Log.e(TAG, "启动对话失败", e) 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 dc8a689c4..3c4cb9144 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 @@ -66,7 +66,7 @@ object BleAgent : BleService.Callback, AgentServiceListener { return false } Log.d(TAG, "手动启动语音识别: ") - return AgentService.startRecognition(true) + return AgentService.startRecognition(true, "ble_wakeup") } /** @@ -148,11 +148,11 @@ Log.d(TAG, "手动启动语音识别: ") // 停止当前TTS,避免冲突 AgentService.interruptCurrentResponse() // AgentService.speakText("我在!") - // 启动语音识别 - + // 启动语音识别,使用BLE唤醒模式 + // 如果已经在识别,则重新启动 //stopListening() - + //stopListening() startListening() diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 574689e3b..3428b4d70 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -65,6 +65,9 @@ class AgentServiceImpl: NSObject { private var idleTimer: DispatchSourceTimer? private let maxIdleSeconds: TimeInterval = 10 + // 语音识别模式 + private var currentRecognitionMode: String = "normal" + internal var audioPlayer: AudioPlayer? // private var locationInfo:[String: String]? @@ -280,26 +283,56 @@ class AgentServiceImpl: NSObject { return true } - private func startIdleCheck() { + private func startIdleCheckForMode(mode: String) { stopIdleCheck() - guard isRecognizing else { return } - + + // 根据模式决定空闲检测策略 + switch mode { + case "phone_call": + // 通话模式:禁用空闲检测,保持持续激活 + os_log("通话模式:禁用空闲检测", log: logger, type: .info) + return + case "ble_wakeup": + // BLE唤醒模式:使用默认的空闲检测 + startIdleCheck(idleSeconds: maxIdleSeconds) + case "push_to_talk": + // 按住说话模式:使用默认的空闲检测 + startIdleCheck(idleSeconds: maxIdleSeconds) + case "normal": + // 普通模式:使用默认的空闲检测 + startIdleCheck(idleSeconds: maxIdleSeconds) + default: + // 未知模式:使用默认的空闲检测 + os_log("未知识别模式: %{public}@,使用默认空闲检测", log: logger, type: .error, mode) + startIdleCheck(idleSeconds: maxIdleSeconds) + } + } + + private func startIdleCheck(idleSeconds: TimeInterval = 10) { + stopIdleCheck() + + guard isRecognizing else { return } + + os_log("启动空闲检测,超时时间: %.0f秒,模式: %{public}@", log: logger, type: .info, idleSeconds, currentRecognitionMode) + // 使用全局队列而不是主队列,避免后台挂起问题 let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) idleTimer = timer - - timer.schedule(deadline: .now() + maxIdleSeconds) + + timer.schedule(deadline: .now() + idleSeconds) timer.setEventHandler { [weak self] in // 使用更安全的方式检查self是否存在 DispatchQueue.main.async { [weak self] in guard let self = self else { return } - + if self.isRecognizing && !self.isSpeaking && !self.isAiStreaming { + os_log("空闲检测触发自动停止,模式: %{public}@", log: self.logger, type: .info, self.currentRecognitionMode) self.stopRecognition() self.sendEvent(name: "auto_stop", data: [ - "reason": "idle_timeout", - "seconds": self.maxIdleSeconds + "reason": "idle_timeout", + "seconds": idleSeconds, + "mode": self.currentRecognitionMode ]) } } @@ -314,10 +347,10 @@ class AgentServiceImpl: NSObject { private func restartIdleCheck() { if isRecognizing { - startIdleCheck() + startIdleCheckForMode(mode: currentRecognitionMode) } } - func startRecognition(useBle: Bool = false) -> Bool { + func startRecognition(useBle: Bool = false, mode: String = "normal") -> Bool { if !isInitialized { sendError("服务未初始化", code: "NOT_INITIALIZED") return false @@ -333,9 +366,13 @@ class AgentServiceImpl: NSObject { if isRecognizing { stopRecognition() } - + + // 设置当前识别模式 + currentRecognitionMode = mode + os_log("设置语音识别模式: %{public}@", log: logger, type: .info, mode) + let audioSourceType: AzureAsrHelper.AudioSourceType = useBle ? .external : .microphone - + if useBle { AudioSessionHub.shared.begin(.playback) } else { @@ -349,7 +386,10 @@ print("ai启动语音=\(audioSourceType)=\(useBle)") sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR") return false } - + + // 根据模式启动相应的空闲检测 + startIdleCheckForMode(mode: mode) + return true } func recognizeCallback() -> Bool { @@ -367,11 +407,18 @@ print("ai启动语音=\(audioSourceType)=\(useBle)") if !isRecognizing { return true } - + + os_log("停止语音识别,当前模式: %{public}@", log: logger, type: .info, currentRecognitionMode) + guard let success = azureAsrHelper?.stopContinuousRecognition(), success else { return false } - + + stopIdleCheck() + + // 重置识别模式 + currentRecognitionMode = "normal" + return true } func disableBluetoothAudio() { @@ -1388,10 +1435,10 @@ extension AgentServiceImpl: BleService.Callback { func onWakeupSignalReceived() { stopTts() - + stopRecognition() - - startRecognition(useBle: true) + + startRecognition(useBle: true, mode: "ble_wakeup") } func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) { @@ -1620,7 +1667,8 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { sendEvent(name: "recognition_started", data: ["status": "started"]) isRecognizing = true hasSpeechDetected = false - startIdleCheck() + // 使用模式感知的空闲检测启动方法 + startIdleCheckForMode(mode: currentRecognitionMode) audioPlayer?.playStartSound() } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index fb8baa1f8..4664ba735 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift @@ -41,7 +41,8 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { result(success) case "startConversation": - impl.startRecognition() + let mode = (call.arguments as? [String: Any])?["mode"] as? String ?? "normal" + impl.startRecognition(mode: mode) result(true) case "recognizeCallback": diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 5d39accce..492c6ea2f 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -238,10 +238,13 @@ class AgentService { /// 开始对话 /// /// 启动语音识别,开始监听用户语音输入 + /// [mode] 语音识别模式:'ble_wakeup'(BLE唤醒), 'phone_call'(通话模式), 'push_to_talk'(按住说话), 'normal'(普通模式) /// 返回是否成功开始对话 - static Future startConversation() async { + static Future startConversation({String mode = 'normal'}) async { try { - final bool result = await _channel.invokeMethod('startConversation'); + final bool result = await _channel.invokeMethod('startConversation', { + 'mode': mode, + }); return result; } on PlatformException catch (e) { throw AgentServiceException(e.code, e.message ?? '启动对话失败', e.details); From bcf8e85c15ed3092136c1f6b4a7f61348fa3ea41 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Wed, 13 Aug 2025 21:24:25 +0800 Subject: [PATCH 10/12] =?UTF-8?q?refactor(agent):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A4=A9=E6=B0=94=E5=8D=A1=E7=89=87=E8=83=8C=E6=99=AF=E6=B8=90?= =?UTF-8?q?=E5=8F=98=E9=80=BB=E8=BE=91=E5=B9=B6=E7=A7=BB=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84BLE=E8=AE=A2=E9=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将天气卡片的背景渐变逻辑提取为独立方法,根据天气类型返回不同的渐变颜色 移除agent_controller中未使用的BLE事件订阅代码 --- .../agent/controllers/agent_controller.dart | 2 +- lib/modules/agent/views/weather_card.dart | 101 +++++++++++++++--- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 6ca07abcc..71d4be63b 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -160,7 +160,7 @@ class AgentController extends GetxController { _setupScrollListener(); //设置滚动监听器 _loadChatHistory(); //加载聊天历史记录 _subscribeToEvents(); //订阅代理服务事件 - _subscribeToBleEvents(); //订阅BLE事件 + // _subscribeToBleEvents(); //订阅BLE事件 _initMusicStatus(); //初始化音乐状态 _setupMediaSessionCallbacks(); //设置媒体会话回调 _setupTextInputListener(); //设置文本输入监听器 diff --git a/lib/modules/agent/views/weather_card.dart b/lib/modules/agent/views/weather_card.dart index d06f7d97a..ce5026993 100644 --- a/lib/modules/agent/views/weather_card.dart +++ b/lib/modules/agent/views/weather_card.dart @@ -25,17 +25,7 @@ Widget buildWeatherContent(BuildContext context, String city, Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( - gradient: isDarkMode - ? const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF1E40AF), Color(0xFF1E3A8A)], - ) - : const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)], - ), + gradient: _getWeatherGradient(now['dayweather'] ?? '', isDarkMode), borderRadius: BorderRadius.circular(12.r), boxShadow: [ BoxShadow( @@ -63,7 +53,7 @@ Widget buildWeatherContent(BuildContext context, String city, color: Colors.white, ), ), - SizedBox(height: 4), + SizedBox(height: 4.w), Text( _getWeatherDescriptionTranslation(now["dayweather"]), style: TextStyle( @@ -483,3 +473,90 @@ IconData getWeatherIcon(String description) { // 默认或未知天气 return Icons.help_outline; } + +/// 根据天气类型获取背景渐变颜色 +LinearGradient _getWeatherGradient(String weather, bool isDarkMode) { + final weatherType = weather.trim(); + + // 晴天 - 温暖的橙黄色 + if (['晴', '少云', '晴间多云'].any((sunny) => weatherType.contains(sunny))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFFFF8A00), Color(0xFFE65100)] // 深色模式:深橙色 + : [Color(0xFFFFB74D), Color(0xFFFF9800)], // 浅色模式:亮橙色 + ); + } + + // 多云/阴天 - 灰蓝色 + if (['多云', '阴'].any((cloudy) => weatherType.contains(cloudy))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF455A64), Color(0xFF37474F)] // 深色模式:深灰蓝 + : [Color(0xFF78909C), Color(0xFF607D8B)], // 浅色模式:灰蓝色 + ); + } + + // 雨天 - 深蓝色 + if (weatherType.contains('雨') || + ['阵雨', '雷阵雨', '小雨', '中雨', '大雨', '暴雨'] + .any((rain) => weatherType.contains(rain))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF1565C0), Color(0xFF0D47A1)] // 深色模式:深蓝色 + : [Color(0xFF42A5F5), Color(0xFF1976D2)], // 浅色模式:亮蓝色 + ); + } + + // 雪天 - 冷色调蓝白色 + if (weatherType.contains('雪') || + ['阵雪', '小雪', '中雪', '大雪', '暴雪'] + .any((snow) => weatherType.contains(snow))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF0277BD), Color(0xFF01579B)] // 深色模式:深冷蓝 + : [Color(0xFF81D4FA), Color(0xFF29B6F6)], // 浅色模式:浅蓝色 + ); + } + + // 雾霾天 - 暗黄灰色 + if (weatherType.contains('雾') || + weatherType.contains('霾') || + ['雾', '浓雾', '霾', '中度霾', '重度霾'].any((fog) => weatherType.contains(fog))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF5D4037), Color(0xFF3E2723)] // 深色模式:深棕色 + : [Color(0xFF8D6E63), Color(0xFF6D4C41)], // 浅色模式:棕灰色 + ); + } + + // 大风天 - 青绿色 + if (weatherType.contains('风') || + ['大风', '强风', '疾风', '烈风'].any((wind) => weatherType.contains(wind))) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF00695C), Color(0xFF004D40)] // 深色模式:深青色 + : [Color(0xFF26A69A), Color(0xFF00796B)], // 浅色模式:青绿色 + ); + } + + // 默认 - 原来的蓝色 + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? [Color(0xFF1E40AF), Color(0xFF1E3A8A)] + : [Color(0xFF60A5FA), Color(0xFF3B82F6)], + ); +} From 0ea6bd419c85bcf02a795c91f1a23f33fab44d57 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Thu, 14 Aug 2025 12:08:59 +0800 Subject: [PATCH 11/12] =?UTF-8?q?=E4=BC=98=E5=8C=96ota=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84=EF=BC=8C=E5=8E=BB=E5=A4=84?= =?UTF-8?q?ssl=E8=AF=81=E4=B9=A6=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/ota_upgrade_controller.dart | 872 +++++------------- .../ota_upgrade/services/device_checker.dart | 99 ++ .../services/download_manager.dart | 153 +++ .../services/ota_status_manager.dart | 43 + .../ota_upgrade/services/ui_manager.dart | 117 +++ .../ota_upgrade/services/version_manager.dart | 114 +++ 6 files changed, 735 insertions(+), 663 deletions(-) create mode 100644 lib/modules/ota_upgrade/services/device_checker.dart create mode 100644 lib/modules/ota_upgrade/services/download_manager.dart create mode 100644 lib/modules/ota_upgrade/services/ota_status_manager.dart create mode 100644 lib/modules/ota_upgrade/services/ui_manager.dart create mode 100644 lib/modules/ota_upgrade/services/version_manager.dart diff --git a/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart b/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart index 91e28e89d..9e84456af 100644 --- a/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart +++ b/lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart @@ -1,747 +1,293 @@ import 'package:classic_bluetooth/classic_bluetooth.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:voitrans/data/models/appconfig_model.dart'; -import '/routes/app_routes.dart'; -import 'package:logger/web.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:path/path.dart'; -import 'dart:async'; -import '../../../data/services/network/api.dart'; -import '../model/ota_upgrade_model.dart'; -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:logger/web.dart'; import 'package:ota/ota.dart'; import '../../../data/services/ble_manager.dart'; -import '../../../data/models/appconfig.dart'; - +import '../services/version_manager.dart'; +import '../services/download_manager.dart'; +import '../services/device_checker.dart'; +import '../services/ui_manager.dart'; +import '../services/ota_status_manager.dart'; + +/// OTA升级主控制器 - 重构后的简化版本 +/// 负责协调各个服务,管理升级流程的整体状态 class OtaUpgradeController extends GetxController { - static const int NOT_STARTED = -1; // 未开始 - static const int VALIDATION_FILE = 0; // 验证文件 - static const int ONUPGRADE = 1; // 升级中 - static const int START_UPGRADING = 2; // 开始升级 - static const int UPGRADE_SUCCESS = 3; // 升级成功 - static const int UPGRADE_FAILED = 4; // 升级失败 - static const int CONNECTION_DISCONNECTION = 5; // 连接断开 - static const int CONNECTION_SUCCESS = 6; // 连接成功 - static const int DOWNLOADING_UP_FILES = 7; // 下载升级文件 - -// 错误信息,用于存储错误提示 - final RxString errorMessage = ''.obs; + // 服务依赖 + final _versionManager = VersionManager(); + final _downloadManager = DownloadManager(); + final _deviceChecker = DeviceChecker(); + final _uiManager = UiManager(); + final _bleManager = Get.find(); + final _jlota = Ota.instance; -// 最新版本号 + // 状态管理 + final RxString errorMessage = ''.obs; final RxString latestVersion = ''.obs; - // 最新更新内容 - final RxString updatedescription = ''.obs; - -// 当前设备固件版本号 final RxString currentVersion = ''.obs; - - String _address = ''; - -// 更新包地址,用于下载更新文件 - String updatepackageaddress = ''; - -// 下载进度,范围为 0.0 到 1.0 + final RxString detailedStatus = ''.obs; + final RxString updatedescription = ''.obs; // 添加缺失的更新描述属性 final RxDouble downloadProgress = 0.0.obs; - -// 是否有可用更新的状态 - final isUpdateAvailable = true.obs; - -// 当前升级进度,范围为 0.0 到 1.0 - // final RxDouble progress = 0.0.obs; - -// OTA 单例实例,用于处理升级相关逻辑 - final jlota = Ota.instance; - -// 是否正在下载更新包的状态 + final RxDouble progress = 0.0.obs; + final RxInt type = OtaStatusManager.NOT_STARTED.obs; + final RxBool isUpdateAvailable = true.obs; final RxBool isDownloading = false.obs; - -// 蓝牙管理服务,用于获取设备信息和通信 - 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; - - // 新增:详细状态提示 - final RxString detailedStatus = ''.obs; + final RxBool isFetchingDeviceInfo = true.obs; final RxBool showDetailedStatus = false.obs; + String _address = ''; + String _updatePackageAddress = ''; + @override void onInit() { super.onInit(); - _getCurrentVersion(); + _initializeController(); } @override void onClose() { - jlota.onOtaClose(); + _jlota.onOtaClose(); _bleManager.setEnterOta(false); isUpgrade.value = false; super.onClose(); } - // 添加重试方法 - void retryConnection() { - errorMessage.value = ''; - _getCurrentVersion(); - } - - String getStatusMessage(int type) { - switch (type) { - case NOT_STARTED: // -1 - return "未开始".tr; - case VALIDATION_FILE: // 0 - return "验证文件中".tr; - case ONUPGRADE: // 1 - return "升级中".tr; - case START_UPGRADING: // 2 - return "开始升级".tr; - case UPGRADE_SUCCESS: // 3 - return "升级成功".tr; - case UPGRADE_FAILED: // 4 - return "升级失败".tr; - case CONNECTION_DISCONNECTION: // 5 - return "连接断开".tr; - case CONNECTION_SUCCESS: // 6 - return "连接成功".tr; - case DOWNLOADING_UP_FILES: // 7 (新增) - return "下载升级文件".tr; - default: - return "未知状态".tr; // 更安全的默认值 - } - } - - // 添加更新方法 - Future startUpdate() async { - try { - checkUpdate(); - - // 实现更新逻辑... - } catch (e) { - errorMessage.value = e.toString(); - Get.snackbar( - '更新失败', - e.toString(), - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red[600], - colorText: Colors.white, - icon: const Icon( - Icons.error_outline, - color: Colors.white, - ), - ); - } + /// 初始化控制器 + /// 设置事件监听并加载版本信息 + Future _initializeController() async { + _listenToOtaEvents(); + await _loadVersionInfo(); } - Future _getCurrentVersion() async { - _listenToEvents(); + /// 加载版本信息 + /// 获取当前设备版本和最新版本信息,检查是否有更新 + Future _loadVersionInfo() async { try { isFetchingDeviceInfo.value = true; errorMessage.value = ''; - // 添加获取设备信息的重试机制 - DeviceInfo? deviceInfo = _bleManager.deviceInfo.value; - - // // 第一次检查设备信息 - // if (deviceInfo == null) { - // // 尝试重新获取设备信息 - // await _bleManager.fetchDeviceInfo(); - // } - - while (currentRetryCount < maxRetryAttempts) { - deviceInfo = _bleManager.deviceInfo.value; - - // 检查信息有效性 - if (deviceInfo != null && - deviceInfo.firmwareVersion.isNotEmpty && - deviceInfo.firmwareProductId.isNotEmpty) { - break; - } - - // 获取设备信息 - await _bleManager.fetchDeviceInfo(); - await Future.delayed(const Duration(seconds: 5)); - currentRetryCount++; - } - // 最终验证 - if (deviceInfo == null || - deviceInfo.firmwareVersion.isEmpty || - deviceInfo.firmwareProductId.isEmpty) { - throw "获取设备信息失败,请检查连接"; - } - // 拆分 firmwareVersion 并判断两边是否一致 - final firmwareParts = deviceInfo.firmwareVersion.split('.'); - - // 从第三个 `.` 开始分割 - final firstPart = firmwareParts.sublist(0, 3).join('.'); - final secondPart = firmwareParts.sublist(3).join('.'); - Logger().d("firstPart${firstPart},secondPart=${secondPart}"); - // 判断两部分是否一致 - if (firstPart != secondPart) { - throw "固件版本格式错误:${deviceInfo.firmwareVersion}"; - } - // 正常流程 - currentVersion.value = firstPart; - Logger().d('getequipmentinfo,id: ${deviceInfo.firmwareProductId}'); + // 获取当前版本信息 + final currentInfo = await _versionManager.getCurrentVersionInfo(); + currentVersion.value = currentInfo['currentVersion']; + // 获取蓝牙地址 final connectedDevices = await ClassicBluetooth.getConnectedDevices(); if (connectedDevices.isNotEmpty) { _address = connectedDevices.first.address; } - DBProduct? product = AppConfig.getproduct("DeapSound"); - - // // API请求 - // final response = await Api.getproduct({ - // "id": deviceInfo.firmwareProductId, - // }); - - // // 处理API响应 - // if (response['product'] == null) { - // throw const FormatException('API 响应格式错误'); - // } - - // final resp = OtaConfigResp.fromJson(response); - // Logger().d('getequipmentinfo,resp.systemversion: ${resp.systemversion}'); - latestVersion.value = product?.version ?? ""; - // updatedescription.value = resp.updatedescription!; - updatepackageaddress = product!.updatepackageaddress!; + // 获取最新版本信息 + final latestInfo = await _versionManager.getLatestVersionInfo(); + latestVersion.value = latestInfo['latestVersion']; + _updatePackageAddress = latestInfo['updatePackageAddress']; - // 版本检查 - bool hasUpdate = _isNewVersionAvailable(product.version!); + // 设置更新描述信息 + updatedescription.value = + latestInfo['updateDescription'] ?? '本次更新包含性能优化和错误修复。'; - isUpdateAvailable.value = hasUpdate; - } catch (e) { - // 统一错误处理 - errorMessage.value = e.toString(); - Get.snackbar( - '错误', - e.toString(), - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red[600], - colorText: Colors.white, - icon: const Icon( - Icons.error_outline, - color: Colors.white, - ), + // 检查是否有更新 + isUpdateAvailable.value = _versionManager.isNewVersionAvailable( + currentVersion.value, + latestVersion.value, ); - // 重要:重新抛出异常以便上层处理(可选) + } catch (e) { + _handleError('获取版本信息失败', e.toString()); } finally { isFetchingDeviceInfo.value = false; } } - // 版本比较方法优化 - 支持升级和降级 - bool _isNewVersionAvailable(String latestVersion) { - try { - List currentParts = currentVersion.value - .split('.') - .map((e) => int.parse(e.trim())) - .toList(); - List latestParts = - latestVersion.split('.').map((e) => int.parse(e.trim())).toList(); - - // 确保两个版本号长度一致,短的用0补齐 - int maxLength = currentParts.length > latestParts.length - ? currentParts.length - : latestParts.length; - - while (currentParts.length < maxLength) { - currentParts.add(0); - } - while (latestParts.length < maxLength) { - latestParts.add(0); - } - - // 比较每一段,只要有不同就返回true(支持升级和降级) - for (int i = 0; i < maxLength; i++) { - if (latestParts[i] != currentParts[i]) { - return true; // 版本不同,允许更新(升级或降级) - } - } - - return false; // 版本完全相同,无需更新 - } catch (e) { - Logger().d('版本比较错误: $e'); - return false; - } - } - - // 版本更新 + /// 检查更新 + /// 显示更新确认对话框或错误提示 Future checkUpdate() async { try { - if (latestVersion.value != "") { - _showUpdateDialog(latestVersion.value); + if (latestVersion.value.isNotEmpty) { + _uiManager.showUpdateDialog(latestVersion.value, _startUpdateProcess); } else { - Get.snackbar( - '错误', - '获取最新版本信息失败', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[800], - colorText: Colors.white, - icon: const Icon( - Icons.error_outline, - color: Colors.white, - ), - ); + _uiManager.showErrorSnackbar('错误', '获取最新版本信息失败', + backgroundColor: Colors.orange[800]); } } catch (e) { - Get.snackbar( - '错误', - '获取版本信息失败', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[800], - colorText: Colors.white, - icon: const Icon( - Icons.error_outline, - color: Colors.white, - ), - ); + _uiManager.showErrorSnackbar('错误', '获取版本信息失败', + backgroundColor: Colors.orange[800]); } } - void _showUpdateDialog(String latest) async { - Logger().d('显示更新对话框'); - Get.dialog( - AlertDialog( - title: Text('发现新版本 $latest'), - content: Text('是否立即更新?'), - actions: [ - TextButton( - onPressed: () async { - Logger().d('取消按钮被点击'); - - Get.back(); - Future.delayed(Duration(milliseconds: 300)); - }, - child: Text('取消'), - ), - TextButton( - onPressed: () async { - Logger().d('更新按钮被点击'); - - Get.back(); - Future.delayed(Duration(milliseconds: 300)); - - // 检查设备电量 - if (!_checkBatteryLevel()) { - return; // 电量不足,不执行升级 - } - - startDownload(); - }, - child: Text('更新'), - ), - ], - ), - ); + /// 开始更新流程的公共方法 + /// 供视图调用的更新入口点 + Future startUpdate() async { + await _startUpdateProcess(); } -// 修改后的startDownload方法 - Future startDownload() async { - await _bleManager.setEnterOta(true); - await Future.delayed(const Duration(seconds: 2)); - jlota.init(); - - isDownloading.value = true; - showDetailedStatus.value = true; - detailedStatus.value = '准备下载固件包...'; - - int lastIndex = updatepackageaddress.lastIndexOf('/'); - String fileName = updatepackageaddress.substring(lastIndex + 1); - String directoryName = "ota_upgrades"; + /// 检查设备状态后开始下载和升级 + Future _startUpdateProcess() async { + // 检查设备电量 + if (!_deviceChecker.checkBatteryLevel()) { + return; + } - final dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 5), - receiveTimeout: Duration.zero, - sendTimeout: const Duration(seconds: 5), - headers: {'Accept-Encoding': 'gzip'}, - )); + await _startDownload(); + } - type.value = DOWNLOADING_UP_FILES; + /// 开始下载固件 + /// 初始化OTA环境并下载固件包 + Future _startDownload() async { try { - detailedStatus.value = '正在获取下载目录...'; - final Directory? downloadsDir = await getApplicationSupportDirectory(); - if (downloadsDir == null) { - throw FileSystemException('无法访问下载目录'); - } - - final targetDir = - Directory('${downloadsDir.path}/Download/$directoryName'); - if (!await targetDir.exists()) { - detailedStatus.value = '创建下载目录...'; - await targetDir.create(recursive: true); - } - - final savePath = '${targetDir.path}/$fileName'; - - // 新增:检查同名文件逻辑 - detailedStatus.value = '检查本地文件...'; - bool existingSameFile = false; - try { - var files = await targetDir.list().toList(); - for (var entity in files) { - if (await FileSystemEntity.isFile(entity.path)) { - String name = basename(entity.path); // 使用path包中的basename - if (name == fileName) { - existingSameFile = true; - break; - } - } - } - } catch (e) { - Get.snackbar( - '错误', - '检查本地文件失败: $e', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red[600], - colorText: Colors.white, - icon: Icon( - Icons.error_outline, - color: Colors.white, - ), - ); - - return; - } - - if (existingSameFile) { - //Get.snackbar('文件已存在', '跳过下载,直接更新'); - detailedStatus.value = '文件已存在,准备升级...'; - startUpgrading(savePath); - return; - } - - // 新增:删除其他文件逻辑 - detailedStatus.value = '清理旧文件...'; - try { - var files = await targetDir.list().toList(); - for (var entity in files) { - if (await FileSystemEntity.isFile(entity.path)) { - await entity.delete(); - } - } - } catch (e) { - Get.snackbar( - '错误', - '删除旧文件失败: $e', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red[600], - colorText: Colors.white, - icon: Icon( - Icons.error_outline, - color: Colors.white, - ), - ); - //isUpgrade.value = false; - return; - } - - Logger().d('文件保存路径: $savePath'); - - detailedStatus.value = '开始下载固件包...'; - final options = Options( - receiveTimeout: Duration.zero, - followRedirects: true, - maxRedirects: 5, - ); - // 修改下载部分 - await dio.download( - updatepackageaddress, - savePath, - options: options, - onReceiveProgress: (received, total) { - if (total != -1) { - downloadProgress.value = received / total; - detailedStatus.value = - '正在下载固件包... ${(received / total * 100).toStringAsFixed(1)}%'; - } + await _bleManager.setEnterOta(true); + await Future.delayed(const Duration(seconds: 2)); + _jlota.init(); + + isDownloading.value = true; + showDetailedStatus.value = true; + type.value = OtaStatusManager.DOWNLOADING_UP_FILES; + + final filePath = await _downloadManager.downloadFirmware( + downloadUrl: _updatePackageAddress, + onProgress: (progress) { + downloadProgress.value = progress; + }, + onStatusUpdate: (status) { + detailedStatus.value = status; }, ); - File file = File(savePath); - if (await file.exists()) { - // Get.snackbar('下载完成', '文件已保存至: $savePath'); - detailedStatus.value = '下载完成,准备升级...'; - } else { - Get.snackbar( - '错误', - '文件未找到,请检查写入权限', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[600], - colorText: Colors.white, - icon: Icon( - Icons.error_outline, - color: Colors.white, - ), - ); - detailedStatus.value = '下载失败'; - showDetailedStatus.value = false; - return; - } + isDownloading.value = false; - startUpgrading(savePath); - return; - } on DioException catch (e) { - Get.snackbar( - '下载失败', - '错误: ${e.message}', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[600], - colorText: Colors.white, - icon: Icon( - Icons.error_outline, - color: Colors.white, - ), - ); - } on FileSystemException catch (e) { - Get.snackbar( - '存储失败', - '无法写入文件: ${e.message}', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[600], - colorText: Colors.white, - icon: Icon( - Icons.error_outline, - color: Colors.white, - ), - ); + await _startUpgrading(filePath); } catch (e) { isDownloading.value = false; detailedStatus.value = '下载失败,请检查网络连接'; showDetailedStatus.value = false; - Get.snackbar( - '下载失败', - '请检查网络连接: $e', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[600], - colorText: Colors.white, - icon: Icon( - Icons.error_outline, - color: Colors.white, - ), - ); + _handleError('下载失败', e.toString()); Logger().d('下载失败: $e'); } } - // 开始升级 - void startUpgrading(String path) async { + /// 开始升级 + /// [filePath] 固件文件路径 + /// 启动OTA升级过程 + Future _startUpgrading(String filePath) async { detailedStatus.value = '准备设备连接...'; await Future.delayed(const Duration(milliseconds: 500)); detailedStatus.value = '正在连接设备...'; - jlota.startUpgrading(address: _address, filePath: path); - return; - // isUpgrade.value = false; + _jlota.startUpgrading(address: _address, filePath: filePath); } - // 停止升级 - void stopUpgrading() async { - jlota.onOtaClose(); - return; - // isUpgrade.value = false; + /// 停止升级 + /// 关闭OTA连接 + void stopUpgrading() { + _jlota.onOtaClose(); } - // 检查设备电量是否满足升级条件 - bool _checkBatteryLevel() { - try { - final deviceInfo = _bleManager.deviceInfo.value; - - if (deviceInfo == null) { - Get.snackbar( - '电量检查失败', - '无法获取设备信息,请检查设备连接', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[600], - colorText: Colors.white, - icon: Icon( - Icons.warning_amber, - color: Colors.white, - ), - ); - return false; - } + /// 重试连接 + /// 清除错误信息并重新加载版本信息 + void retryConnection() { + errorMessage.value = ''; + _loadVersionInfo(); + } - final leftBattery = deviceInfo.leftEarBattery; - final rightBattery = deviceInfo.rightEarBattery; - - // 检查左右耳电量是否都大于30% - if (leftBattery <= 30 || rightBattery <= 30) { - String message = '设备电量不足,无法进行固件升级\n'; - message += '升级要求:左右耳电量均需大于30%\n'; - message += '当前电量:左耳 ${leftBattery}%,右耳 ${rightBattery}%\n'; - message += '请充电后再试'; - - Get.dialog( - AlertDialog( - title: Text( - '电量不足', - style: TextStyle( - color: Colors.orange[700], - fontWeight: FontWeight.w600, - ), - ), - content: Text( - message, - style: TextStyle( - color: Colors.orange[600], - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - style: TextButton.styleFrom( - foregroundColor: Colors.orange[700], - ), - child: const Text('确定'), - ), - ], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: Colors.orange.withValues(alpha: 0.3), - width: 1, - ), - ), - backgroundColor: Colors.orange[50], - ), - ); - return false; - } + /// 获取状态描述 + /// [type] 状态类型 + /// 返回对应的状态描述文本 + String getStatusMessage(int type) { + return OtaStatusManager.getStatusMessage(type); + } - return true; - } catch (e) { - Get.snackbar( - '电量检查失败', - '检查设备电量时发生错误:$e', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.orange[600], - colorText: Colors.white, - icon: const Icon( - Icons.warning_amber, - color: Colors.white, - ), - ); - return false; - } + /// 监听OTA事件 + /// 设置OTA事件流的监听器 + void _listenToOtaEvents() { + _jlota.eventStream.listen((event) { + _handleOtaEvent(event); + }); } - 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; // 开始升级 - detailedStatus.value = - '升级进度: ${(event.progress! * 100).toStringAsFixed(1)}%'; - } - if (event.event == 'onStartOTA') { - type.value = START_UPGRADING; - isUpgrade.value = true; // 开始升级 + /// 处理OTA事件 + /// [event] OTA事件对象 + /// 根据不同的事件类型执行相应的处理逻辑 + void _handleOtaEvent(dynamic event) { + switch (event.event) { + case 'onProgress': + if (event.type != null && event.progress != null) { + progress.value = event.progress!; + type.value = event.type!; + isUpgrade.value = true; + detailedStatus.value = + '升级进度: ${(event.progress! * 100).toStringAsFixed(1)}%'; + } + break; + case 'onStartOTA': + type.value = OtaStatusManager.START_UPGRADING; + isUpgrade.value = true; detailedStatus.value = 'OTA升级已开始...'; - } - if (event.event == 'onStopOTA') { - type.value = UPGRADE_SUCCESS; - isUpgrade.value = false; - detailedStatus.value = '升级成功完成'; - showDetailedStatus.value = false; - - // 升级成功,更新版本信息显示 - currentVersion.value = latestVersion.value; - isUpdateAvailable.value = false; - - // 显示升级成功对话框,引导用户重新配对 - Get.dialog( - AlertDialog( - title: Row( - children: [ - Icon( - Icons.check_circle, - color: Colors.green[600], - size: 28, - ), - SizedBox(width: 12.w), - Text( - '升级成功', - style: TextStyle( - color: Colors.green[700], - fontWeight: FontWeight.w600, - ), - ), - ], - ), - content: Text( - '固件升级已完成!\n\n设备需要重新配对才能正常使用,现在跳转到配对页面吗?', - style: TextStyle( - fontSize: 16, - height: 1.4, - ), - ), - actions: [ - TextButton( - onPressed: () { - Get.back(); // 关闭对话框 - }, - child: Text( - '稍后配对', - style: TextStyle(color: Colors.grey[600]), - ), - ), - ElevatedButton( - onPressed: () { - Get.back(); // 关闭对话框 - Get.toNamed(Routes.pairing); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green[600], - foregroundColor: Colors.white, - ), - child: Text('立即配对'), - ), - ], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - barrierDismissible: false, // 防止用户意外关闭 - ); - } - if (event.event == 'onError') { - type.value = UPGRADE_FAILED; - isUpgrade.value = false; - detailedStatus.value = '升级失败,错误码:${event.code}'; - showDetailedStatus.value = false; - Get.snackbar( - '升级失败', - '请检查设备连接或升级包,错误码:${event.code}', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red[600], - colorText: Colors.white, - icon: Icon( - Icons.error_outline, - color: Colors.white, - ), - ); - } - if (event.event == 'NOCONNECTION') { - type.value = CONNECTION_DISCONNECTION; - isUpgrade.value = false; - detailedStatus.value = '设备连接断开'; - showDetailedStatus.value = false; - } - if (event.event == 'CONNECTION_SUCCESS') { - type.value = CONNECTION_SUCCESS; - isUpgrade.value = true; // 开始升级 - detailedStatus.value = '设备连接成功'; - } - }); + break; + case 'onStopOTA': + _handleUpgradeSuccess(); + break; + case 'onError': + _handleUpgradeError(event.code); + break; + case 'NOCONNECTION': + _handleConnectionLost(); + break; + case 'CONNECTION_SUCCESS': + _handleConnectionSuccess(); + break; + } + } + + /// 处理升级成功 + /// 更新状态并显示成功对话框 + void _handleUpgradeSuccess() { + type.value = OtaStatusManager.UPGRADE_SUCCESS; + isUpgrade.value = false; + detailedStatus.value = '升级成功完成'; + showDetailedStatus.value = false; + + // 更新版本信息 + currentVersion.value = latestVersion.value; + isUpdateAvailable.value = false; + + // 显示成功对话框 + _uiManager.showUpgradeSuccessDialog(); + } + + /// 处理升级错误 + /// [errorCode] 错误代码 + /// 更新状态并显示错误提示 + void _handleUpgradeError(dynamic errorCode) { + type.value = OtaStatusManager.UPGRADE_FAILED; + isUpgrade.value = false; + detailedStatus.value = '升级失败,错误码:$errorCode'; + showDetailedStatus.value = false; + _uiManager.showErrorSnackbar( + '升级失败', + '请检查设备连接或升级包,错误码:$errorCode', + ); + } + + /// 处理连接断开 + /// 更新连接状态 + void _handleConnectionLost() { + type.value = OtaStatusManager.CONNECTION_DISCONNECTION; + isUpgrade.value = false; + detailedStatus.value = '设备连接断开'; + showDetailedStatus.value = false; + } + + /// 处理连接成功 + /// 更新连接状态 + void _handleConnectionSuccess() { + type.value = OtaStatusManager.CONNECTION_SUCCESS; + isUpgrade.value = true; + detailedStatus.value = '设备连接成功'; + } + + /// 统一错误处理 + /// [title] 错误标题 + /// [message] 错误信息 + /// 设置错误状态并显示错误提示 + void _handleError(String title, String message) { + errorMessage.value = message; + _uiManager.showErrorSnackbar(title, message); } } diff --git a/lib/modules/ota_upgrade/services/device_checker.dart b/lib/modules/ota_upgrade/services/device_checker.dart new file mode 100644 index 000000000..7b3fb2531 --- /dev/null +++ b/lib/modules/ota_upgrade/services/device_checker.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import '../../../data/services/ble_manager.dart'; + +/// 设备检查服务 +/// 负责检查设备状态,如电量检查等 +class DeviceChecker { + final BleManager _bleManager = Get.find(); + + /// 检查设备电量是否满足升级条件 + /// 返回true表示电量充足,可以进行升级 + /// 返回false表示电量不足,不能进行升级 + bool checkBatteryLevel() { + try { + final deviceInfo = _bleManager.deviceInfo.value; + + if (deviceInfo == null) { + _showBatteryCheckFailedSnackbar('无法获取设备信息,请检查设备连接'); + return false; + } + + final leftBattery = deviceInfo.leftEarBattery; + final rightBattery = deviceInfo.rightEarBattery; + + // 检查左右耳电量是否都大于30% + if (leftBattery <= 30 || rightBattery <= 30) { + _showLowBatteryDialog(leftBattery, rightBattery); + return false; + } + + return true; + } catch (e) { + _showBatteryCheckFailedSnackbar('检查设备电量时发生错误:$e'); + return false; + } + } + + /// 显示电量不足对话框 + /// [leftBattery] 左耳电量 + /// [rightBattery] 右耳电量 + void _showLowBatteryDialog(int leftBattery, int rightBattery) { + String message = '设备电量不足,无法进行固件升级\n'; + message += '升级要求:左右耳电量均需大于30%\n'; + message += '当前电量:左耳 ${leftBattery}%,右耳 ${rightBattery}%\n'; + message += '请充电后再试'; + + Get.dialog( + AlertDialog( + title: Text( + '电量不足', + style: TextStyle( + color: Colors.orange[700], + fontWeight: FontWeight.w600, + ), + ), + content: Text( + message, + style: TextStyle( + color: Colors.orange[600], + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + style: TextButton.styleFrom( + foregroundColor: Colors.orange[700], + ), + child: const Text('确定'), + ), + ], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Colors.orange.withValues(alpha: 0.3), + width: 1, + ), + ), + backgroundColor: Colors.orange[50], + ), + ); + } + + /// 显示电量检查失败提示 + /// [message] 错误信息 + void _showBatteryCheckFailedSnackbar(String message) { + Get.snackbar( + '电量检查失败', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange[600], + colorText: Colors.white, + icon: const Icon( + Icons.warning_amber, + color: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/modules/ota_upgrade/services/download_manager.dart b/lib/modules/ota_upgrade/services/download_manager.dart new file mode 100644 index 000000000..b98aeeaac --- /dev/null +++ b/lib/modules/ota_upgrade/services/download_manager.dart @@ -0,0 +1,153 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart'; +import 'package:logger/web.dart'; + +/// 文件下载管理器 +/// 负责处理固件包的下载、文件管理等功能 +class DownloadManager { + late final Dio _dio; + + /// 构造函数 + /// 初始化Dio实例并配置SSL证书处理 + DownloadManager() { + _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 30), // 增加连接超时时间 + receiveTimeout: Duration.zero, + sendTimeout: const Duration(seconds: 30), // 增加发送超时时间 + headers: {'Accept-Encoding': 'gzip'}, + )); + + // 配置SSL证书验证 + (_dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () { + final client = HttpClient(); + + // 处理SSL证书验证 + client.badCertificateCallback = + (X509Certificate cert, String host, int port) { + // 在生产环境中,应该进行适当的证书验证 + // 这里为了解决证书过期问题,暂时跳过验证 + // 建议:在生产环境中实现更安全的证书验证逻辑 + Logger().w('SSL证书验证跳过: $host:$port'); + return true; + }; + + return client; + }; + } + + /// 下载固件包 + /// [downloadUrl] 下载地址 + /// [onProgress] 下载进度回调 + /// [onStatusUpdate] 状态更新回调 + /// 返回下载文件的本地路径 + /// 抛出异常如果下载失败 + Future downloadFirmware({ + required String downloadUrl, + required Function(double) onProgress, + required Function(String) onStatusUpdate, + }) async { + onStatusUpdate('准备下载固件包...'); + + final fileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1); + const directoryName = "ota_upgrades"; + + // 获取下载目录 + onStatusUpdate('正在获取下载目录...'); + final Directory? downloadsDir = await getApplicationSupportDirectory(); + if (downloadsDir == null) { + throw FileSystemException('无法访问下载目录'); + } + + final targetDir = Directory('${downloadsDir.path}/Download/$directoryName'); + if (!await targetDir.exists()) { + onStatusUpdate('创建下载目录...'); + await targetDir.create(recursive: true); + } + + final savePath = '${targetDir.path}/$fileName'; + + // 检查是否已存在同名文件 + onStatusUpdate('检查本地文件...'); + if (await _checkExistingFile(targetDir, fileName)) { + onStatusUpdate('文件已存在,准备升级...'); + return savePath; + } + + // 清理旧文件 + onStatusUpdate('清理旧文件...'); + await _cleanOldFiles(targetDir); + + // 开始下载 + onStatusUpdate('开始下载固件包...'); + Logger().d('文件保存路径: $savePath'); + + final options = Options( + receiveTimeout: Duration.zero, + followRedirects: true, + maxRedirects: 5, + ); + + await _dio.download( + downloadUrl, + savePath, + options: options, + onReceiveProgress: (received, total) { + if (total != -1) { + final progress = received / total; + onProgress(progress); + onStatusUpdate('正在下载固件包... ${(progress * 100).toStringAsFixed(1)}%'); + } + }, + ); + + // 验证文件是否下载成功 + final file = File(savePath); + if (!await file.exists()) { + throw FileSystemException('文件下载失败,请检查写入权限'); + } + + onStatusUpdate('下载完成,准备升级...'); + return savePath; + } + + /// 检查是否存在同名文件 + /// [targetDir] 目标目录 + /// [fileName] 文件名 + /// 返回true表示文件已存在 + Future _checkExistingFile(Directory targetDir, String fileName) async { + try { + final files = await targetDir.list().toList(); + for (var entity in files) { + if (await FileSystemEntity.isFile(entity.path)) { + final name = basename(entity.path); + if (name == fileName) { + return true; + } + } + } + return false; + } catch (e) { + Logger().d('检查本地文件失败: $e'); + return false; + } + } + + /// 清理旧文件 + /// [targetDir] 目标目录 + /// 抛出异常如果清理失败 + Future _cleanOldFiles(Directory targetDir) async { + try { + final files = await targetDir.list().toList(); + for (var entity in files) { + if (await FileSystemEntity.isFile(entity.path)) { + await entity.delete(); + } + } + } catch (e) { + throw Exception('删除旧文件失败: $e'); + } + } +} diff --git a/lib/modules/ota_upgrade/services/ota_status_manager.dart b/lib/modules/ota_upgrade/services/ota_status_manager.dart new file mode 100644 index 000000000..38142633f --- /dev/null +++ b/lib/modules/ota_upgrade/services/ota_status_manager.dart @@ -0,0 +1,43 @@ +import 'package:get/get.dart'; + +/// OTA升级状态管理器 +/// 负责管理OTA升级过程中的各种状态常量和状态描述 +class OtaStatusManager { + static const int NOT_STARTED = -1; // 未开始 + static const int VALIDATION_FILE = 0; // 验证文件 + static const int ONUPGRADE = 1; // 升级中 + static const int START_UPGRADING = 2; // 开始升级 + static const int UPGRADE_SUCCESS = 3; // 升级成功 + static const int UPGRADE_FAILED = 4; // 升级失败 + static const int CONNECTION_DISCONNECTION = 5; // 连接断开 + static const int CONNECTION_SUCCESS = 6; // 连接成功 + static const int DOWNLOADING_UP_FILES = 7; // 下载升级文件 + + /// 获取状态描述信息 + /// [type] 状态类型 + /// 返回对应的状态描述文本 + static String getStatusMessage(int type) { + switch (type) { + case NOT_STARTED: + return "未开始".tr; + case VALIDATION_FILE: + return "验证文件中".tr; + case ONUPGRADE: + return "升级中".tr; + case START_UPGRADING: + return "开始升级".tr; + case UPGRADE_SUCCESS: + return "升级成功".tr; + case UPGRADE_FAILED: + return "升级失败".tr; + case CONNECTION_DISCONNECTION: + return "连接断开".tr; + case CONNECTION_SUCCESS: + return "连接成功".tr; + case DOWNLOADING_UP_FILES: + return "下载升级文件".tr; + default: + return "未知状态".tr; + } + } +} \ No newline at end of file diff --git a/lib/modules/ota_upgrade/services/ui_manager.dart b/lib/modules/ota_upgrade/services/ui_manager.dart new file mode 100644 index 000000000..a2f4288d4 --- /dev/null +++ b/lib/modules/ota_upgrade/services/ui_manager.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:logger/web.dart'; +import '/routes/app_routes.dart'; + +/// UI交互管理器 +/// 负责处理所有与用户界面交互相关的功能 +class UiManager { + /// 显示更新确认对话框 + /// [latestVersion] 最新版本号 + /// [onConfirm] 确认更新的回调函数 + void showUpdateDialog(String latestVersion, VoidCallback onConfirm) { + Logger().d('显示更新对话框'); + Get.dialog( + AlertDialog( + title: Text('发现新版本 $latestVersion'), + content: const Text('是否立即更新?'), + actions: [ + TextButton( + onPressed: () { + Logger().d('取消按钮被点击'); + Get.back(); + }, + child: const Text('取消'), + ), + TextButton( + onPressed: () { + Logger().d('更新按钮被点击'); + Get.back(); + Future.delayed(const Duration(milliseconds: 300)); + onConfirm(); + }, + child: const Text('更新'), + ), + ], + ), + ); + } + + /// 显示升级成功对话框 + /// 引导用户进行设备重新配对 + void showUpgradeSuccessDialog() { + Get.dialog( + AlertDialog( + title: Row( + children: [ + Icon( + Icons.check_circle, + color: Colors.green[600], + size: 28, + ), + SizedBox(width: 12.w), + Text( + '升级成功', + style: TextStyle( + color: Colors.green[700], + fontWeight: FontWeight.w600, + ), + ), + ], + ), + content: const Text( + '固件升级已完成!\n\n设备需要重新配对才能正常使用,现在跳转到配对页面吗?', + style: TextStyle( + fontSize: 16, + height: 1.4, + ), + ), + actions: [ + TextButton( + onPressed: () { + Get.back(); + }, + child: Text( + '稍后配对', + style: TextStyle(color: Colors.grey[600]), + ), + ), + ElevatedButton( + onPressed: () { + Get.back(); + Get.toNamed(Routes.pairing); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[600], + foregroundColor: Colors.white, + ), + child: const Text('立即配对'), + ), + ], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + barrierDismissible: false, + ); + } + + /// 显示错误提示 + /// [title] 错误标题 + /// [message] 错误信息 + /// [backgroundColor] 背景颜色,可选 + void showErrorSnackbar(String title, String message, {Color? backgroundColor}) { + Get.snackbar( + title, + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: backgroundColor ?? Colors.red[600], + colorText: Colors.white, + icon: const Icon( + Icons.error_outline, + color: Colors.white, + ), + ); + } +} \ No newline at end of file diff --git a/lib/modules/ota_upgrade/services/version_manager.dart b/lib/modules/ota_upgrade/services/version_manager.dart new file mode 100644 index 000000000..579da63c6 --- /dev/null +++ b/lib/modules/ota_upgrade/services/version_manager.dart @@ -0,0 +1,114 @@ +import 'package:logger/web.dart'; +import '../../../data/services/ble_manager.dart'; +import '../../../data/models/appconfig.dart'; +import '../../../data/models/appconfig_model.dart'; +import 'package:get/get.dart'; + +/// 版本管理服务 +/// 负责处理设备版本信息获取、版本比较等功能 +class VersionManager { + final BleManager _bleManager = Get.find(); + final int maxRetryAttempts = 3; + int currentRetryCount = 0; + + /// 获取当前设备版本信息 + /// 返回包含当前版本、产品ID和设备信息的Map + /// 抛出异常如果获取失败 + Future> getCurrentVersionInfo() async { + DeviceInfo? deviceInfo = _bleManager.deviceInfo.value; + + // 重试机制获取设备信息 + while (currentRetryCount < maxRetryAttempts) { + deviceInfo = _bleManager.deviceInfo.value; + + if (deviceInfo != null && + deviceInfo.firmwareVersion.isNotEmpty && + deviceInfo.firmwareProductId.isNotEmpty) { + break; + } + + await _bleManager.fetchDeviceInfo(); + await Future.delayed(const Duration(seconds: 5)); + currentRetryCount++; + } + + if (deviceInfo == null || + deviceInfo.firmwareVersion.isEmpty || + deviceInfo.firmwareProductId.isEmpty) { + throw "获取设备信息失败,请检查连接"; + } + + // 验证固件版本格式 + final firmwareParts = deviceInfo.firmwareVersion.split('.'); + final firstPart = firmwareParts.sublist(0, 3).join('.'); + final secondPart = firmwareParts.sublist(3).join('.'); + + Logger().d("firstPart: $firstPart, secondPart: $secondPart"); + + if (firstPart != secondPart) { + throw "固件版本格式错误:${deviceInfo.firmwareVersion}"; + } + + return { + 'currentVersion': firstPart, + 'productId': deviceInfo.firmwareProductId, + 'deviceInfo': deviceInfo, + }; + } + + /// 获取最新版本信息 + /// 返回包含最新版本、更新描述和更新包地址的Map + /// 抛出异常如果获取失败 + Future> getLatestVersionInfo() async { + DBProduct? product = AppConfig.getproduct("DeapSound"); + + if (product == null) { + throw "无法获取产品信息"; + } + + return { + 'latestVersion': product.version ?? "", + 'updateDescription': product.updatedescription ?? "", + 'updatePackageAddress': product.updatepackageaddress ?? "", + }; + } + + /// 比较版本号,判断是否有新版本可用 + /// [currentVersion] 当前版本号 + /// [latestVersion] 最新版本号 + /// 返回true表示有新版本可用(支持升级和降级) + bool isNewVersionAvailable(String currentVersion, String latestVersion) { + try { + List currentParts = currentVersion + .split('.') + .map((e) => int.parse(e.trim())) + .toList(); + List latestParts = + latestVersion.split('.').map((e) => int.parse(e.trim())).toList(); + + // 确保两个版本号长度一致,短的用0补齐 + int maxLength = currentParts.length > latestParts.length + ? currentParts.length + : latestParts.length; + + while (currentParts.length < maxLength) { + currentParts.add(0); + } + while (latestParts.length < maxLength) { + latestParts.add(0); + } + + // 比较每一段,只要有不同就返回true(支持升级和降级) + for (int i = 0; i < maxLength; i++) { + if (latestParts[i] != currentParts[i]) { + return true; + } + } + + return false; + } catch (e) { + Logger().d('版本比较错误: $e'); + return false; + } + } +} \ No newline at end of file From b3eed32e11f6f42bc141ad28e61303589589a3b1 Mon Sep 17 00:00:00 2001 From: fdp <1286779656@qq.com> Date: Thu, 14 Aug 2025 12:14:24 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=E6=8B=86=E5=88=86ios=E5=BD=95=E9=9F=B3?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=92=8C=E5=BE=AE=E8=BD=AFsdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting_record_controller.dart | 28 +- .../controllers/translation_controller.dart | 23 +- .../azure_speech/AzureAsrToAsr.kt | 343 +++++++++- .../ios/azure_speech/Package.swift | 5 +- .../Sources/azure_speech/AzureAsrHelper.swift | 644 +++--------------- .../azure_speech/AzureSpeechPlugin.swift | 54 +- .../{azure_speech => tools}/RecordFile.swift | 0 .../Sources/tools/SimpleAudioReceiver.swift | 534 +++++++++++++++ 8 files changed, 1024 insertions(+), 607 deletions(-) rename local_plugins/azure_speech/ios/azure_speech/Sources/{azure_speech => tools}/RecordFile.swift (100%) create mode 100644 local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 50736fe52..11177bb1b 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -78,7 +78,6 @@ class MeetingRecordController extends GetxController _initializeComponents(); _setupFromArguments(); generateWaveData(); - _reinitializeAsrService(); } @override @@ -87,18 +86,6 @@ class MeetingRecordController extends GetxController super.onClose(); } - // 重新初始化ASR服务以支持新的语言 - Future _reinitializeAsrService() async { - try { - // 重新初始化ASR服务 - await _asrService.initialize(); - - Logger.info('ASR服务重新初始化成功'); - } catch (e) { - Logger.error('重新初始化ASR服务失败: ${e.toString()}'); - } - } - // 请求录音权限 Future _requestRecordPermission() async { try { @@ -164,7 +151,6 @@ class MeetingRecordController extends GetxController _bleManager.closeCodec(); } _asrService.stopRecord(true); - _asrService.stopContinuousRecognition(); isRecording.value = false; } @@ -197,12 +183,6 @@ class MeetingRecordController extends GetxController /// Toggles visibility of transcribed text content Future toggleTextDisplay() async { - final recognitionStream = await _asrService.recognizeCallback(); - _recognitionSubscription?.cancel(); - _recognitionSubscription = - recognitionStream.listen(_handleRecognitionEvent); - - await _asrService.startContinuousRecognition(_audioSourceType); showTextContent.toggle(); } @@ -242,10 +222,6 @@ class MeetingRecordController extends GetxController /// Starts recording session Future _startRecording() async { - // Initialize speech recognition - final recognitionStream = - await _asrService.startContinuousRecognition(_audioSourceType); - // Prepare storage directory final appDir = await appDocDir; final dir = Directory("${appDir.path}/MeetingAudio/"); @@ -256,7 +232,7 @@ class MeetingRecordController extends GetxController final fullFileName = "${fileName.value}_$formattedTime"; // Start audio recording - await _asrService.enableRecord("${dir.path}/$fullFileName.wav", + await _asrService.enableRecord("${dir.path}$fullFileName.wav", acceptAudioData: true); // 监听音频数据流 @@ -302,7 +278,6 @@ class MeetingRecordController extends GetxController /// Pauses current recording session Future _pauseRecording() async { - await _asrService.stopContinuousRecognition(); await _asrService.pauseRecord(); pauseTimer(); } @@ -475,7 +450,6 @@ class MeetingRecordController extends GetxController } } - await _asrService.stopContinuousRecognition(); _resetToDefaultState(); isRecording.value = false; } diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 52a2ad2c2..2625b8b0b 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -587,6 +587,7 @@ class TranslationController extends GetxController { scrollController.dispose(); restoreOtherServices(); _asrService.dispose(); + _astService.dispose(); super.onClose(); } @@ -753,7 +754,9 @@ class TranslationController extends GetxController { try { await stopRecording(); await _asrService.stopContinuousRecognition(); - await _astService.stopContinuousTranslation(); + if (currentMode.value == "call") { + await _astService.stopContinuousTranslation(); + } // 停止ASR活跃时长计时 _stopAsrActiveTracking(); @@ -1214,23 +1217,19 @@ class TranslationController extends GetxController { await _asrService.initialize( supportedLanguages: asrSupportedLanguages, ); -// Future initialize({ -// required String subscriptionKey, -// required String region, -// required List supportedLanguages, -// required String audioSourceType, -// required String translationAccessKey, -// required String translationSecretKey, -// String translationRegion = 'cn-north-1', -// }); - // 重新初始化ASR服务以支持通话音频源 - await _astService.initialize(supportedLanguages: asrSupportedLanguages); + // 获取识别事件流 - 这启动了异步识别过程 var recognitionStream = await _asrService.recognizeCallback(); // 监听识别结果 _recognitionSubscription?.cancel(); _recognitionSubscription = recognitionStream.listen(_handleRecognitionEvent); + + //判断是否进入通话 + if (currentMode.value == "call") { + // 重新初始化ASR服务以支持通话音频源 + _initializeCallModeTranslationService(); + } Logger.info('ASR服务重新初始化成功'); } catch (e) { Logger.error('重新初始化ASR服务失败: ${e.toString()}'); diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt index 38c374238..ac79b478a 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt @@ -735,6 +735,107 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { eventCallback?.onStateChanged("AudioPlayback", enabled) } + /** + * 动态修改语音识别语言 + * @param newLanguage 新的识别语言代码,如 "zh-CN", "en-US", "ja-JP" 等 + * @param restartRecognition 是否重启识别服务,默认为true + */ + fun changeRecognitionLanguage(newLanguage: String, restartRecognition: Boolean = true) { + try { + val wasRecognizing = serviceState.isRecognizing.get() + + // 如果正在识别,先停止 + if (wasRecognizing && restartRecognition) { + stopContinuousTranslation() + } + + // 更新配置 + serviceConfig.sourceLanguage = newLanguage + speechConfig?.speechRecognitionLanguage = newLanguage + + // 重新设置识别器 + setupSpeechRecognizer() + + Log.d(TAG, "识别语言已更改为: $newLanguage") + eventCallback?.onStateChanged("LanguageChanged", true) + + // 如果之前在识别且需要重启,则重新开始 + if (wasRecognizing && restartRecognition) { + startContinuousTranslation() + } + + } catch (e: Exception) { + Log.e(TAG, "更改识别语言失败", e) + eventCallback?.onError("LanguageChange", "更改识别语言失败: ${e.message}") + } + } + + /** + * 批量设置多语言识别 + * @param languages 支持的语言列表 + * @param enableAutoDetection 是否启用自动语言检测 + */ + fun setupMultiLanguageRecognition(languages: List, enableAutoDetection: Boolean = true) { + try { + serviceConfig.enableAutoLanguageDetection = enableAutoDetection + + if (enableAutoDetection && languages.isNotEmpty()) { + // 设置自动语言检测的候选语言 + speechConfig?.setProperty("SpeechServiceConnection_LanguageIdMode", "Continuous") + + // 构建语言候选列表 + val languageList = languages.joinToString(",") + speechConfig?.setProperty("SpeechServiceConnection_ContinuousLanguageIdPriority", languageList) + + Log.d(TAG, "多语言识别已设置,支持语言: $languageList") + } + + // 重新设置识别器 + setupSpeechRecognizer() + + } catch (e: Exception) { + Log.e(TAG, "设置多语言识别失败", e) + eventCallback?.onError("MultiLanguageSetup", "设置多语言识别失败: ${e.message}") + } + } + + /** + * 获取当前支持的语言列表 + */ + fun getSupportedLanguages(): List { + return listOf( + "zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "ko-KR", + "fr-FR", "de-DE", "es-ES", "ru-RU", "ar-SA", "pt-BR", + "it-IT", "th-TH", "vi-VN", "hi-IN" + ) + } + + /** + * 设置识别参数 + * @param endSilenceTimeout 结束静音超时时间(毫秒) + * @param segmentationTimeout 分段静音超时时间(毫秒) + * @param initialSilenceTimeout 初始静音超时时间(毫秒) + */ + fun setRecognitionParameters( + endSilenceTimeout: Int = 300, + segmentationTimeout: Int = 300, + initialSilenceTimeout: Int = 200 + ) { + try { + speechConfig?.apply { + setProperty("SpeechServiceConnection_EndSilenceTimeoutMs", endSilenceTimeout.toString()) + setProperty("Speech_SegmentationSilenceTimeoutMs", segmentationTimeout.toString()) + setProperty("SpeechServiceConnection_InitialSilenceTimeoutMs", initialSilenceTimeout.toString()) + } + + Log.d(TAG, "识别参数已更新: 结束静音=${endSilenceTimeout}ms, 分段静音=${segmentationTimeout}ms, 初始静音=${initialSilenceTimeout}ms") + + } catch (e: Exception) { + Log.e(TAG, "设置识别参数失败", e) + eventCallback?.onError("ParameterSetup", "设置识别参数失败: ${e.message}") + } + } + /** * 获取当前音频播放状态 * @return true表示启用音频播放,false表示禁用 @@ -743,6 +844,54 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { return serviceConfig.enableAudioPlayback } + /** + * 暂停服务(保留资源) + */ + fun pause() { + try { + if (serviceState.isRecognizing.get()) { + recognizer?.stopContinuousRecognitionAsync() + } + audioProcessor?.stopRecording() + Log.d(TAG, "服务已暂停") + } catch (e: Exception) { + Log.e(TAG, "暂停服务失败", e) + } + } + + /** + * 恢复服务 + */ + fun resume() { + try { + if (serviceState.isInitialized.get() && serviceConfig.enableContinuousRecognition) { + startContinuousTranslation() + Log.d(TAG, "服务已恢复") + } + } catch (e: Exception) { + Log.e(TAG, "恢复服务失败", e) + } + } + + /** + * 检查服务健康状态 + */ + fun checkServiceHealth(): Map { + return mapOf( + "initialized" to serviceState.isInitialized.get(), + "recognizing" to serviceState.isRecognizing.get(), + "translating" to serviceState.isTranslating.get(), + "synthesizing" to serviceState.isSynthesizing.get(), + "speechConfig" to (speechConfig != null), + "recognizer" to (recognizer != null), + "synthesizer" to (synthesizer != null), + "audioProcessor" to (audioProcessor != null), + "translationService" to (translationService != null), + "currentLanguage" to serviceConfig.sourceLanguage, + "targetLanguage" to serviceConfig.targetLanguage + ) + } + /** * 单独的语音合成方法,只合成不播放,返回音频数据 * @param text 要合成的文本 @@ -801,37 +950,125 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { } /** - * 释放资源 + * 完善的资源释放方法 */ fun dispose() { try { - // 停止所有活动 - launch { - stopContinuousTranslation() + Log.d(TAG, "开始释放服务资源...") + + // 1. 设置释放标志,防止新的操作 + serviceState.isInitialized.set(false) + + // 2. 停止所有活动(使用runBlocking确保同步完成) + runBlocking { + try { + withTimeout(5000) { // 5秒超时 + stopContinuousTranslation() + } + } catch (e: TimeoutCancellationException) { + Log.w(TAG, "停止识别超时,强制继续释放资源") + } } - - // 释放Azure资源 - recognizer?.close() - synthesizer?.close() - speechConfig?.close() - - // 释放音频处理器 - audioProcessor?.dispose() - - // 释放翻译服务 - translationService?.dispose() - - // 取消协程 - job.cancel() - - // 清理缓存 + + // 3. 等待当前处理完成(带超时) + val maxWaitTime = 3000L // 3秒 + val startTime = System.currentTimeMillis() + while ((serviceState.isTranslating.get() || serviceState.isSynthesizing.get() || serviceState.isRecognizing.get()) + && (System.currentTimeMillis() - startTime) < maxWaitTime) { + Thread.sleep(100) + } + + // 4. 强制停止识别器 + try { + recognizer?.stopContinuousRecognitionAsync()?.get(2, TimeUnit.SECONDS) + } catch (e: Exception) { + Log.w(TAG, "停止识别器超时,强制关闭") + } + + // 5. 释放Azure资源(按顺序释放) + try { + recognizer?.close() + recognizer = null + Log.d(TAG, "语音识别器已释放") + } catch (e: Exception) { + Log.e(TAG, "释放识别器失败", e) + } + + try { + synthesizer?.close() + synthesizer = null + Log.d(TAG, "语音合成器已释放") + } catch (e: Exception) { + Log.e(TAG, "释放合成器失败", e) + } + + try { + audioConfig?.close() + audioConfig = null + Log.d(TAG, "音频配置已释放") + } catch (e: Exception) { + Log.e(TAG, "释放音频配置失败", e) + } + + try { + speechConfig?.close() + speechConfig = null + Log.d(TAG, "语音配置已释放") + } catch (e: Exception) { + Log.e(TAG, "释放语音配置失败", e) + } + + // 6. 释放音频处理器 + try { + audioProcessor?.dispose() + audioProcessor = null + Log.d(TAG, "音频处理器已释放") + } catch (e: Exception) { + Log.e(TAG, "释放音频处理器失败", e) + } + + // 7. 释放录音文件 + try { + recordfile1?.closeFile(true) + recordfile1 = null + Log.d(TAG, "录音文件已释放") + } catch (e: Exception) { + Log.e(TAG, "释放录音文件失败", e) + } + + // 8. 释放翻译服务 + try { + translationService?.dispose() + translationService = null + Log.d(TAG, "翻译服务已释放") + } catch (e: Exception) { + Log.e(TAG, "释放翻译服务失败", e) + } + + // 9. 取消协程(使用cancelAndJoin确保完全停止) + try { + runBlocking { + job.cancelAndJoin() + } + Log.d(TAG, "协程已取消") + } catch (e: Exception) { + Log.e(TAG, "取消协程失败", e) + } + + // 10. 清理缓存和回调 voiceCache.clear() - - serviceState.isInitialized.set(false) - - Log.d(TAG, "服务资源已释放") + eventCallback = null + filePath = null + + // 11. 重置状态 + serviceState.isRecognizing.set(false) + serviceState.isSynthesizing.set(false) + serviceState.isTranslating.set(false) + + Log.d(TAG, "服务资源释放完成") + } catch (e: Exception) { - Log.e(TAG, "释放资源失败", e) + Log.e(TAG, "释放资源过程中发生异常", e) } } @@ -895,11 +1132,59 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { } } + /** + * 改进的资源释放方法 + */ fun dispose() { - isRunning.set(false) - processingThread?.interrupt() - pushAudioStream?.close() - audioQueue.clear() + try { + Log.d(TAG, "开始释放音频处理器资源...") + + // 1. 停止运行标志 + isRunning.set(false) + + // 2. 中断处理线程 + processingThread?.let { thread -> + if (thread.isAlive) { + thread.interrupt() + try { + // 等待线程结束,最多等待2秒 + thread.join(2000) + if (thread.isAlive) { + Log.w(TAG, "音频处理线程未能正常结束") + } else { + Log.d(TAG, "音频处理线程已结束") + } + } catch (e: InterruptedException) { + Log.w(TAG, "等待音频处理线程结束时被中断") + Thread.currentThread().interrupt() + } + } + } + processingThread = null + + // 3. 关闭音频流 + try { + pushAudioStream?.close() + pushAudioStream = null + Log.d(TAG, "音频流已关闭") + } catch (e: Exception) { + Log.e(TAG, "关闭音频流失败", e) + } + + // 4. 清理音频队列 + try { + val remainingData = audioQueue.size + audioQueue.clear() + Log.d(TAG, "音频队列已清理,丢弃 $remainingData 个数据包") + } catch (e: Exception) { + Log.e(TAG, "清理音频队列失败", e) + } + + Log.d(TAG, "音频处理器资源释放完成") + + } catch (e: Exception) { + Log.e(TAG, "释放音频处理器资源时发生异常", e) + } } } } diff --git a/local_plugins/azure_speech/ios/azure_speech/Package.swift b/local_plugins/azure_speech/ios/azure_speech/Package.swift index 76d294d9c..17ed7498e 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Package.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Package.swift @@ -18,7 +18,8 @@ let package = Package( .product(name: "speech", package: "speech"), "MicrosoftCognitiveServicesSpeech" ], - path: "Sources/azure_speech" + path: "Sources", // 修改为包含整个 Sources 目录 + sources: ["azure_speech/", "tools/"] // 明确指定包含的子目录 ), // 使用binaryTarget引用Azure Speech SDK的.xcframework .binaryTarget( @@ -26,4 +27,4 @@ let package = Package( path: "MicrosoftCognitiveServicesSpeech.xcframework" ) ] -) \ No newline at end of file +) \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index 8e3e6a85e..fa5c166eb 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -1,7 +1,6 @@ import Foundation import AVFoundation import MicrosoftCognitiveServicesSpeech -// 自定义语音处理组件,提供音频流处理等功能 import speech import os.log @@ -32,7 +31,6 @@ public class AzureAsrHelper: NSObject { private var subscriptionKey = "" private var region = "" - // 音频源配置 public enum AudioSourceType { /** 使用设备麦克风 */ @@ -48,9 +46,8 @@ public class AzureAsrHelper: NSObject { // private var externalAudioStream: ExternalAudioPullStream? // 音频处理 - public var audioStream: AudioStream? - //录音文件 - public var recordfile: RecordFile? + public var audioStream: SimpleAudioReceiver? + /** * 初始化Azure语音服务 * @@ -110,11 +107,6 @@ public class AzureAsrHelper: NSObject { os_log("固定语言模式,当前语言: %{public}@", log: log, type: .info, currentLanguage) } - - - // 录音文件类 - recordfile = RecordFile() - // 预初始化音频组件 preInitializeAudioComponents() @@ -131,18 +123,16 @@ public class AzureAsrHelper: NSObject { */ private func preInitializeAudioComponents() { if audioStream == nil { - audioStream = AudioStream(parentHelper: self) + audioStream = SimpleAudioReceiver(parentHelper: self) audioStream?.initAudioRecord() - audioStream?.onAudioData = { [weak self] data in - self?.recordfile?.saveAudioDataToWav(data) - } - + } + // 预创建音频配置 if let pushStream = audioStream?.pushAudioStream { audioConfig = SPXAudioConfiguration(streamInput: pushStream) os_log("预初始化音频组件完成", log: log, type: .info) } - } + } @@ -154,7 +144,8 @@ public class AzureAsrHelper: NSObject { * @return 是否成功开始识别 */ public func startContinuousRecognition( - audioSourceType: AudioSourceType = .microphone + audioSourceType: AudioSourceType = .microphone, + audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil ) -> Bool { guard speechConfig != nil else { print("语音服务未初始化") @@ -180,7 +171,7 @@ public class AzureAsrHelper: NSObject { return false } // 启动音频处理 - audioStream?.startAudioInput(audioSourceType: audioSourceType) + audioStream?.startAudioRecord(audioSourceType: audioSourceType == .microphone ? .microphone : .external, audioDataCallback: audioDataCallback) // 开始连续识别 try recognizer?.startContinuousRecognition() @@ -190,7 +181,7 @@ public class AzureAsrHelper: NSObject { return true } catch { - audioStream?.stopAudioCapture() + audioStream?.stopMicrophoneCapture() _isContinuousRecognitionActive = false //callback.onError("启动连续识别失败: \(error.localizedDescription)") return false @@ -200,8 +191,12 @@ public class AzureAsrHelper: NSObject { /** * 停止连续语音识别 - * - * @return 是否成功停止 + * + * @return 停止操作是否成功 + */ + /** + * 停止连续语音识别 + * @return 停止操作是否成功 */ public func stopContinuousRecognition() -> Bool { guard speechConfig != nil else { @@ -224,11 +219,10 @@ public class AzureAsrHelper: NSObject { //pushAudioData(data: Data()) } - audioStream?.stopAudioCapture() + audioStream?.stopMicrophoneCapture() // 停止连续识别 try recognizer.stopContinuousRecognition() - - // 会话结束事件会设置_isContinuousRecognitionActive = false + print("stopContinuousRecognition") return true } catch { // 强制重置状态 @@ -236,7 +230,15 @@ public class AzureAsrHelper: NSObject { os_log("强制停止识别失败", log: log, type: .error) // 停止音频处理 - audioStream?.stopAudioCapture() + audioStream?.stopMicrophoneCapture() + + // 【修复】在 catch 块中安全地停止识别器,避免再次抛出异常 + do { + try recognizer.stopContinuousRecognition() + } catch { + // 如果强制停止也失败,记录错误但不再抛出异常 + os_log("强制停止识别器失败: %@", log: log, type: .error, error.localizedDescription) + } return false } @@ -264,8 +266,6 @@ public class AzureAsrHelper: NSObject { // 停止音频处理 audioStream?.releaseAudioResources() - // 停止录音 - recordfile?.closeFile(isSave: true) // 释放资源 recognizer = nil speechConfig = nil @@ -295,12 +295,9 @@ public class AzureAsrHelper: NSObject { do { print("设置音频配置 - 采样率: \(sampleRate), 声道: \(channels)") - // 设置音频流的配置 + // 设置音频接收器的配置 audioStream?.setAudioConfig(sampleRate: sampleRate, channels: channels) - // 设置录音文件的配置 - recordfile?.setAudioConfig(sampleRate: sampleRate, channels: channels) - print("音频配置设置成功") return true } catch { @@ -313,10 +310,6 @@ public class AzureAsrHelper: NSObject { /** * 设置识别器 */ - /** - * 设置识别器 - * 优化:避免重复创建音频流,复用已初始化的组件 - */ private func setupRecognizer() -> Bool { do { print("设置识别器\(_isContinuousRecognitionActive)") @@ -332,6 +325,17 @@ public class AzureAsrHelper: NSObject { setupMicrophoneStream() } + // 安全检查:确保 audioConfig 不为 nil + guard let audioConfig = audioConfig else { + os_log("音频配置未初始化,无法创建识别器", log: log, type: .error) + return false + } + + guard let speechConfig = speechConfig else { + os_log("语音配置未初始化,无法创建识别器", log: log, type: .error) + return false + } + // 清理旧的识别器 recognizer = nil @@ -342,22 +346,21 @@ public class AzureAsrHelper: NSObject { print("创建自动语言检测配置失败,使用默认语言配置") // 如果创建失败,使用默认的单语言配置 recognizer = try SPXSpeechRecognizer( - speechConfiguration: speechConfig!, - audioConfiguration: audioConfig! + speechConfiguration: speechConfig, + audioConfiguration: audioConfig ) return true } - - print("autoDetectConfig: \(autoDetectSourceLanguageConfig)") + print("创建自动语言识别器") recognizer = try SPXSpeechRecognizer( - speechConfiguration: speechConfig!, + speechConfiguration: speechConfig, autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, - audioConfiguration: audioConfig! + audioConfiguration: audioConfig ) } else { recognizer = try SPXSpeechRecognizer( - speechConfiguration: speechConfig!, - audioConfiguration: audioConfig! + speechConfiguration: speechConfig, + audioConfiguration: audioConfig ) } @@ -374,27 +377,20 @@ public class AzureAsrHelper: NSObject { * 设置麦克风流 - 使用推流方式 * 优化:避免重复初始化已存在的音频流 */ + /** + * 设置麦克风音频流 + */ private func setupMicrophoneStream() { - do { - // 【优化】检查是否已经初始化,避免重复创建 - if audioStream == nil { - audioStream = AudioStream(parentHelper: self) - audioStream?.initAudioRecord() - audioStream?.onAudioData = { [weak self] data in - self?.recordfile?.saveAudioDataToWav(data) - } - } - - // 【优化】检查音频配置是否已存在 - if audioConfig == nil, let pushStream = audioStream?.pushAudioStream { - audioConfig = SPXAudioConfiguration(streamInput: pushStream) - os_log("设置麦克风流完成", log: log, type: .info) - } - } catch { - os_log("设置麦克风流失败: %{public}@", log: log, type: .error, error.localizedDescription) - audioStream = nil - } + audioStream = SimpleAudioReceiver(parentHelper: self) + // 【优化】检查音频配置是否已存在 + if audioConfig == nil, let pushStream = audioStream?.pushAudioStream { + audioConfig = SPXAudioConfiguration(streamInput: pushStream) + os_log("设置麦克风流完成", log: log, type: .info) + } } + + + /** * 从语音识别结果中获取检测到的语言 * @param result 语音识别结果 @@ -409,26 +405,12 @@ public class AzureAsrHelper: NSObject { // 备用方案:从属性中获取语言 if let properties = result.properties, - let language = properties.getPropertyByName("SpeechServiceResponse_RecognitionLanguage"), + let language = properties.getPropertyByName("speechServiceResponse_RecognitionLanguage"), !language.isEmpty { print("从属性中检测到语言: \(language)") return language } - // 最后的备用方案:从JSON响应中解析 - if let properties = result.properties, - let jsonString = properties.getPropertyByName("SpeechServiceResponse_Json"), - let jsonData = jsonString.data(using: .utf8) { - do { - if let json = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any], - let language = json["Language"] as? String { - print("从JSON中检测到语言: \(language)") - return language - } - } catch { - print("JSON解析失败: \(error)") - } - } // 使用正确的SPXAutoDetectSourceLanguageResult初始化方法 do { let langResult = try SPXAutoDetectSourceLanguageResult(result) @@ -499,11 +481,6 @@ public class AzureAsrHelper: NSObject { let text = result.text print("正在识别事件=检测到语言: \(detectedLanguage), 识别中: \(text)") - // // 检测文本语言,避免重复调用 - // let detectedLanguage = self.detectTextLanguage(result.text ?? "") ?? "zh-CN" - - // // 输出检测到的语言信息 - // print("检测到的语言: \(detectedLanguage)") DispatchQueue.main.async { callback.onRecognizing(result.text ?? "", detectedLanguage) @@ -522,11 +499,6 @@ public class AzureAsrHelper: NSObject { let text = result.text print("识别完成事件=检测到语言: \(detectedLanguage), 识别中: \(text)") - // 检测文本语言,避免重复调用 - // let detectedLanguage = self.detectTextLanguage(result.text ?? "") ?? "zh-CN" - - // // 输出检测到的语言信息 - // print("检测到的语言: \(detectedLanguage)") // 在主线程回调结果 DispatchQueue.main.async { @@ -573,7 +545,7 @@ public class AzureAsrHelper: NSObject { do { print("禁用蓝牙音频功能,切换回正常音频模式") - audioStream?.setAudioOutputRoute(.speaker) + // audioStream?.disableBluetoothAudio() } catch { print("disableBluetoothAudio") } @@ -585,7 +557,7 @@ public class AzureAsrHelper: NSObject { do { print("恢复原始音频设备状态(通常是重新启用蓝牙)") - audioStream?.setAudioOutputRoute(.receiver) + //audioStream?.restoreOriginalAudioState() } catch { print("restoreOriginalAudioState") } @@ -595,13 +567,31 @@ public class AzureAsrHelper: NSObject { /** * 开启录音 + * @param filePath 录音文件路径 + * @param audioDataCallback 音频数据回调接口 */ - public func enableRecord(filePath: String) { + public func enableRecord(filePath: String, audioDataCallback: SimpleAudioReceiver.AudioDataCallback? = nil) { + os_log("开启录音: %{public}@", log: log, type: .info, filePath) + if audioStream == nil { + // 创建外部音频拉流对象 + audioStream = SimpleAudioReceiver(parentHelper: self) + audioStream?.initAudioRecord() + } - recordfile?.closeFile(isSave: true) - recordfile?.creatingFiles(atPath: filePath) // Fixed method call + if audioStream?.recordfile == nil { + audioStream?.recordfile = RecordFile() + } + + + // 修复:移除多余的 audioDataCallback 参数 + audioStream?.startAudioRecord( + audioSourceType: audioSourceType == .microphone ? .microphone : .external, + audioDataCallback: audioDataCallback + ) + audioStream?.recordfile?.closeFile(isSave: true) + audioStream?.recordfile?.creatingFiles(atPath: filePath) } /** @@ -610,7 +600,7 @@ public class AzureAsrHelper: NSObject { public func moveFile(sourcePath: String, destPath: String)-> Bool { - recordfile?.moveFile(from: sourcePath, to: destPath) // Fixed method call + audioStream?.recordfile?.moveFile(from: sourcePath, to: destPath) // Fixed method call return true } @@ -622,461 +612,47 @@ public class AzureAsrHelper: NSObject { - recordfile?.renameFile(at: filePath, to: newName) // Fixed method call + audioStream?.recordfile?.renameFile(at: filePath, to: newName) // Fixed method call return true } - /** - * 停止录音 + + + /** + * 暂停录音 */ public func pauseRecord() { - - - recordfile?.isPause = true + os_log("暂停录音", log: log, type: .info) + guard let audioStream = audioStream, audioStream.recordfile != nil else { + return + } + audioStream.stopMicrophoneCapture() } - - + /** - * 关闭录音 + * 继续录音 */ - public func stopRecord(isSave: Bool) { - - - recordfile?.isPause = false - recordfile?.closeFile(isSave: true) - } - - // MARK: - 音频流类 - - public class AudioStream: NSObject { - public private(set) var pushAudioStream: SPXPushAudioInputStream? - private let writeQueue = LinkedBlockingQueue() - private var audioEngine: AVAudioEngine? - private var audioFormat: AVAudioFormat? - private let audioSession = AVAudioSession.sharedInstance() - private var audioSourceType = AudioSourceType.microphone - private var isRunning = false - public var isWriting = false - private let bufferSize: Int = 4096 - private var writeThread: DispatchQueue? - private var currentRoute: AudioOutputRoute? - public var onAudioData: ((Data) -> Void)? - - // 添加对外部类的弱引用 - private weak var parentHelper: AzureAsrHelper? - - // 添加初始化方法,接收外部类引用 - init(parentHelper: AzureAsrHelper) { - self.parentHelper = parentHelper - super.init() - } - /** - * 初始化 - */ - /** - * 初始化音频录制组件 - * 包括音频格式、推流、音频引擎等核心组件的初始化 - */ - public func initAudioRecord() { - print("初始化了") - audioFormat = getOptimalAudioFormat() - pushAudioStream = SPXPushAudioInputStream() - - // 初始化音频引擎 - audioEngine = AVAudioEngine() - - isRunning = true - // 创建新的写线程 - writeThread = DispatchQueue(label: "audio.stream.writer") - startWriteThread() - //self.setAudioOutputRoute(.receiver) - } - - /// 获取最佳音频格式 (iOS 通常支持标准采样率) - public func getOptimalAudioFormat() -> AVAudioFormat? { - let sampleRate: Double = 16000 // iOS 通常支持 16kHz - return AVAudioFormat( - commonFormat: .pcmFormatInt16, - sampleRate: sampleRate, - channels: 1, - interleaved: true - ) - } - - /** - * 设置音频配置 - * @param sampleRate 采样率,默认16000 - * @param channels 声道数,默认1 - */ - public func setAudioConfig(sampleRate: Int, channels: Int) { - print("AudioStream设置音频配置 - 采样率: \(sampleRate), 声道: \(channels)") - - // 更新音频格式 - audioFormat = AVAudioFormat( - commonFormat: .pcmFormatInt16, - sampleRate: Double(sampleRate), - channels: AVAudioChannelCount(channels), - interleaved: true - ) - - // 如果已经有推流,重新创建 - if pushAudioStream != nil { - let audioStreamFormat = SPXAudioStreamFormat() - audioStreamFormat?.initUsingPCM(withSampleRate: UInt(sampleRate), bitsPerSample: 16, channels: UInt(channels)) - pushAudioStream = SPXPushAudioInputStream(audioFormat: audioStreamFormat) - } - - print("AudioStream音频配置设置完成") - } - /** - * 开始音频输入 - */ - public func startAudioInput(audioSourceType: AudioSourceType = .microphone) { - isWriting=true - self.audioSourceType = audioSourceType; - print("startAudioInput=audioSourceType\(audioSourceType)") - switch audioSourceType { - case .microphone: - runMicrophoneCapture() - case .external: - runExternalCapture() - } + public func resumeRecord() { + os_log("继续录音", log: log, type: .info) + guard let audioStream = audioStream, audioStream.recordfile != nil else { + return } - - /** - * 开启音频写入线程 - */ - public func startWriteThread() { - writeThread?.async { [weak self] in - guard let self = self else { return } - - while self.isRunning { - // print("是否写入: \(self.isWriting)") - if !self.isWriting { - usleep(10_000) - continue - } - - guard let dataToWrite = self.writeQueue.take() else { continue } - - // print("写入数据长度: \(dataToWrite.count)") - do { - try self.pushAudioStream?.write(dataToWrite) - self.onAudioData?(dataToWrite) -} catch { - print("推送音频数据失败: \(error.localizedDescription)") -} - - } - } - } - - /** - * 向音频流写入音频数据 - * 仅当音频源设置为external时有效 - * - * @param data 音频数据字节数组 - */ - public func saveAudioDataTo(data: Data) { - // 通过父类引用调用方法 - if audioSourceType != .external || !(parentHelper?.isContinuousRecognitionActive() ?? false) { - return - } - // print("外部data=\(data)") - // 放入队列,由写线程写入 - writeQueue.put(data) - } - -// 私有方法:启动麦克风捕获 -/** - * 启动麦克风捕获 - * 优化:复用已初始化的音频引擎,减少启动延迟 - */ -private func runMicrophoneCapture() { - do { - // 【优化】检查音频引擎是否已初始化,避免重复创建 - if audioEngine == nil { - audioEngine = AVAudioEngine() - } - - // 如果音频引擎正在运行,先停止 - if audioEngine?.isRunning == true { - audioEngine?.stop() - } - - // 获取音频输入节点(麦克风) - guard let inputNode = audioEngine?.inputNode else { - throw NSError(domain: "AudioSetup", code: 1, userInfo: [NSLocalizedDescriptionKey: "无法获取音频输入节点"]) - } - - // 移除之前的音频处理块,避免重复添加 - inputNode.removeTap(onBus: 0) - - // 获取硬件支持的原始音频格式 - let hardwareFormat = inputNode.inputFormat(forBus: 0) - - // iOS 13+ 启用语音处理 - if #available(iOS 13.0, *) { - try inputNode.setVoiceProcessingEnabled(true) - } - - // 检查目标音频格式和转换器是否可用 - guard let targetFormat = audioFormat, // 外部定义的期望音频格式 - let converter = AVAudioConverter(from: hardwareFormat, to: targetFormat) else { - throw NSError(domain: "AudioSetup", code: 2) - } - - // 在输入节点上安装录音回调 - inputNode.installTap(onBus: 0, - bufferSize: UInt32(bufferSize), // 每次回调的缓冲区大小 - format: hardwareFormat) { // 使用原始硬件格式 - [weak self] buffer, time in // 弱引用避免循环引用 - - // 确保实例存在且正在写入状态 - guard let self = self, self.isWriting else { return } - - // 创建目标格式的音频缓冲区 - let convertedBuffer = AVAudioPCMBuffer( - pcmFormat: targetFormat, - // 计算转换后的帧容量(考虑采样率差异) - frameCapacity: AVAudioFrameCount( - targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate - ) - )! - - var error: NSError? - // 执行音频格式转换 - let status = converter.convert( - to: convertedBuffer, - error: &error, - withInputFrom: { inNumPackets, outStatus in - outStatus.pointee = .haveData // 标记有数据可用 - return buffer // 返回原始音频数据 - } - ) - - // 转换成功且无错误 - if status == .haveData, error == nil { - // 将音频缓冲区转换为二进制数据 - let data = self.audioBufferToData(convertedBuffer) - // 将数据放入写入队列(后续处理) - self.writeQueue.put(data) - } - } - - // 激活音频会话(允许录音) - try audioSession.setActive(true, options: [.notifyOthersOnDeactivation]) - - // 启动音频引擎 - try audioEngine?.start() - } catch { - print("麦克风启动失败: \(error)") + audioStream.resumeRecord() } -} - private func audioBufferToData(_ buffer: AVAudioPCMBuffer) -> Data { - let frameLength = Int(buffer.frameLength) - let channelCount = 1 - let dataLength = frameLength * channelCount * MemoryLayout.size - - // Handle 16-bit integer format - if let int16Data = buffer.int16ChannelData { - return Data( - bytes: int16Data.pointee, - count: dataLength - ) - } - // Handle float format - else if let floatData = buffer.floatChannelData { - var int16Array = [Int16](repeating: 0, count: frameLength) - let floatBuffer = floatData.pointee - - for i in 0.. { - private var queue: [T] = [] - private var isClosed = false - - // 使用DispatchSemaphore实现阻塞行为 - private let availableItems: DispatchSemaphore - private let queueLock = NSLock() - - init() { - self.availableItems = DispatchSemaphore(value: 0) - } - - /** - * 阻塞式获取元素(等价于Android的take()) - * @return 队列中的元素,如果队列已关闭则返回nil - */ - func take() -> T? { - // 等待可用元素(阻塞直到有元素或队列关闭) - availableItems.wait() - - queueLock.lock() - defer { queueLock.unlock() } - - // 检查队列是否已关闭 - if isClosed && queue.isEmpty { - return nil - } - - // 获取第一个元素 - guard !queue.isEmpty else { - return nil - } - - return queue.removeFirst() - } - - /** - * 添加元素(等价于Android的put()) - * @param item 要添加的元素 - */ - func put(_ item: T) { - queueLock.lock() - defer { queueLock.unlock() } - - // 检查队列是否已关闭 - if isClosed { - return - } - - // 添加元素(无容量限制,与Android一致) - queue.append(item) - - // 通知有新元素可用 - availableItems.signal() - } - - /** - * 关闭队列 - * 关闭后不能再添加新元素,但可以继续取出已有元素 - */ - func close() { - queueLock.lock() - defer { queueLock.unlock() } - - isClosed = true - - // 唤醒所有等待的take()操作 - for _ in 0..<100 { // 假设最多100个等待者 - availableItems.signal() - } + public func stopRecord(isSave: Bool) { + guard let audioStream = audioStream, audioStream.recordfile != nil else { + return } + audioStream.stopMicrophoneCapture() + audioStream.recordfile?.isPause = false // + audioStream.recordfile?.closeFile(isSave: true) // } + + @@ -1177,4 +753,4 @@ private func runMicrophoneCapture() { // "languageName": languageName, // "originalCode": topResult.language // ] -// } \ No newline at end of file +// } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 4d18c9c71..97954579a 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -258,16 +258,37 @@ import os.log case "enableRecord": guard let args = call.arguments as? [String: Any], let filePath = args["filePath"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "filePath 参数不能为空", details: nil)) + result(FlutterError(code: "INVALID_ARGUMENTS", message: "文件路径不能为空", details: nil)) return } + + // 检查是否需要接受音频数据 + let acceptAudioData = args["acceptAudioData"] as? Bool ?? false + do { - print("音频文件名称为: \(filePath)") - try azureAsrHelper.enableRecord(filePath: filePath) + if acceptAudioData { + // 创建音频数据回调实现类 + let audioCallback = AudioDataCallbackImpl { [weak self] audioData in + // 构建音频数据事件映射 + let audioEvent: [String: Any] = [ + "type": "audioData", + "data": audioData, + "timestamp": Date().timeIntervalSince1970 * 1000, + "size": audioData.count + ] + // 发送音频数据事件到 Flutter 层 + self?.sendAsrEvent(audioEvent) + } + try azureAsrHelper.enableRecord(filePath: filePath, audioDataCallback: audioCallback) + } else { + try azureAsrHelper.enableRecord(filePath: filePath) + } result(true) } catch { result(FlutterError(code: "ENABLERECORD_ERROR", message: error.localizedDescription, details: nil)) } + + case "pauseRecord": azureAsrHelper.pauseRecord() @@ -574,3 +595,30 @@ extension AzureSpeechPlugin: AudioDataListener { sendTtsEvent(eventMap) } } + +// MARK: - 音频数据回调实现 +/** + * 音频数据回调实现类 + * 实现 SimpleAudioReceiver.AudioDataCallback 协议 + */ +private class AudioDataCallbackImpl: SimpleAudioReceiver.AudioDataCallback { + private let callback: (Data) -> Void + + /** + * 初始化音频数据回调实现 + * @param callback 音频数据处理闭包 + */ + init(callback: @escaping (Data) -> Void) { + self.callback = callback + } + + /** + * 音频数据回调方法 + * @param data 音频数据 + */ + func onAudio(_ data: Data) { + callback(data) + } +} + + diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/RecordFile.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift similarity index 100% rename from local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/RecordFile.swift rename to local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift new file mode 100644 index 000000000..25834b1bd --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift @@ -0,0 +1,534 @@ +import Foundation +import AVFoundation +import MicrosoftCognitiveServicesSpeech +import os.log + +/** + * 简单音频接收器类,用于处理音频录制和流传输 + * 对应Android的SimpleAudioReceiver功能 + */ +public class SimpleAudioReceiver: NSObject { + + private let tag = "SimpleAudioReceiver" + private let log = OSLog(subsystem: "com.azure.speech", category: "SimpleAudioReceiver") + + /** + * 音频来源类型 + */ + public enum AudioSourceType { + /** 使用设备麦克风 */ + case microphone + /** 使用外部提供的音频数据 */ + case external + } + + // MARK: - 音频流类 + + public private(set) var pushAudioStream: SPXPushAudioInputStream? + private let writeQueue = LinkedBlockingQueue() + private var audioEngine: AVAudioEngine? + private var audioFormat: AVAudioFormat? + private let audioSession = AVAudioSession.sharedInstance() + private var audioSourceType = AudioSourceType.microphone + private var audioDataCallback: AudioDataCallback? + private var isRunning = false + public var _isWriting = false + private let bufferSize: Int = 4096 + private var writeThread: DispatchQueue? + private var currentRoute: AudioOutputRoute? + //public var onAudioData: ((Data) -> Void)? + public var recordfile: RecordFile? + // 添加对外部类的弱引用 + private weak var parentHelper: AzureAsrHelper? + + // 添加初始化方法,接收外部类引用 + init(parentHelper: AzureAsrHelper) { + self.parentHelper = parentHelper + super.init() + } + /** + * 初始化 + */ + /** + * 初始化音频录制组件 + * 包括音频格式、推流、音频引擎等核心组件的初始化 + */ + public func initAudioRecord() { + print("初始化了") + audioFormat = getOptimalAudioFormat() + pushAudioStream = SPXPushAudioInputStream() + + // 初始化音频引擎 + audioEngine = AVAudioEngine() + + isRunning = true + // 创建新的写线程 + writeThread = DispatchQueue(label: "audio.stream.writer") + startWriteThread() + } + + /// 获取最佳音频格式 (iOS 通常支持标准采样率) + public func getOptimalAudioFormat() -> AVAudioFormat? { + let sampleRate: Double = 16000 // iOS 通常支持 16kHz + return AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: sampleRate, + channels: 1, + interleaved: true + ) + } + + /** + * 设置音频配置 + * @param sampleRate 采样率,默认16000 + * @param channels 声道数,默认1 + */ + /** + * 设置音频配置参数 + * @param sampleRate 采样率 + * @param channels 声道数 + */ + public func setAudioConfig(sampleRate: Int, channels: Int) { + print( + "AudioStream设置音频配置: sampleRate=\(sampleRate), channels=\(channels)" + ) + + // 如果已经有推流,重新创建 + if pushAudioStream != nil { + // 修复:安全解包 SPXAudioStreamFormat + guard let audioStreamFormat = SPXAudioStreamFormat.init( + usingPCMWithSampleRate: UInt(sampleRate), + bitsPerSample: 16, + channels: UInt(channels) + ) else { + print("创建音频流格式失败") + return + } + pushAudioStream = SPXPushAudioInputStream(audioFormat: audioStreamFormat) + } + + print("AudioStream音频配置设置完成") + } + /** + * 开始音频输入 + */ + public func startAudioRecord(audioSourceType: AudioSourceType = .microphone, audioDataCallback: AudioDataCallback?) { + _isWriting=true + self.audioSourceType = audioSourceType; + self.audioDataCallback = audioDataCallback + print("startAudioRecord=audioSourceType\(audioSourceType)") + switch audioSourceType { + case .microphone: + runMicrophoneCapture() + case .external: + runExternalCapture() + } + } + + /** + * 开启音频写入线程 + * 修复:使用userInitiated QoS避免优先级反转 + */ + public func startWriteThread() { + // 使用userInitiated QoS匹配音频录制线程的优先级 + writeThread = DispatchQueue(label: "audio.stream.writer", qos: .userInitiated) + + writeThread?.async { [weak self] in + guard let self = self else { return } + + while self.isRunning { + if !self._isWriting { + print("startWriteThread 111") + usleep(10_000) + continue + } + print("startWriteThread 222") + guard let dataToWrite = self.writeQueue.take() else { continue } + + do { + if let callback = self.audioDataCallback { + callback.onAudio(dataToWrite) + } + try self.pushAudioStream?.write(dataToWrite) + if self.recordfile != nil { + print("写入recordfile数据长度: \(dataToWrite.count)") + recordfile?.saveAudioDataToWav(dataToWrite) + } + print("写入数据长度: \(dataToWrite.count)") + } catch { + print("推送音频数据失败: \(error.localizedDescription)") + } + } + } + } + + /** + * 向音频流写入音频数据 + * 仅当音频源设置为external时有效 + * + * @param data 音频数据字节数组 + */ + public func saveAudioDataTo(data: Data) { + // 通过父类引用调用方法 + if audioSourceType != .external || !(parentHelper?.isContinuousRecognitionActive() ?? false) { + return + } + // print("外部data=\(data)") + // 放入队列,由写线程写入 + writeQueue.put(data) + } + + // 私有方法:启动麦克风捕获 + /** + * 启动麦克风捕获 + * 优化:复用已初始化的音频引擎,减少启动延迟 + */ + private func runMicrophoneCapture() { + do { + // 【新增】首先配置音频会话 + try audioSession.setCategory( + .playAndRecord, + mode: .measurement, // 使用 measurement 模式获得最佳录音质量 + options: [.allowBluetooth, .defaultToSpeaker] + ) + + // 【新增】请求麦克风权限(如果尚未授权) + if audioSession.recordPermission != .granted { + audioSession.requestRecordPermission { granted in + if !granted { + print("麦克风权限被拒绝") + } + } + } + + // 【新增】激活音频会话(在配置音频引擎之前) + try audioSession.setActive(true, options: [.notifyOthersOnDeactivation]) + + // 检查音频引擎是否已初始化,避免重复创建 + if audioEngine == nil { + audioEngine = AVAudioEngine() + } + + // 如果音频引擎正在运行,先停止 + if audioEngine?.isRunning == true { + audioEngine?.stop() + } + + // 获取音频输入节点(麦克风) + guard let inputNode = audioEngine?.inputNode else { + throw NSError(domain: "AudioSetup", code: 1, userInfo: [NSLocalizedDescriptionKey: "无法获取音频输入节点"]) + } + + // 移除之前的音频处理块,避免重复添加 + inputNode.removeTap(onBus: 0) + + // 获取硬件支持的原始音频格式 + let hardwareFormat = inputNode.inputFormat(forBus: 0) + + // iOS 13+ 启用语音处理 + if #available(iOS 13.0, *) { + try inputNode.setVoiceProcessingEnabled(true) + } + + // 检查目标音频格式和转换器是否可用 + guard let targetFormat = audioFormat, // 外部定义的期望音频格式 + let converter = AVAudioConverter(from: hardwareFormat, to: targetFormat) else { + throw NSError(domain: "AudioSetup", code: 2) + } + + // 在输入节点上安装录音回调 + inputNode.installTap(onBus: 0, + bufferSize: UInt32(bufferSize), // 每次回调的缓冲区大小 + format: hardwareFormat) { // 使用原始硬件格式 + [weak self] buffer, time in // 弱引用避免循环引用 + + // 确保实例存在且正在写入状态 + guard let self = self, self._isWriting else { return } + + // 创建目标格式的音频缓冲区 + let convertedBuffer = AVAudioPCMBuffer( + pcmFormat: targetFormat, + // 计算转换后的帧容量(考虑采样率差异) + frameCapacity: AVAudioFrameCount( + targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate + ) + )! + + var error: NSError? + // 执行音频格式转换 + let status = converter.convert( + to: convertedBuffer, + error: &error, + withInputFrom: { inNumPackets, outStatus in + outStatus.pointee = .haveData // 标记有数据可用 + return buffer // 返回原始音频数据 + } + ) + + // 转换成功且无错误 + if status == .haveData, error == nil { + // 将音频缓冲区转换为二进制数据 + let data = self.audioBufferToData(convertedBuffer) + // 将数据放入写入队列(后续处理) + self.writeQueue.put(data) + } + } + + // 激活音频会话(允许录音) + try audioSession.setActive(true, options: [.notifyOthersOnDeactivation]) + + try audioEngine?.start() + } catch { + print("麦克风启动失败: \(error)") + // 【新增】添加详细错误处理 + if let nsError = error as NSError? { + print("错误域: \(nsError.domain), 错误代码: \(nsError.code)") + print("错误描述: \(nsError.localizedDescription)") + } + } + } + private func audioBufferToData(_ buffer: AVAudioPCMBuffer) -> Data { + let frameLength = Int(buffer.frameLength) + let channelCount = 1 + let dataLength = frameLength * channelCount * MemoryLayout.size + + // Handle 16-bit integer format + if let int16Data = buffer.int16ChannelData { + return Data( + bytes: int16Data.pointee, + count: dataLength + ) + } + // Handle float format + else if let floatData = buffer.floatChannelData { + var int16Array = [Int16](repeating: 0, count: frameLength) + let floatBuffer = floatData.pointee + + for i in 0.. Bool { + return _isWriting + } + +} +// MARK: - iOS版LinkedBlockingQueue实现 +private class LinkedBlockingQueue { + private var queue: [T] = [] + private var isClosed = false + + // 使用DispatchSemaphore实现阻塞行为 + private let availableItems: DispatchSemaphore + private let queueLock = NSLock() + + init() { + self.availableItems = DispatchSemaphore(value: 0) + } + + /** + * 阻塞式获取元素(等价于Android的take()) + * @return 队列中的元素,如果队列已关闭则返回nil + */ + func take() -> T? { + // 等待可用元素(阻塞直到有元素或队列关闭) + availableItems.wait() + + queueLock.lock() + defer { queueLock.unlock() } + + // 检查队列是否已关闭 + if isClosed && queue.isEmpty { + return nil + } + + // 获取第一个元素 + guard !queue.isEmpty else { + return nil + } + + return queue.removeFirst() + } + + /** + * 添加元素(等价于Android的put()) + * @param item 要添加的元素 + */ + func put(_ item: T) { + queueLock.lock() + defer { queueLock.unlock() } + + // 检查队列是否已关闭 + if isClosed { + return + } + + // 添加元素(无容量限制,与Android一致) + queue.append(item) + + // 通知有新元素可用 + availableItems.signal() + } + + /** + * 关闭队列 + * 关闭后不能再添加新元素,但可以继续取出已有元素 + */ + func close() { + queueLock.lock() + defer { queueLock.unlock() } + + isClosed = true + + // 唤醒所有等待的take()操作 + for _ in 0..<100 { // 假设最多100个等待者 + availableItems.signal() + } + } +} + +// MARK: - 协议定义 +